diff --git a/docker-compose.yml b/docker-compose.yml index 549e3c1..c1b5055 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -87,6 +87,8 @@ services: # restart: unless-stopped networks: - aurak-network + extra_hosts: + - "host.docker.internal:host-gateway" web: build: diff --git a/docs/admin-credentials.md b/docs/admin-credentials.md new file mode 100644 index 0000000..4184107 --- /dev/null +++ b/docs/admin-credentials.md @@ -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. \ No newline at end of file diff --git a/docs/debugging-checklist.md b/docs/debugging-checklist.md new file mode 100644 index 0000000..69bf3a7 --- /dev/null +++ b/docs/debugging-checklist.md @@ -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. 查看前端 Console(F12) +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` - 类型定义 \ No newline at end of file diff --git a/server/Dockerfile b/server/Dockerfile index 0db2c51..a494947 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -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 diff --git a/server/src/app.module.ts b/server/src/app.module.ts index e984ce4..3647a71 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -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, diff --git a/server/src/assessment/assessment.service.ts b/server/src/assessment/assessment.service.ts index 3a9f1bc..977ce75 100644 --- a/server/src/assessment/assessment.service.ts +++ b/server/src/assessment/assessment.service.ts @@ -1422,7 +1422,7 @@ const initialState: Partial = { 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, diff --git a/server/src/assessment/controllers/question-bank.controller.ts b/server/src/assessment/controllers/question-bank.controller.ts index 37672b5..6dcbc1a 100644 --- a/server/src/assessment/controllers/question-bank.controller.ts +++ b/server/src/assessment/controllers/question-bank.controller.ts @@ -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, diff --git a/server/src/assessment/entities/question-bank-item.entity.ts b/server/src/assessment/entities/question-bank-item.entity.ts index 0b6914f..a7b6abc 100644 --- a/server/src/assessment/entities/question-bank-item.entity.ts +++ b/server/src/assessment/entities/question-bank-item.entity.ts @@ -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; diff --git a/server/src/assessment/entities/question-bank.entity.ts b/server/src/assessment/entities/question-bank.entity.ts index 1c96d97..640e3b8 100644 --- a/server/src/assessment/entities/question-bank.entity.ts +++ b/server/src/assessment/entities/question-bank.entity.ts @@ -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( diff --git a/server/src/assessment/services/question-bank.service.ts b/server/src/assessment/services/question-bank.service.ts index 4541878..69cba61 100644 --- a/server/src/assessment/services/question-bank.service.ts +++ b/server/src/assessment/services/question-bank.service.ts @@ -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 { + 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 { + 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)); } diff --git a/server/src/knowledge-base/embedding.service.ts b/server/src/knowledge-base/embedding.service.ts index 9a2a70f..3ada010 100644 --- a/server/src/knowledge-base/embedding.service.ts +++ b/server/src/knowledge-base/embedding.service.ts @@ -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 { + // 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 { + 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; + } } diff --git a/server/src/knowledge-group/knowledge-group.controller.ts b/server/src/knowledge-group/knowledge-group.controller.ts index 7e51f6e..49d5aa0 100644 --- a/server/src/knowledge-group/knowledge-group.controller.ts +++ b/server/src/knowledge-group/knowledge-group.controller.ts @@ -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, diff --git a/server/src/knowledge-group/knowledge-group.service.ts b/server/src/knowledge-group/knowledge-group.service.ts index f64278f..73df963 100644 --- a/server/src/knowledge-group/knowledge-group.service.ts +++ b/server/src/knowledge-group/knowledge-group.service.ts @@ -62,11 +62,19 @@ export class KnowledgeGroupService { userId: string, tenantId: string, ): Promise { + 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 { + 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( diff --git a/web/Dockerfile b/web/Dockerfile index baba85f..ed9bedc 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -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 # 复制源代码 diff --git a/web/components/GroupManager.tsx b/web/components/GroupManager.tsx index 68042d2..3b914f0 100644 --- a/web/components/GroupManager.tsx +++ b/web/components/GroupManager.tsx @@ -44,6 +44,8 @@ export const GroupManager: React.FC = ({ 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(); diff --git a/web/components/views/AssessmentStatsView.tsx b/web/components/views/AssessmentStatsView.tsx index 65da9a4..5c6d1ac 100644 --- a/web/components/views/AssessmentStatsView.tsx +++ b/web/components/views/AssessmentStatsView.tsx @@ -42,7 +42,7 @@ export const AssessmentStatsView: React.FC = () => { const [groups, setGroups] = useState([]); 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 () => { diff --git a/web/components/views/QuestionBankView.tsx b/web/components/views/QuestionBankView.tsx new file mode 100644 index 0000000..9876c20 --- /dev/null +++ b/web/components/views/QuestionBankView.tsx @@ -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([]); + 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([]); + 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 ( +
+
+

题库管理

+ +
+ + {loading ? ( +
加载中...
+ ) : error ? ( +
错误: {error}
+ ) : banks.length === 0 ? ( +
+ +

暂无题库,点击上方按钮创建

+
+ ) : ( +
+ {banks.map((bank) => ( +
+

{bank.name}

+

{bank.description}

+

状态: {bank.status}

+
+ ))} +
+ )} + + {/* Drawer */} + <> + {showDrawer && ( +
setShowDrawer(false)} + /> + )} +
+
+
+

+ + 创建题库 +

+ +
+
+
+
+ + 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 + /> +
+
+ + 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="输入描述" + /> +
+
+ + + {loadingTemplates && 加载中...} +
+
+
+
+ +
+
+
+ +
+ ); +} \ No newline at end of file diff --git a/web/index.tsx b/web/index.tsx index 09e72b3..3b72101 100644 --- a/web/index.tsx +++ b/web/index.tsx @@ -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 = () => (
@@ -87,7 +88,8 @@ function App() { } /> } /> } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/web/services/questionBankService.ts b/web/services/questionBankService.ts new file mode 100644 index 0000000..457d71a --- /dev/null +++ b/web/services/questionBankService.ts @@ -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 { + 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 { + 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 { + 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): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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): Promise { + 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 { + const response = await apiClient.request(`/question-banks/${bankId}/items/${itemId}`, { method: 'DELETE' }); + if (!response.ok) throw new Error('Failed to delete item'); + }, +}; \ No newline at end of file diff --git a/web/src/components/layouts/WorkspaceLayout.tsx b/web/src/components/layouts/WorkspaceLayout.tsx index dea99b6..6e00c69 100644 --- a/web/src/components/layouts/WorkspaceLayout.tsx +++ b/web/src/components/layouts/WorkspaceLayout.tsx @@ -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} /> + {(activeTenant?.features?.isNotebookEnabled ?? true) && ( { 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'; +}