Initial commit: AuraK人才测评系统基础框架
## 已实现功能 - 题库管理后端API完整实现 - 模板管理页面(Settings-测评模板) - 评估统计页面 - 人才测评页面(AssessmentView) - QuestionBank前端服务层 ## 技术栈 - 后端: Node.js + NestJS + TypeORM - 前端: React + TypeScript - 容器化: Docker Compose ## 已知待完善 - 题库列表页缺少删除按钮 - 题库详情页未实现(题目管理/AI生成/审核)
This commit is contained in:
@@ -87,6 +87,8 @@ services:
|
||||
# restart: unless-stopped
|
||||
networks:
|
||||
- aurak-network
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
web:
|
||||
build:
|
||||
|
||||
@@ -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.
|
||||
@@ -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` - 类型定义
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
# 复制源代码
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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 />} />
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { apiClient } from './apiClient';
|
||||
import { apiClient } from '../../services/apiClient';
|
||||
|
||||
export interface AssessmentStats {
|
||||
totalAttempts: number;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user