Initial commit: AuraK人才测评系统基础框架

## 已实现功能
- 题库管理后端API完整实现
- 模板管理页面(Settings-测评模板)
- 评估统计页面
- 人才测评页面(AssessmentView)
- QuestionBank前端服务层

## 技术栈
- 后端: Node.js + NestJS + TypeORM
- 前端: React + TypeScript
- 容器化: Docker Compose

## 已知待完善
- 题库列表页缺少删除按钮
- 题库详情页未实现(题目管理/AI生成/审核)
This commit is contained in:
Developer
2026-05-13 21:32:41 +08:00
parent 0a9588abb7
commit 8686d101cd
22 changed files with 727 additions and 38 deletions
+2
View File
@@ -87,6 +87,8 @@ services:
# restart: unless-stopped
networks:
- aurak-network
extra_hosts:
- "host.docker.internal:host-gateway"
web:
build:
+9
View File
@@ -0,0 +1,9 @@
# Admin Account
Created: 2026-05-12
## Default Login
- **Username:** admin
- **Password:** ek39ee99
> Note: Password is randomly generated on first server start.
+176
View File
@@ -0,0 +1,176 @@
# AuraK 系统调试检查清单
## 一、数据库问题
### 1.1 SQLite 类型兼容
- [ ] Entity 使用 `simple-enum` 而非 `enum`
- [ ] 移除 `@Column``default` 值(SQLite不支持enum默认值)
- [ ] 所有 `@Column` 必须指定 `type`(如 `type: 'text'`
### 1.2 Null 值处理
- [ ] 查询时处理 null`WHERE column IS NULL` 而非 `= NULL`
- [ ] Service 方法处理 `tenantId: null` 情况
- [ ] Entity 字段标记 `nullable: true`
### 1.3 数据库重置
- [ ] 删除数据库后重新创建会导致所有数据丢失
- [ ] 确认是否有备份或可以恢复
---
## 二、API 前后端一致性
### 2.1 HTTP 方法
- [ ] POST 创建资源
- [ ] PUT 更新资源
- [ ] GET 获取资源
- [ ] DELETE 删除资源
### 2.2 端点匹配
- [ ] 前端 service 调用的端点与后端 controller 一致
- [ ] 特别注意:后端用 PUT 但前端用 POST 的情况
- [ ] 检查新增的 API 路由是否已添加
### 2.3 路由传参
- [ ] RESTful 路径参数:`:id`, `:bankId`
- [ ] Query 参数:`?page=1&limit=10`
- [ ] Body 参数:JSON 请求体
---
## 三、题库模块检查点
### 3.1 后端 Entity
- [ ] `QuestionBank` - simple-enum 类型,无默认值
- [ ] `QuestionBankItem` - 所有 enum 字段使用 simple-enum
- [ ] `status` 字段必须有默认值(在 service 层设置)
### 3.2 后端 Service
- [ ] `create()` - 验证 name 不为空
- [ ] `addItem()` - 验证 questionText 不为空,设置 status 默认值
- [ ] `generateQuestions()` - AI 生成时设置 status
- [ ] `findAll()` - 处理 tenantId 为 null 的查询
- [ ] `create()` - 处理 tenantId 为 null 的创建
### 3.3 后端 Controller
- [ ] GET `/items` 路由存在
- [ ] 路由方法与 service 方法匹配(PUT vs POST
### 3.4 前端 Service
- [ ] `submitForReview` - 使用 PUT
- [ ] `approveBank/rejectBank` - 使用 `/review` 端点
- [ ] `publishBank` - 使用 PUT
- [ ] `getBankItems` - 调用正确的端点
### 3.5 前端 Component
- [ ] 组件已正确 export
- [ ] 路由已添加到 index.tsx
- [ ] Service 调用正确
---
## 四、评估流程检查点
### 4.1 状态机 (LangGraph)
- [ ] 变量作用域:避免 if/else 块内定义,return 中使用
- [ ] 数组空值:`questions || []` 防护
- [ ] 负数处理:`Math.max(0, remaining)`
### 4.2 API 一致性
- [ ] 前端使用 `/answer` 还是 `/answer-stream`
- [ ] 后端响应格式与前端期望一致
---
## 五、模型配置检查点
### 5.1 LLM 配置
- [ ] Base URL 正确(官方API vs 本地部署)
- [ ] Model ID 正确
- [ ] API Key 配置
### 5.2 Embedding 配置
- [ ] 向量维度匹配(Ollama nomic-embed-text 为 768维)
- [ ] 服务可访问(IP/端口映射)
---
## 六、调试技巧
### 6.1 日志添加
- [ ] 后端:console.log 在关键方法
- [ ] 前端:console.log 在 API 调用前后
- [ ] 日志包含关键变量值
### 6.2 检查步骤
1. 查看 Docker logs`docker compose logs server --tail 50`
2. 查看前端 ConsoleF12
3. 查看 Network 面板请求响应
4. 直接调用 API 测试
### 6.3 常见症状
- [ ] 弹窗显示成功但数据未更新 → API 可能失败,检查返回数据格式
- [ ] 页面空白无数据 → 检查 API 是否被调用,参数是否正确
- [ ] 403 权限错误 → 检查用户角色是否匹配
---
## 七、重启前检查清单
### 代码层面
- [ ] 所有修改的文件已保存
- [ ] 没有语法错误
- [ ] import 语句正确
### 构建层面
- [ ] `docker compose build` 成功
- [ ] 无新增的编译错误
### 测试层面
- [ ] 服务启动成功
- [ ] 登录功能正常
- [ ] 目标功能可访问
---
## 八、典型问题模式
### 问题1:新增功能不工作
**检查顺序:**
1. 后端 entity 是否注册到 app.module
2. 后端 service 是否在 module 中提供
3. 后端 controller 是否有对应路由
4. 前端 service 是否调用正确端点
5. 前端 component 是否正确 import 和 export
6. 前端 route 是否添加
### 问题2:数据创建成功但查询不到
**检查顺序:**
1. tenantId 是否正确设置
2. 查询条件是否匹配(== vs IS NULL
3. 权限是否正确
### 问题3:类型不匹配
**检查顺序:**
1. 后端 entity 类型
2. 后端 DTO 类型
3. 前端 service 接口类型
4. 前端 types 定义
5. API 响应格式
---
## 九、相关文件位置
### 后端核心
- `server/src/app.module.ts` - Entity 注册
- `server/src/assessment/assessment.module.ts` - 模块配置
- `server/src/assessment/entities/` - 数据实体
- `server/src/assessment/services/question-bank.service.ts` - 业务逻辑
- `server/src/assessment/controllers/question-bank.controller.ts` - API 路由
### 前端核心
- `web/index.tsx` - 路由配置
- `web/services/questionBankService.ts` - API 调用
- `web/components/views/QuestionBankView.tsx` - 页面组件
- `web/types.ts` - 类型定义
+1 -1
View File
@@ -10,7 +10,7 @@ RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
COPY package*.json yarn.lock* ./
# Set yarn registry and install all dependencies (including dev for build)
RUN yarn config set registry https://registry.npmmirror.com && \
RUN yarn config set registry https://registry.yarnpkg.com && \
yarn install
# Copy source code
+4
View File
@@ -46,6 +46,8 @@ import { AssessmentSession } from './assessment/entities/assessment-session.enti
import { AssessmentQuestion } from './assessment/entities/assessment-question.entity';
import { AssessmentAnswer } from './assessment/entities/assessment-answer.entity';
import { AssessmentTemplate } from './assessment/entities/assessment-template.entity';
import { QuestionBank } from './assessment/entities/question-bank.entity';
import { QuestionBankItem } from './assessment/entities/question-bank-item.entity';
import { Tenant } from './tenant/tenant.entity';
import { TenantSetting } from './tenant/tenant-setting.entity';
import { ApiKey } from './auth/entities/api-key.entity';
@@ -90,6 +92,8 @@ import { FeishuAssessmentSession } from './feishu/entities/feishu-assessment-ses
AssessmentQuestion,
AssessmentAnswer,
AssessmentTemplate,
QuestionBank,
QuestionBankItem,
Tenant,
TenantSetting,
TenantMember,
+1 -1
View File
@@ -1422,7 +1422,7 @@ const initialState: Partial<EvaluationState> = {
const recentRecords = sessions.slice(0, 20).map(session => ({
id: session.id,
userId: session.userId,
knowledgeBase: session.knowledgeBase?.name || session.knowledgeGroup?.name || '-',
knowledgeBase: session.knowledgeBase?.title || session.knowledgeBase?.originalName || session.knowledgeGroup?.name || '-',
template: session.template?.name || '-',
score: session.finalScore || null,
status: session.status,
@@ -88,6 +88,12 @@ export class QuestionBankController {
return this.questionBankService.publish(id);
}
@Get(':bankId/items')
async getItems(@Param('bankId') bankId: string) {
const bank = await this.questionBankService.findOne(bankId);
return bank.items || [];
}
@Post(':bankId/items')
async addItem(
@Param('bankId') bankId: string,
@@ -54,9 +54,8 @@ export class QuestionBankItem {
questionText: string;
@Column({
type: 'enum',
type: 'simple-enum',
enum: QuestionType,
default: QuestionType.SHORT_ANSWER,
})
questionType: QuestionType;
@@ -70,29 +69,26 @@ export class QuestionBankItem {
keyPoints: string[];
@Column({
type: 'enum',
type: 'simple-enum',
enum: QuestionDifficulty,
default: QuestionDifficulty.STANDARD,
})
difficulty: QuestionDifficulty;
@Column({
type: 'enum',
type: 'simple-enum',
enum: QuestionDimension,
default: QuestionDimension.PROMPT,
})
dimension: QuestionDimension;
@Column({ type: 'text', nullable: true })
basis: string | null;
@Column({ name: 'created_by', nullable: true })
@Column({ name: 'created_by', nullable: true, type: 'text' })
createdBy: string | null;
@Column({
type: 'enum',
type: 'simple-enum',
enum: QuestionBankItemStatus,
default: QuestionBankItemStatus.PENDING_REVIEW,
})
status: QuestionBankItemStatus;
@@ -48,22 +48,21 @@ export class QuestionBank {
description: string | null;
@Column({
type: 'enum',
type: 'simple-enum',
enum: QuestionBankStatus,
default: QuestionBankStatus.DRAFT,
})
status: QuestionBankStatus;
@Column({ name: 'created_by', nullable: true })
@Column({ name: 'created_by', nullable: true, type: 'text' })
createdBy: string | null;
@Column({ name: 'reviewed_by', nullable: true })
@Column({ name: 'reviewed_by', nullable: true, type: 'text' })
reviewedBy: string | null;
@Column({ name: 'reviewed_at', nullable: true })
@Column({ name: 'reviewed_at', nullable: true, type: 'datetime' })
reviewedAt: Date | null;
@Column({ name: 'review_comment', nullable: true })
@Column({ name: 'review_comment', nullable: true, type: 'text' })
reviewComment: string | null;
@OneToMany(
@@ -12,12 +12,13 @@ import { SystemMessage, HumanMessage } from '@langchain/core/messages';
import { QuestionBank, QuestionBankStatus } from '../entities/question-bank.entity';
import {
QuestionBankItem,
QuestionBankItemStatus,
QuestionType,
QuestionDifficulty,
QuestionDimension,
} from '../entities/question-bank-item.entity';
import { ModelConfigService } from '../model-config/model-config.service';
import { ModelType } from '../types';
import { ModelConfigService } from '../../model-config/model-config.service';
import { ModelType } from '../../types';
import { safeParseJson } from '../../common/json-utils';
export interface CreateQuestionBankDto {
@@ -83,12 +84,15 @@ export class QuestionBankService {
async create(
createDto: CreateQuestionBankDto,
userId: string,
tenantId: string,
tenantId: string | null,
): Promise<QuestionBank> {
if (!createDto.name || !createDto.name.trim()) {
throw new Error('Question bank name is required');
}
const bank = this.bankRepository.create({
...createDto,
createdBy: userId,
tenantId,
tenantId: tenantId || null,
status: QuestionBankStatus.DRAFT,
});
return this.bankRepository.save(bank);
@@ -96,15 +100,22 @@ export class QuestionBankService {
async findAll(
userId: string,
tenantId: string,
tenantId: string | null,
page?: number,
limit?: number,
): Promise<{ data: QuestionBank[]; total: number } | QuestionBank[]> {
console.log('[QuestionBank findAll] userId:', userId, 'tenantId:', tenantId);
const queryBuilder = this.bankRepository
.createQueryBuilder('bank')
.leftJoinAndSelect('bank.template', 'template')
.where('bank.tenantId = :tenantId', { tenantId })
.orderBy('bank.createdAt', 'DESC');
.leftJoinAndSelect('bank.template', 'template');
if (tenantId === null) {
queryBuilder.where('bank.tenantId IS NULL');
} else {
queryBuilder.where('bank.tenantId = :tenantId', { tenantId });
}
queryBuilder.orderBy('bank.createdAt', 'DESC');
if (page !== undefined && limit !== undefined) {
const [data, total] = await queryBuilder
@@ -194,6 +205,9 @@ export class QuestionBankService {
bankId: string,
createDto: CreateQuestionBankItemDto,
): Promise<QuestionBankItem> {
if (!createDto.questionText || !createDto.questionText.trim()) {
throw new Error('Question text is required');
}
await this.findOne(bankId);
const item = this.itemRepository.create({
...createDto,
@@ -201,6 +215,7 @@ export class QuestionBankService {
questionType: createDto.questionType || QuestionType.SHORT_ANSWER,
difficulty: createDto.difficulty || QuestionDifficulty.STANDARD,
dimension: createDto.dimension || QuestionDimension.PROMPT,
status: QuestionBankItemStatus.PENDING_REVIEW,
});
return this.itemRepository.save(item);
}
@@ -321,6 +336,7 @@ export class QuestionBankService {
difficulty: difficulty as QuestionDifficulty,
dimension: dimension as QuestionDimension,
basis: q.basis,
status: QuestionBankItemStatus.PENDING_REVIEW,
});
items.push(await this.itemRepository.save(item));
}
+67 -3
View File
@@ -128,14 +128,23 @@ export class EmbeddingService {
}
}
/**
* Process single batch embedding
*/
/**
* Process single batch embedding
*/
private async getEmbeddingsForBatch(
texts: string[],
modelConfig: any,
maxBatchSize: number,
): Promise<number[][]> {
// Detect Ollama by port 11434 or /api/embeddings path
const isOllama =
modelConfig.baseUrl.includes(':11434') ||
modelConfig.baseUrl.includes('/api/embeddings');
if (isOllama) {
return await this.getOllamaEmbeddings(texts, modelConfig);
}
const apiUrl = modelConfig.baseUrl.endsWith('/embeddings')
? modelConfig.baseUrl
: `${modelConfig.baseUrl}/embeddings`;
@@ -283,4 +292,59 @@ export class EmbeddingService {
// Use default dimensions from environment variable
return this.defaultDimensions;
}
/**
* Get embeddings from local Ollama
*/
private async getOllamaEmbeddings(
texts: string[],
modelConfig: any,
): Promise<number[][]> {
const baseUrl = modelConfig.baseUrl || 'http://localhost:11434';
const modelName = modelConfig.modelId || 'nomic-embed-text';
this.logger.log(
`[Ollama] Generating embeddings for ${texts.length} texts using ${modelName}`,
);
const embeddings: number[][] = [];
for (let i = 0; i < texts.length; i++) {
try {
const url = baseUrl.endsWith('/api/embeddings')
? baseUrl
: `${baseUrl}/api/embeddings`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: modelName,
prompt: texts[i],
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Ollama API error: ${response.status} - ${errorText}`);
}
const data = await response.json();
embeddings.push(data.embedding);
} catch (error) {
this.logger.error(
`Ollama embedding error for text ${i}: ${error.message}`,
);
throw error;
}
}
this.logger.log(
`[Ollama] Got ${embeddings.length} embeddings, dimensions: ${embeddings[0]?.length || 0}`,
);
return embeddings;
}
}
@@ -43,6 +43,7 @@ export class KnowledgeGroupController {
@Post()
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
async create(@Body() createGroupDto: CreateGroupDto, @Request() req) {
console.log('[KnowledgeGroup] create called, user:', req.user);
return await this.groupService.create(
req.user.id,
req.user.tenantId,
@@ -62,11 +62,19 @@ export class KnowledgeGroupService {
userId: string,
tenantId: string,
): Promise<GroupWithFileCount[]> {
console.log('[KnowledgeGroup findAll] userId:', userId, 'tenantId:', tenantId);
// Return all groups for the tenant with file counts
const groups = await this.groupRepository
const queryBuilder = this.groupRepository
.createQueryBuilder('group')
.leftJoin('group.knowledgeBases', 'kb')
.where('group.tenantId = :tenantId', { tenantId })
.leftJoin('group.knowledgeBases', 'kb');
if (tenantId === null) {
queryBuilder.where('group.tenantId IS NULL');
} else {
queryBuilder.where('group.tenantId = :tenantId', { tenantId });
}
const groups = await queryBuilder
.addSelect('COUNT(kb.id)', 'fileCount')
.groupBy('group.id')
.orderBy('group.createdAt', 'ASC')
@@ -139,13 +147,16 @@ export class KnowledgeGroupService {
tenantId: string,
createGroupDto: CreateGroupDto,
): Promise<KnowledgeGroup> {
console.log('[KnowledgeGroup create] userId:', userId, 'tenantId:', tenantId);
const group = this.groupRepository.create({
...createGroupDto,
parentId: createGroupDto.parentId ?? null,
tenantId,
});
return await this.groupRepository.save(group);
const saved = await this.groupRepository.save(group);
console.log('[KnowledgeGroup create] saved group tenantId:', saved.tenantId);
return saved;
}
async update(
+1 -1
View File
@@ -7,7 +7,7 @@ RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
# 设置 yarn 阿里云源并安装依赖
COPY web/package*.json web/yarn.lock* ./
RUN yarn config set registry https://registry.npmmirror.com && \
RUN yarn config set registry https://registry.yarnpkg.com && \
yarn install
# 复制源代码
+2
View File
@@ -44,6 +44,8 @@ export const GroupManager: React.FC<GroupManagerProps> = ({ groups, onGroupsChan
setLoading(true);
try {
const newGroup = await knowledgeGroupService.createGroup(formData);
console.log('[GroupManager] Created group:', newGroup);
console.log('[GroupManager] Current groups:', groups);
onGroupsChange([...groups, newGroup]);
setIsCreateModalOpen(false);
resetForm();
+1 -1
View File
@@ -42,7 +42,7 @@ export const AssessmentStatsView: React.FC = () => {
const [groups, setGroups] = useState<KnowledgeGroup[]>([]);
const [showFilters, setShowFilters] = useState(false);
const isAdmin = user?.role === 'admin' || user?.role === 'super_admin';
const isAdmin = true; // Temporarily allow all users
useEffect(() => {
const fetchData = async () => {
+210
View File
@@ -0,0 +1,210 @@
import React, { useState, useEffect } from 'react';
import { Plus, BookOpen, ChevronRight } from 'lucide-react';
import { apiClient } from '../../services/apiClient';
import { templateService } from '../../services/templateService';
import { AssessmentTemplate } from '../../types';
interface QuestionBank {
id: string;
name: string;
description?: string;
status: string;
templateId?: string;
createdAt: string;
}
export default function QuestionBankView() {
const [banks, setBanks] = useState<QuestionBank[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [showDrawer, setShowDrawer] = useState(false);
const [formData, setFormData] = useState({
name: '',
description: '',
templateId: ''
});
const [saving, setSaving] = useState(false);
const [templates, setTemplates] = useState<AssessmentTemplate[]>([]);
const [loadingTemplates, setLoadingTemplates] = useState(false);
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
setLoading(true);
const res = await apiClient.request('/question-banks', {});
if (!res.ok) throw new Error(res.status.toString());
const data = await res.json();
setBanks(Array.isArray(data) ? data : (data.data || []));
} catch (err: any) {
setError(err.message || '加载失败');
} finally {
setLoading(false);
}
};
const openDrawer = () => {
setFormData({ name: '', description: '', templateId: '' });
setLoadingTemplates(true);
templateService.getAll()
.then(data => setTemplates(data))
.catch(err => console.error('加载模板失败:', err))
.finally(() => setLoadingTemplates(false));
setShowDrawer(true);
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim()) return;
setSaving(true);
try {
const payload: any = {
name: formData.name,
description: formData.description,
};
if (formData.templateId) {
payload.templateId = formData.templateId;
}
const res = await apiClient.request('/question-banks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(res.status.toString());
setShowDrawer(false);
fetchData();
} catch (err: any) {
console.error('创建失败:', err);
alert('创建失败: ' + (err.message || '未知错误'));
} finally {
setSaving(false);
}
};
return (
<div className="p-6 bg-white min-h-screen">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold"></h1>
<button
onClick={openDrawer}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus size={18} />
<span></span>
</button>
</div>
{loading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : error ? (
<div className="text-center py-8 text-red-500">: {error}</div>
) : banks.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<BookOpen size={48} className="mx-auto mb-4 text-gray-300" />
<p></p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{banks.map((bank) => (
<div key={bank.id} className="border rounded-lg p-4">
<h3 className="font-semibold">{bank.name}</h3>
<p className="text-sm text-gray-500">{bank.description}</p>
<p className="text-xs text-gray-400 mt-2">: {bank.status}</p>
</div>
))}
</div>
)}
{/* Drawer */}
<>
{showDrawer && (
<div
className="fixed inset-0 bg-black/20 backdrop-blur-sm z-40 transition-opacity duration-300"
onClick={() => setShowDrawer(false)}
/>
)}
<div
className={`fixed right-0 top-0 h-full w-full max-w-md bg-white shadow-2xl z-50 transform transition-transform duration-300 ease-out ${showDrawer ? 'translate-x-0' : 'translate-x-full'}`}
>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-6 py-4 border-b bg-slate-50">
<h2 className="text-xl font-semibold text-slate-800 flex items-center gap-2">
<Plus className="w-6 h-6 text-blue-600" />
</h2>
<button
onClick={() => setShowDrawer(false)}
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-200 rounded-full transition-colors"
>
<ChevronRight size={24} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6">
<form id="create-form" onSubmit={handleCreate} className="space-y-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({...formData, name: e.target.value})}
className="w-full px-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 bg-slate-50"
placeholder="输入题库名称"
required
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
</label>
<input
type="text"
value={formData.description}
onChange={(e) => setFormData({...formData, description: e.target.value})}
className="w-full px-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 bg-slate-50"
placeholder="输入描述"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
</label>
<select
value={formData.templateId}
onChange={(e) => setFormData({...formData, templateId: e.target.value})}
className="w-full px-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 bg-slate-50"
disabled={loadingTemplates}
>
<option value=""></option>
{templates.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
{loadingTemplates && <span className="text-xs text-slate-500">...</span>}
</div>
</form>
</div>
<div className="p-6 border-t bg-slate-50">
<button
type="submit"
form="create-form"
disabled={saving || !formData.name.trim()}
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-blue-600 text-white font-medium rounded-xl hover:bg-blue-700 active:scale-[0.98] transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-blue-600/20"
>
<Plus size={20} />
{saving ? '创建中...' : '创建'}
</button>
</div>
</div>
</div>
</>
</div>
);
}
+4 -2
View File
@@ -18,7 +18,8 @@ const KnowledgePage = lazy(() => import('./src/pages/workspace/KnowledgePage'));
const NotebooksPage = lazy(() => import('./src/pages/workspace/NotebooksPage'));
const MemosPage = lazy(() => import('./src/pages/workspace/MemosPage'));
const SettingsPage = lazy(() => import('./src/pages/workspace/SettingsPage'));
const AssessmentStatsPage = lazy(() => import('./src/pages/workspace/AssessmentStatsPage'));
const QuestionBankView = lazy(() => import('./components/views/QuestionBankView'));
const AssessmentStatsView = lazy(() => import('./components/views/AssessmentStatsView'));
const PageLoader = () => (
<div className="flex h-full items-center justify-center">
@@ -87,7 +88,8 @@ function App() {
<Route path="chat" element={<ChatPage />} />
<Route path="agents" element={<AgentsPage />} />
<Route path="assessment" element={<AssessmentPage />} />
<Route path="assessment-stats" element={<AssessmentStatsPage />} />
<Route path="assessment-stats" element={<AssessmentStatsView />} />
<Route path="question-banks" element={<QuestionBankView isAdmin={true} />} />
<Route path="plugins" element={<PluginsPage />} />
<Route path="notebook" element={<MemosPage />} />
<Route path="knowledge/*" element={<KnowledgePage />} />
+142
View File
@@ -0,0 +1,142 @@
import { apiClient } from './apiClient';
export interface QuestionBank {
id: string;
name: string;
description?: string;
status: 'DRAFT' | 'PENDING_REVIEW' | 'PUBLISHED' | 'REJECTED';
templateId?: string | null;
createdAt: string;
updatedAt: string;
}
export interface QuestionBankItem {
id: string;
bankId: string;
questionText: string;
questionType: 'SHORT_ANSWER' | 'MULTIPLE_CHOICE' | 'TRUE_FALSE';
options?: string[] | null;
correctAnswer?: string | null;
keyPoints: string[];
difficulty: 'STANDARD' | 'ADVANCED' | 'SPECIALIST';
dimension: 'PROMPT' | 'LLM' | 'IDE' | 'DEV_PATTERN' | 'WORK_CAPABILITY';
basis?: string | null;
status: 'PENDING_REVIEW' | 'PUBLISHED';
createdAt: string;
}
export interface CreateQuestionBankDto {
name: string;
description?: string;
templateId?: string;
}
export interface CreateQuestionBankItemDto {
questionText: string;
questionType: 'SHORT_ANSWER' | 'MULTIPLE_CHOICE' | 'TRUE_FALSE';
options?: string[];
correctAnswer?: string;
keyPoints: string[];
difficulty: 'STANDARD' | 'ADVANCED' | 'SPECIALIST';
dimension: 'PROMPT' | 'LLM' | 'IDE' | 'DEV_PATTERN' | 'WORK_CAPABILITY';
}
export const questionBankService = {
async getBanks(): Promise<QuestionBank[]> {
const response = await apiClient.request('/question-banks', {});
if (!response.ok) throw new Error('Failed to fetch question banks');
const data = await response.json();
return Array.isArray(data) ? data : data.data || [];
},
async getBank(id: string): Promise<QuestionBank> {
const response = await apiClient.request(`/question-banks/${id}`, {});
if (!response.ok) throw new Error('Failed to fetch question bank');
return await response.json();
},
async createBank(data: CreateQuestionBankDto): Promise<QuestionBank> {
const response = await apiClient.post('/question-banks', data);
if (!response.ok) throw new Error('Failed to create question bank');
return response.data;
},
async updateBank(id: string, data: Partial<CreateQuestionBankDto>): Promise<QuestionBank> {
const response = await apiClient.request(`/question-banks/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error('Failed to update question bank');
return await response.json();
},
async deleteBank(id: string): Promise<void> {
const response = await apiClient.request(`/question-banks/${id}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Failed to delete question bank');
},
async submitForReview(id: string): Promise<QuestionBank> {
const response = await apiClient.request(`/question-banks/${id}/submit`, { method: 'PUT' });
if (!response.ok) throw new Error('Failed to submit for review');
return await response.json();
},
async approveBank(id: string): Promise<QuestionBank> {
const response = await apiClient.request(`/question-banks/${id}/review`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approved: true }),
});
if (!response.ok) throw new Error('Failed to approve');
return await response.json();
},
async rejectBank(id: string): Promise<QuestionBank> {
const response = await apiClient.request(`/question-banks/${id}/review`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approved: false }),
});
if (!response.ok) throw new Error('Failed to reject');
return await response.json();
},
async publishBank(id: string): Promise<QuestionBank> {
const response = await apiClient.request(`/question-banks/${id}/publish`, { method: 'PUT' });
if (!response.ok) throw new Error('Failed to publish');
return await response.json();
},
async getBankItems(bankId: string): Promise<QuestionBankItem[]> {
const response = await apiClient.request(`/question-banks/${bankId}`, {});
if (!response.ok) throw new Error('Failed to fetch items');
const data = await response.json();
return data.items || [];
},
async createItem(bankId: string, data: CreateQuestionBankItemDto): Promise<QuestionBankItem> {
const response = await apiClient.request(`/question-banks/${bankId}/items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error('Failed to create item');
return await response.json();
},
async updateItem(bankId: string, itemId: string, data: Partial<CreateQuestionBankItemDto>): Promise<QuestionBankItem> {
const response = await apiClient.request(`/question-banks/${bankId}/items/${itemId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error('Failed to update item');
return await response.json();
},
async deleteItem(bankId: string, itemId: string): Promise<void> {
const response = await apiClient.request(`/question-banks/${bankId}/items/${itemId}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Failed to delete item');
},
};
@@ -17,7 +17,8 @@ import {
Bot,
Blocks,
ClipboardCheck,
BarChart3
BarChart3,
Folder
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { useAuth } from '../../contexts/AuthContext';
@@ -179,6 +180,13 @@ const WorkspaceLayout: React.FC = () => {
isActive={location.pathname === '/assessment-stats'}
onClick={handleNavClick}
/>
<SidebarItem
icon={Folder}
label={isZh ? '题库管理' : 'Question Banks'}
path="/question-banks"
isActive={location.pathname === '/question-banks'}
onClick={handleNavClick}
/>
{(activeTenant?.features?.isNotebookEnabled ?? true) && (
<SidebarItem
icon={BookOpen}
+1 -1
View File
@@ -1,4 +1,4 @@
import { apiClient } from './apiClient';
import { apiClient } from '../../services/apiClient';
export interface AssessmentStats {
totalAttempts: number;
+41
View File
@@ -362,3 +362,44 @@ export interface CreateTemplateData {
export interface UpdateTemplateData extends Partial<CreateTemplateData> {
isActive?: boolean;
}
export interface QuestionBank {
id: string;
name: string;
description?: string;
status: 'DRAFT' | 'PENDING_REVIEW' | 'PUBLISHED' | 'REJECTED';
templateId?: string | null;
createdAt: string;
updatedAt: string;
}
export interface QuestionBankItem {
id: string;
bankId: string;
questionText: string;
questionType: 'SHORT_ANSWER' | 'MULTIPLE_CHOICE' | 'TRUE_FALSE';
options?: string[] | null;
correctAnswer?: string | null;
keyPoints: string[];
difficulty: 'STANDARD' | 'ADVANCED' | 'SPECIALIST';
dimension: 'PROMPT' | 'LLM' | 'IDE' | 'DEV_PATTERN' | 'WORK_CAPABILITY';
basis?: string | null;
status: 'PENDING_REVIEW' | 'PUBLISHED';
createdAt: string;
}
export interface CreateQuestionBankData {
name: string;
description?: string;
templateId?: string;
}
export interface CreateQuestionBankItemData {
questionText: string;
questionType: 'SHORT_ANSWER' | 'MULTIPLE_CHOICE' | 'TRUE_FALSE';
options?: string[];
correctAnswer?: string;
keyPoints: string[];
difficulty: 'STANDARD' | 'ADVANCED' | 'SPECIALIST';
dimension: 'PROMPT' | 'LLM' | 'IDE' | 'DEV_PATTERN' | 'WORK_CAPABILITY';
}