feat: 修改部分导入文本的逻辑,添加部署脚本和样式文件,更新数据库迁移逻辑
- 新增部署脚本 `build-deploy-bundle.mjs`,用于构建和部署 web 和 server 目录。 - 新增样式文件 `index-acd65452.css`,包含基础样式和响应式设计。 - 新增脚本 `repro-import-text.mjs`,用于测试文本导入 API。 - 新增测试文件 `db-migration-score-zero.test.ts`,验证历史数据库中 questions.score 约束的迁移逻辑。 - 更新数据库初始化逻辑,允许插入 score=0 的问题。
This commit is contained in:
38
AGENTS.md
38
AGENTS.md
@@ -15,4 +15,40 @@ Use `@/openspec/AGENTS.md` to learn:
|
||||
|
||||
Keep this managed block so 'openspec update' can refresh the instructions.
|
||||
|
||||
<!-- OPENSPEC:END -->
|
||||
<!-- OPENSPEC:END -->
|
||||
|
||||
# 项目约束:发布/打包流程(强制)
|
||||
|
||||
以后只要涉及“发布 / 打包 / 上线 / 部署”,默认目标产物必须生成到 `deploy_bundle/`(无需用户再次强调)。
|
||||
|
||||
## 一键口径
|
||||
|
||||
- **发布前**:`npm ci` → `npm run check` → `npm test`
|
||||
- **构建**:`npm run build`(包含:`vite build` + `scripts/build-api.mjs` + `postbuild` 复制 `init.sql`)
|
||||
- **产物**:同步到 `deploy_bundle/`(见下方“产物映射”)
|
||||
|
||||
## 产物映射(从仓库根目录执行)
|
||||
|
||||
构建完成后,仓库根目录的 `dist/` 同时包含:
|
||||
- 前端静态资源:`dist/index.html`、`dist/assets/`、`dist/favicon.svg` 等
|
||||
- 后端产物:`dist/api/server.js`、`dist/api/server.js.map`、`dist/api/database/init.sql`
|
||||
|
||||
发布时必须把它们整理为:
|
||||
|
||||
- `deploy_bundle/web/`:放“前端静态资源”(复制 `dist/` 下除 `api/` 之外的所有内容)
|
||||
- `deploy_bundle/server/`:放“后端可运行目录”
|
||||
- `deploy_bundle/server/dist/api/`:复制整个 `dist/api/`
|
||||
- `deploy_bundle/server/package.json`、`deploy_bundle/server/package-lock.json`:与根目录依赖保持同步
|
||||
- `deploy_bundle/server/ecosystem.config.cjs`:PM2 配置(通常无需改动,按环境变量/路径调整)
|
||||
|
||||
## 服务器侧部署(默认 PM2)
|
||||
|
||||
- 将 `deploy_bundle/server/` 上传/同步到服务器运行目录(由 `ecosystem.config.cjs` 的 `cwd` 决定)
|
||||
- 在服务器运行目录执行:`npm ci --omit=dev`
|
||||
- 启动/重启:`pm2 start ecosystem.config.cjs` 或 `pm2 reload ecosystem.config.cjs`
|
||||
|
||||
## 发布注意事项(必须检查)
|
||||
|
||||
- Node 版本与构建目标一致(当前后端构建目标:Node 20)
|
||||
- 数据库文件是外置持久化(由 `DB_PATH` 指定),发布不覆盖 DB 文件
|
||||
- 构建后确认 `deploy_bundle/server/dist/api/database/init.sql` 存在(用于首次初始化/新环境)
|
||||
|
||||
@@ -96,6 +96,18 @@ export class QuizController {
|
||||
|
||||
totalPossibleScore += Number(question.score) || 0;
|
||||
|
||||
if (answer.userAnswer === undefined || answer.userAnswer === null) {
|
||||
answer.userAnswer = '';
|
||||
}
|
||||
|
||||
// 规则:分值为0的题目不判定正误,不要求答案,默认正确
|
||||
if (Number(question.score) === 0) {
|
||||
answer.score = 0;
|
||||
answer.isCorrect = true;
|
||||
processedAnswers.push(answer);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (question.type === 'multiple') {
|
||||
const optionCount = question.options ? question.options.length : 0;
|
||||
const unitScore = optionCount > 0 ? question.score / optionCount : 0;
|
||||
|
||||
@@ -164,9 +164,84 @@ const ensureUserGroupSchemaAndAllUsersMembership = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const getTableCreateSql = async (tableName: string): Promise<string> => {
|
||||
const row = await get(
|
||||
`SELECT sql FROM sqlite_master WHERE type='table' AND name=? LIMIT 1`,
|
||||
[tableName],
|
||||
);
|
||||
return (row?.sql as string | undefined) ?? '';
|
||||
};
|
||||
|
||||
const tableSqlHasScoreGreaterThanZeroCheck = (tableSql: string): boolean => {
|
||||
if (!tableSql) return false;
|
||||
// 兼容不同空白/大小写写法:CHECK(score > 0) / CHECK ( score>0 )
|
||||
return /check\s*\(\s*score\s*>\s*0\s*\)/i.test(tableSql);
|
||||
};
|
||||
|
||||
const migrateQuestionsScoreCheckToAllowZero = async () => {
|
||||
const questionsSql = await getTableCreateSql('questions');
|
||||
if (!tableSqlHasScoreGreaterThanZeroCheck(questionsSql)) return;
|
||||
|
||||
console.log('检测到旧表约束:questions.score CHECK(score > 0),开始迁移为 >= 0');
|
||||
|
||||
// 迁移方式:重建 questions 表(SQLite 不支持直接修改 CHECK 约束)
|
||||
// 注意:questions 被 quiz_answers 外键引用,因此迁移期间临时关闭 foreign_keys。
|
||||
await exec('PRAGMA foreign_keys = OFF;');
|
||||
try {
|
||||
await exec('BEGIN TRANSACTION;');
|
||||
|
||||
await exec(`
|
||||
CREATE TABLE IF NOT EXISTS questions_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('single', 'multiple', 'judgment', 'text')),
|
||||
options TEXT,
|
||||
answer TEXT NOT NULL,
|
||||
analysis TEXT NOT NULL DEFAULT '',
|
||||
score INTEGER NOT NULL CHECK(score >= 0),
|
||||
category TEXT NOT NULL DEFAULT '通用',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
await exec(`
|
||||
INSERT INTO questions_new (id, content, type, options, answer, analysis, score, category, created_at)
|
||||
SELECT
|
||||
id,
|
||||
content,
|
||||
type,
|
||||
options,
|
||||
COALESCE(answer, ''),
|
||||
COALESCE(analysis, ''),
|
||||
score,
|
||||
COALESCE(category, '通用'),
|
||||
created_at
|
||||
FROM questions;
|
||||
`);
|
||||
|
||||
await exec('DROP TABLE questions;');
|
||||
await exec('ALTER TABLE questions_new RENAME TO questions;');
|
||||
|
||||
await exec('CREATE INDEX IF NOT EXISTS idx_questions_type ON questions(type);');
|
||||
await exec('CREATE INDEX IF NOT EXISTS idx_questions_score ON questions(score);');
|
||||
await exec('CREATE INDEX IF NOT EXISTS idx_questions_category ON questions(category);');
|
||||
|
||||
await exec('COMMIT;');
|
||||
console.log('questions 表迁移完成:score 允许 0 分');
|
||||
} catch (error) {
|
||||
try {
|
||||
await exec('ROLLBACK;');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await exec('PRAGMA foreign_keys = ON;');
|
||||
}
|
||||
};
|
||||
|
||||
const migrateDatabase = async () => {
|
||||
// 跳过迁移,因为数据库连接可能未初始化
|
||||
console.log('跳过数据库迁移');
|
||||
await migrateQuestionsScoreCheckToAllowZero();
|
||||
};
|
||||
|
||||
// 数据库初始化函数
|
||||
@@ -194,6 +269,9 @@ export const initDatabase = async () => {
|
||||
await ensureColumn('quiz_records', "score_percentage REAL", 'score_percentage');
|
||||
await ensureColumn('quiz_records', "status TEXT", 'status');
|
||||
|
||||
// 兼容历史数据库:迁移无法通过 init.sql 修复的约束/结构
|
||||
await migrateDatabase();
|
||||
|
||||
// 用户组(含“全体用户”系统组)
|
||||
await ensureUserGroupSchemaAndAllUsersMembership();
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ CREATE TABLE questions (
|
||||
options TEXT, -- JSON格式存储选项
|
||||
answer TEXT NOT NULL,
|
||||
analysis TEXT NOT NULL DEFAULT '',
|
||||
score INTEGER NOT NULL CHECK(score > 0),
|
||||
score INTEGER NOT NULL CHECK(score >= 0),
|
||||
category TEXT NOT NULL DEFAULT '通用',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
@@ -306,7 +306,7 @@ export class ExamSubjectModel {
|
||||
judgment: 20,
|
||||
text: 10
|
||||
}),
|
||||
categoryRatios: parseJson<Record<string, number>>(row.categoryRatios, { 通用: 100 }),
|
||||
categoryRatios: parseJson<Record<string, number>>(row.categoryRatios, {}),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt
|
||||
}));
|
||||
@@ -341,7 +341,7 @@ export class ExamSubjectModel {
|
||||
judgment: 20,
|
||||
text: 10
|
||||
}),
|
||||
categoryRatios: parseJson<Record<string, number>>(row.categoryRatios, { 通用: 100 }),
|
||||
categoryRatios: parseJson<Record<string, number>>(row.categoryRatios, {}),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt
|
||||
};
|
||||
@@ -361,7 +361,7 @@ export class ExamSubjectModel {
|
||||
const timeLimitMinutes = data.timeLimitMinutes ?? 60;
|
||||
if (!Number.isInteger(timeLimitMinutes) || timeLimitMinutes <= 0) throw new Error('答题时间必须为正整数(分钟)');
|
||||
|
||||
const categoryRatios = data.categoryRatios && Object.keys(data.categoryRatios).length > 0 ? data.categoryRatios : { 通用: 100 };
|
||||
const categoryRatios = data.categoryRatios && Object.keys(data.categoryRatios).length > 0 ? data.categoryRatios : {};
|
||||
const typeRatioMode = isRatioMode(data.typeRatios as any);
|
||||
const categoryRatioMode = isRatioMode(categoryRatios);
|
||||
if (typeRatioMode !== categoryRatioMode) {
|
||||
@@ -423,7 +423,7 @@ export class ExamSubjectModel {
|
||||
const timeLimitMinutes = data.timeLimitMinutes ?? 60;
|
||||
if (!Number.isInteger(timeLimitMinutes) || timeLimitMinutes <= 0) throw new Error('答题时间必须为正整数(分钟)');
|
||||
|
||||
const categoryRatios = data.categoryRatios && Object.keys(data.categoryRatios).length > 0 ? data.categoryRatios : { 通用: 100 };
|
||||
const categoryRatios = data.categoryRatios && Object.keys(data.categoryRatios).length > 0 ? data.categoryRatios : {};
|
||||
const typeRatioMode = isRatioMode(data.typeRatios as any);
|
||||
const categoryRatioMode = isRatioMode(categoryRatios);
|
||||
if (typeRatioMode !== categoryRatioMode) {
|
||||
|
||||
@@ -51,11 +51,12 @@ export class QuestionModel {
|
||||
static async create(data: CreateQuestionData): Promise<Question> {
|
||||
const id = uuidv4();
|
||||
const optionsStr = data.options ? JSON.stringify(data.options) : null;
|
||||
const rawAnswer = (data as any).answer;
|
||||
const normalizedAnswer =
|
||||
data.type === 'judgment' && !Array.isArray(data.answer)
|
||||
? this.normalizeJudgmentAnswer(data.answer)
|
||||
: data.answer;
|
||||
const answerStr = Array.isArray(normalizedAnswer) ? JSON.stringify(normalizedAnswer) : normalizedAnswer;
|
||||
data.type === 'judgment' && !Array.isArray(rawAnswer)
|
||||
? this.normalizeJudgmentAnswer(rawAnswer)
|
||||
: rawAnswer;
|
||||
const answerStr = Array.isArray(normalizedAnswer) ? JSON.stringify(normalizedAnswer) : String(normalizedAnswer ?? '');
|
||||
const category = data.category && data.category.trim() ? data.category.trim() : '通用';
|
||||
const analysis = String(data.analysis ?? '').trim().slice(0, 255);
|
||||
|
||||
@@ -88,11 +89,12 @@ export class QuestionModel {
|
||||
const question = questions[i];
|
||||
const id = uuidv4();
|
||||
const optionsStr = question.options ? JSON.stringify(question.options) : null;
|
||||
const rawAnswer = (question as any).answer;
|
||||
const normalizedAnswer =
|
||||
question.type === 'judgment' && !Array.isArray(question.answer)
|
||||
? this.normalizeJudgmentAnswer(question.answer)
|
||||
: question.answer;
|
||||
const answerStr = Array.isArray(normalizedAnswer) ? JSON.stringify(normalizedAnswer) : normalizedAnswer;
|
||||
question.type === 'judgment' && !Array.isArray(rawAnswer)
|
||||
? this.normalizeJudgmentAnswer(rawAnswer)
|
||||
: rawAnswer;
|
||||
const answerStr = Array.isArray(normalizedAnswer) ? JSON.stringify(normalizedAnswer) : String(normalizedAnswer ?? '');
|
||||
const category = question.category && question.category.trim() ? question.category.trim() : '通用';
|
||||
const analysis = String(question.analysis ?? '').trim().slice(0, 255);
|
||||
|
||||
@@ -153,11 +155,12 @@ export class QuestionModel {
|
||||
const question = questions[i];
|
||||
try {
|
||||
const optionsStr = question.options ? JSON.stringify(question.options) : null;
|
||||
const rawAnswer = (question as any).answer;
|
||||
const normalizedAnswer =
|
||||
question.type === 'judgment' && !Array.isArray(question.answer)
|
||||
? this.normalizeJudgmentAnswer(question.answer)
|
||||
: question.answer;
|
||||
const answerStr = Array.isArray(normalizedAnswer) ? JSON.stringify(normalizedAnswer) : normalizedAnswer;
|
||||
question.type === 'judgment' && !Array.isArray(rawAnswer)
|
||||
? this.normalizeJudgmentAnswer(rawAnswer)
|
||||
: rawAnswer;
|
||||
const answerStr = Array.isArray(normalizedAnswer) ? JSON.stringify(normalizedAnswer) : String(normalizedAnswer ?? '');
|
||||
const category = question.category && question.category.trim() ? question.category.trim() : '通用';
|
||||
const analysis = String(question.analysis ?? '').trim().slice(0, 255);
|
||||
|
||||
@@ -316,11 +319,12 @@ export class QuestionModel {
|
||||
}
|
||||
|
||||
if (data.answer !== undefined) {
|
||||
const rawAnswer = (data as any).answer;
|
||||
const normalizedAnswer =
|
||||
data.type === 'judgment' && !Array.isArray(data.answer)
|
||||
? this.normalizeJudgmentAnswer(data.answer)
|
||||
: data.answer;
|
||||
const answerStr = Array.isArray(normalizedAnswer) ? JSON.stringify(normalizedAnswer) : normalizedAnswer;
|
||||
data.type === 'judgment' && !Array.isArray(rawAnswer)
|
||||
? this.normalizeJudgmentAnswer(rawAnswer)
|
||||
: rawAnswer;
|
||||
const answerStr = Array.isArray(normalizedAnswer) ? JSON.stringify(normalizedAnswer) : String(normalizedAnswer ?? '');
|
||||
fields.push('answer = ?');
|
||||
values.push(answerStr);
|
||||
}
|
||||
@@ -411,13 +415,20 @@ export class QuestionModel {
|
||||
}
|
||||
|
||||
// 验证答案
|
||||
if (!data.answer) {
|
||||
errors.push('答案不能为空');
|
||||
const score = Number((data as any).score);
|
||||
const allowEmptyAnswer = Number.isFinite(score) && score === 0;
|
||||
if (!allowEmptyAnswer) {
|
||||
const ans = (data as any).answer;
|
||||
const isEmptyArray = Array.isArray(ans) && ans.length === 0;
|
||||
const isEmptyString = typeof ans === 'string' && ans.trim().length === 0;
|
||||
if (ans === undefined || ans === null || isEmptyArray || isEmptyString) {
|
||||
errors.push('答案不能为空');
|
||||
}
|
||||
}
|
||||
|
||||
// 验证分值
|
||||
if (!data.score || data.score <= 0) {
|
||||
errors.push('分值必须是正数');
|
||||
if (!Number.isFinite(score) || score < 0) {
|
||||
errors.push('分值必须是非负数');
|
||||
}
|
||||
|
||||
if (data.category !== undefined && data.category.trim().length === 0) {
|
||||
@@ -445,12 +456,16 @@ export class QuestionModel {
|
||||
errors.push(`第${index + 1}行:题型必须是 single、multiple、judgment 或 text`);
|
||||
}
|
||||
|
||||
if (!row.answer) {
|
||||
const score = Number((row as any).score);
|
||||
const allowEmptyAnswer = Number.isFinite(score) && score === 0;
|
||||
const ans = (row as any).answer;
|
||||
const isEmptyString = typeof ans === 'string' && ans.trim().length === 0;
|
||||
if (!allowEmptyAnswer && (ans === undefined || ans === null || isEmptyString)) {
|
||||
errors.push(`第${index + 1}行:答案不能为空`);
|
||||
}
|
||||
|
||||
if (!row.score || row.score <= 0) {
|
||||
errors.push(`第${index + 1}行:分值必须是正数`);
|
||||
if (!Number.isFinite(score) || score < 0) {
|
||||
errors.push(`第${index + 1}行:分值必须是非负数`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -154,7 +154,11 @@ export class QuizModel {
|
||||
END
|
||||
ELSE r.status
|
||||
END as status,
|
||||
r.created_at as createdAt
|
||||
r.created_at as createdAt,
|
||||
COALESCE(r.subject_id, t.subject_id) as subjectId,
|
||||
COALESCE(s.name, ts.name) as subjectName,
|
||||
r.task_id as taskId,
|
||||
t.name as taskName
|
||||
FROM quiz_records r
|
||||
LEFT JOIN (
|
||||
SELECT a.record_id as recordId, SUM(q.score) as totalPossibleScore
|
||||
@@ -162,6 +166,9 @@ export class QuizModel {
|
||||
JOIN questions q ON a.question_id = q.id
|
||||
GROUP BY a.record_id
|
||||
) totals ON totals.recordId = r.id
|
||||
LEFT JOIN exam_tasks t ON r.task_id = t.id
|
||||
LEFT JOIN exam_subjects s ON r.subject_id = s.id
|
||||
LEFT JOIN exam_subjects ts ON t.subject_id = ts.id
|
||||
WHERE r.id = ?
|
||||
`;
|
||||
const record = await get(sql, [id]);
|
||||
|
||||
@@ -1,19 +1,49 @@
|
||||
上传word格式文档,这是我公司内部考试的题目,我需要你根据文档内容,转换为符合要求的考试题目。请以csv格式输出,包含题型、题目类别、分值、题目内容、选项A、选项B、选项C、选项D、答案、解析。并以文本块方式呈现。
|
||||
---------------------------------------------------------------------------
|
||||
# 格式:
|
||||
你是“本知识库”的出题助手。请【仅基于本知识库的内容】生成可用于“文本导入题库”的考试题目,用.csv的格式返回。
|
||||
|
||||
重要要求(务必遵守):
|
||||
1) 题型必须严格符合本项目支持的题型:单选、多选、判断、文字描述。不得输出其他题型名称。
|
||||
2) 题目来源声明:这些题目来源于“本知识库”。但【不得】在题干/选项/解析中写出“根据《某文件》/依据XX文档/引用出处/本知识库/某章节/某页面”等任何出处描述。
|
||||
3) 严谨性:题目内容必须与知识库一致、表述严谨、可验证;不得编造不确定事实,不得出现与知识不符/自相矛盾/模棱两可的题。
|
||||
4) 避免固定模板句式:禁止出现“根据《…》”“依据…规则”“根据…文档”这类开头。
|
||||
|
||||
题型数量配置(你将按我给的数量生成):
|
||||
- 单选题数量:{单选数量}
|
||||
- 多选题数量:{多选数量}
|
||||
- 判断题数量:{判断数量}
|
||||
- 文字描述题数量:{文字描述数量}
|
||||
|
||||
输出要求:
|
||||
- 以“管道分隔的CSV文本”输出(每列用“|”分隔;不是逗号分隔)。
|
||||
- 只输出题目数据行,不要输出任何额外说明文字。
|
||||
- 每行字段固定为:
|
||||
题型|题目类别|分值|题目内容|选项A|选项B|选项C|选项D|答案1,答案2|解析
|
||||
|
||||
# 解析:
|
||||
- 题型:单选,多选,判断,文字描述
|
||||
- 分值:默认5分,根据题目难度,取值2~20分,注意:文字描述题默认0分
|
||||
- 题目内容:题目的具体内容,在题目前面加【题型】
|
||||
- 选项:对于选择题,提供4个选项,选项之间用"|"分割,例如:北京|上海|广州|深圳
|
||||
- 答案:标准答案,例如:A,对于多选题,有多个答案,答案之间用","做分割
|
||||
- 解析:对题目答案的解析,例如:这是常识
|
||||
字段规则:
|
||||
- 题型:只能是 单选 / 多选 / 判断 / 文字描述。
|
||||
- 题目类别:优先使用知识库中的分类;不确定则用“通用”。
|
||||
- 分值:默认 5 分;按难度可取 2~20 的整数;文字描述题必须为 0 分。
|
||||
- 题目内容:
|
||||
- 必须在最前面加【题型】前缀:
|
||||
- 单选 → 【单选题】
|
||||
- 多选 → 【多选题】
|
||||
- 判断 → 【判断题】
|
||||
- 文字描述 → 【文字描述题】
|
||||
- 题干不得包含任何出处/文件名/章节号/“根据…”等引用式表述。
|
||||
- 选项:
|
||||
- 单选/多选:必须给出 A-D 四个选项(不得为空、不得重复、不得出现明显无关/语义重叠选项)。
|
||||
- 判断:选项A 为空,选项B 固定写“正确”,选项C 固定写“错误”,选项D 为空。
|
||||
- 文字描述:选项A 为空,选项B 可写“可自由作答”,其余为空。
|
||||
- 答案:
|
||||
- 单选:A/B/C/D 之一。
|
||||
- 多选:用英文逗号分隔,如 A,B,D(按 A-D 升序)。
|
||||
- 判断:只能是“正确”或“错误”。
|
||||
- 文字描述:留空。
|
||||
- 解析:
|
||||
- 必须给出简洁且严谨的理由/要点;不得提及任何文件出处;不得出现“这是常识/见文档”等空泛描述。
|
||||
|
||||
# 示例:
|
||||
多选|软件技术|10|【多选题】下列哪些属于网络安全的基本组成部分?|防火墙|杀毒软件|数据加密|物理安全|A,B,C|这是常识
|
||||
单选|通用|5|【单选题】我国首都是哪里?|北京|上海|广州|深圳|A|我国首都为北京
|
||||
多选|通用|5|【多选题】以下哪些是水果?|苹果|白菜|香蕉|西红柿|A,C,D|水果包括苹果/香蕉/西红柿
|
||||
判断|通用|2|【判断题】地球是圆的||正确|地球接近球体
|
||||
文字描述|通用|10|【文字描述题】请简述你对该岗位的理解||可自由作答|仅用于人工评阅
|
||||
示例(仅示例格式,实际题目需来自本知识库):
|
||||
多选|软件技术|10|【多选题】下列哪些属于网络安全的基本组成部分?|防火墙|杀毒软件|数据加密|物理安全|A,B,C|防火墙用于访问控制;杀毒软件用于恶意代码检测;数据加密用于保护数据机密性。
|
||||
单选|通用|5|【单选题】我国首都是哪里?|北京|上海|广州|深圳|A|我国首都为北京。
|
||||
多选|通用|5|【多选题】以下哪些是水果?|苹果|白菜|香蕉|西红柿|A,C,D|苹果、香蕉、西红柿(植物学上为果实)常被归入水果;白菜为蔬菜。
|
||||
判断|通用|2|【判断题】地球是圆的||正确|错误||正确|地球整体接近球体,但严格来说是略扁的旋转椭球体。
|
||||
文字描述|通用|0|【文字描述题】请简述你对该岗位的理解||可自由作答||| |用于人工评阅,关注职责理解、能力匹配与改进方向。
|
||||
BIN
data/survey.db
BIN
data/survey.db
Binary file not shown.
@@ -9,9 +9,10 @@
|
||||
"dev:frontend": "vite",
|
||||
"build": "vite build && node scripts/build-api.mjs",
|
||||
"postbuild": "node scripts/copy-init-sql.mjs",
|
||||
"bundle": "node scripts/build-deploy-bundle.mjs",
|
||||
"preview": "vite preview",
|
||||
"start": "node --enable-source-maps dist/api/server.js",
|
||||
"test": "node --import tsx --test test/admin-task-stats.test.ts test/question-text-import.test.ts test/swipe-detect.test.ts test/user-tasks.test.ts test/user-records-subjectname.test.ts test/score-percentage.test.ts",
|
||||
"test": "node --import tsx --test test/admin-task-stats.test.ts test/question-text-import.test.ts test/swipe-detect.test.ts test/user-tasks.test.ts test/user-records-subjectname.test.ts test/score-percentage.test.ts test/user-default-group.test.ts",
|
||||
"check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
681
deploy_bundle/web/assets/index-38e9e7a4.js
Normal file
681
deploy_bundle/web/assets/index-38e9e7a4.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
deploy_bundle/web/assets/index-acd65452.css
Normal file
1
deploy_bundle/web/assets/index-acd65452.css
Normal file
File diff suppressed because one or more lines are too long
@@ -6,8 +6,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>宝来威考试平台</title>
|
||||
<meta name="description" content="功能完善的在线问卷调查系统,支持多种题型、随机抽题、免注册答题等特性" />
|
||||
<script type="module" crossorigin src="/assets/index-509f66ca.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index-46911e80.css">
|
||||
<script type="module" crossorigin src="/assets/index-38e9e7a4.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index-acd65452.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"dev:frontend": "vite",
|
||||
"build": "vite build && node scripts/build-api.mjs",
|
||||
"postbuild": "node scripts/copy-init-sql.mjs",
|
||||
"bundle": "node scripts/build-deploy-bundle.mjs",
|
||||
"preview": "vite preview",
|
||||
"start": "node --enable-source-maps dist/api/server.js",
|
||||
"test": "node --import tsx --test test/admin-task-stats.test.ts test/question-text-import.test.ts test/swipe-detect.test.ts test/user-tasks.test.ts test/user-records-subjectname.test.ts test/score-percentage.test.ts test/user-default-group.test.ts",
|
||||
|
||||
88
scripts/build-deploy-bundle.mjs
Normal file
88
scripts/build-deploy-bundle.mjs
Normal file
@@ -0,0 +1,88 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
const distDir = path.join(projectRoot, 'dist');
|
||||
const deployDir = path.join(projectRoot, 'deploy_bundle');
|
||||
const deployWebDir = path.join(deployDir, 'web');
|
||||
const deployServerDir = path.join(deployDir, 'server');
|
||||
|
||||
const exists = async (p) => {
|
||||
try {
|
||||
await fs.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const ensureDir = async (p) => {
|
||||
await fs.mkdir(p, { recursive: true });
|
||||
};
|
||||
|
||||
const emptyDir = async (p) => {
|
||||
await fs.rm(p, { recursive: true, force: true });
|
||||
await fs.mkdir(p, { recursive: true });
|
||||
};
|
||||
|
||||
const copyDir = async (src, dest) => {
|
||||
await fs.cp(src, dest, { recursive: true });
|
||||
};
|
||||
|
||||
const copyFile = async (src, dest) => {
|
||||
await ensureDir(path.dirname(dest));
|
||||
await fs.copyFile(src, dest);
|
||||
};
|
||||
|
||||
if (!(await exists(distDir))) {
|
||||
throw new Error('未找到 dist/。请先执行:npm run build');
|
||||
}
|
||||
|
||||
// 1) web: dist/ 下除 api/ 之外所有内容
|
||||
await emptyDir(deployWebDir);
|
||||
|
||||
const distEntries = await fs.readdir(distDir, { withFileTypes: true });
|
||||
for (const entry of distEntries) {
|
||||
if (entry.name === 'api') continue;
|
||||
const src = path.join(distDir, entry.name);
|
||||
const dest = path.join(deployWebDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await copyDir(src, dest);
|
||||
} else {
|
||||
await copyFile(src, dest);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) server: dist/api -> deploy_bundle/server/dist/api
|
||||
await ensureDir(deployServerDir);
|
||||
await ensureDir(path.join(deployServerDir, 'dist'));
|
||||
|
||||
const distApiDir = path.join(distDir, 'api');
|
||||
if (!(await exists(distApiDir))) {
|
||||
throw new Error('未找到 dist/api/。请确认 npm run build 已成功执行(包含 scripts/build-api.mjs)。');
|
||||
}
|
||||
|
||||
await emptyDir(path.join(deployServerDir, 'dist', 'api'));
|
||||
await copyDir(distApiDir, path.join(deployServerDir, 'dist', 'api'));
|
||||
|
||||
// 3) 同步 server 运行所需文件
|
||||
await copyFile(path.join(projectRoot, 'package.json'), path.join(deployServerDir, 'package.json'));
|
||||
if (await exists(path.join(projectRoot, 'package-lock.json'))) {
|
||||
await copyFile(path.join(projectRoot, 'package-lock.json'), path.join(deployServerDir, 'package-lock.json'));
|
||||
}
|
||||
|
||||
// ecosystem.config.cjs:以仓库根目录为准同步(如需环境差异,可在服务器上覆盖 env/cwd)
|
||||
if (await exists(path.join(projectRoot, 'ecosystem.config.cjs'))) {
|
||||
await copyFile(path.join(projectRoot, 'ecosystem.config.cjs'), path.join(deployServerDir, 'ecosystem.config.cjs'));
|
||||
}
|
||||
|
||||
// sanity check
|
||||
const initSql = path.join(deployServerDir, 'dist', 'api', 'database', 'init.sql');
|
||||
if (!(await exists(initSql))) {
|
||||
throw new Error('缺少 deploy_bundle/server/dist/api/database/init.sql(postbuild 应该会复制)。');
|
||||
}
|
||||
|
||||
console.log('deploy_bundle 已生成:');
|
||||
console.log('- deploy_bundle/web');
|
||||
console.log('- deploy_bundle/server');
|
||||
40
scripts/repro-import-text.mjs
Normal file
40
scripts/repro-import-text.mjs
Normal file
@@ -0,0 +1,40 @@
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.DB_PATH = ':memory:';
|
||||
|
||||
const { initDatabase } = await import('../api/database');
|
||||
await initDatabase();
|
||||
|
||||
const { app } = await import('../api/server');
|
||||
|
||||
const server = app.listen(0);
|
||||
const addr = server.address();
|
||||
const baseUrl = `http://127.0.0.1:${addr.port}`;
|
||||
|
||||
try {
|
||||
const body = {
|
||||
mode: 'incremental',
|
||||
questions: [
|
||||
{
|
||||
content:
|
||||
'【文字描述题】请简述你对公司“只服务渠道客户,不直接做甲方项目(除深圳周边近的)”这一政策的理解。',
|
||||
type: 'text',
|
||||
category: '通用',
|
||||
score: 0,
|
||||
answer: '',
|
||||
analysis: '用于人工评阅,关注对渠道保护、资源倾斜、合作共赢等理念的理解。',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/questions/import-text`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
console.log('status:', res.status);
|
||||
console.log(text);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
@@ -313,7 +313,7 @@ const QuizPage = () => {
|
||||
};
|
||||
|
||||
const handleJumpToFirstUnanswered = () => {
|
||||
const firstUnansweredIndex = questions.findIndex(q => !answers[q.id]);
|
||||
const firstUnansweredIndex = questions.findIndex(q => Number(q.score) > 0 && !answers[q.id]);
|
||||
if (firstUnansweredIndex !== -1) {
|
||||
handleJumpTo(firstUnansweredIndex);
|
||||
}
|
||||
@@ -325,7 +325,7 @@ const QuizPage = () => {
|
||||
setSubmitting(true);
|
||||
|
||||
if (!forceSubmit) {
|
||||
const unansweredQuestions = questions.filter(q => !answers[q.id]);
|
||||
const unansweredQuestions = questions.filter(q => Number(q.score) > 0 && !answers[q.id]);
|
||||
if (unansweredQuestions.length > 0) {
|
||||
message.warning(`还有 ${unansweredQuestions.length} 道题未作答`);
|
||||
return;
|
||||
@@ -333,10 +333,11 @@ const QuizPage = () => {
|
||||
}
|
||||
|
||||
const answersData = questions.map(question => {
|
||||
const isCorrect = checkAnswer(question, answers[question.id]);
|
||||
const userAnswer = (answers[question.id] ?? '') as any;
|
||||
const isCorrect = checkAnswer(question, userAnswer);
|
||||
return {
|
||||
questionId: question.id,
|
||||
userAnswer: answers[question.id],
|
||||
userAnswer,
|
||||
score: isCorrect ? question.score : 0,
|
||||
isCorrect
|
||||
};
|
||||
@@ -382,6 +383,7 @@ const QuizPage = () => {
|
||||
};
|
||||
|
||||
const checkAnswer = (question: Question, userAnswer: string | string[]): boolean => {
|
||||
if (Number(question.score) === 0) return true;
|
||||
if (!userAnswer) return false;
|
||||
|
||||
if (question.type === 'multiple') {
|
||||
@@ -395,7 +397,7 @@ const QuizPage = () => {
|
||||
};
|
||||
|
||||
const answeredCount = useMemo(() => {
|
||||
return questions.reduce((count, q) => (answers[q.id] ? count + 1 : count), 0);
|
||||
return questions.reduce((count, q) => (answers[q.id] || Number(q.score) === 0 ? count + 1 : count), 0);
|
||||
}, [questions, answers]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -58,7 +58,7 @@ const ExamSubjectPage = () => {
|
||||
text: 10
|
||||
});
|
||||
const [categoryRatios, setCategoryRatios] = useState<Record<string, number>>({
|
||||
通用: 100
|
||||
|
||||
});
|
||||
|
||||
const sumValues = (valuesMap: Record<string, number>) => Object.values(valuesMap).reduce((a, b) => a + b, 0);
|
||||
@@ -133,11 +133,20 @@ const ExamSubjectPage = () => {
|
||||
}, []);
|
||||
|
||||
const handleCreate = () => {
|
||||
if (categories.length === 0) {
|
||||
message.warning('题目类别加载中,请稍后再试');
|
||||
return;
|
||||
}
|
||||
setEditingSubject(null);
|
||||
form.resetFields();
|
||||
// 设置默认值
|
||||
const defaultTypeRatios = { single: 4, multiple: 3, judgment: 2, text: 1 };
|
||||
const defaultCategoryRatios: Record<string, number> = { 通用: 10 };
|
||||
const total = sumValues(defaultTypeRatios);
|
||||
const preferredCategory =
|
||||
categories.find((c) => c.name !== '通用')?.name ?? categories[0]?.name;
|
||||
const defaultCategoryRatios: Record<string, number> = preferredCategory
|
||||
? { [preferredCategory]: total }
|
||||
: {};
|
||||
|
||||
// 初始化状态
|
||||
setConfigMode('count');
|
||||
@@ -160,11 +169,7 @@ const ExamSubjectPage = () => {
|
||||
const initialTypeRatios = subject.typeRatios || { single: 40, multiple: 30, judgment: 20, text: 10 };
|
||||
|
||||
// 初始化类别比重,确保所有类别都有值
|
||||
const initialCategoryRatios: Record<string, number> = { 通用: 100 };
|
||||
// 合并现有类别比重
|
||||
if (subject.categoryRatios) {
|
||||
Object.assign(initialCategoryRatios, subject.categoryRatios);
|
||||
}
|
||||
const initialCategoryRatios: Record<string, number> = subject.categoryRatios || {};
|
||||
|
||||
// 确保状态与表单值正确同步
|
||||
const inferredMode = isRatioMode(initialTypeRatios) && isRatioMode(initialCategoryRatios) ? 'ratio' : 'count';
|
||||
@@ -380,11 +385,15 @@ const ExamSubjectPage = () => {
|
||||
key: 'categoryRatios',
|
||||
render: (ratios: Record<string, number>) => {
|
||||
const ratioMode = isRatioMode(ratios || {});
|
||||
const total = sumValues(ratios || {});
|
||||
const knownCategories = new Set(categories.map((c) => c.name));
|
||||
const entries = ratios
|
||||
? Object.entries(ratios).filter(([k]) => knownCategories.size === 0 || knownCategories.has(k))
|
||||
: [];
|
||||
const total = entries.reduce((s, [, v]) => s + (Number(v) || 0), 0);
|
||||
return (
|
||||
<div className="p-3 bg-white rounded-lg border border-gray-200 shadow-sm">
|
||||
<div className="w-full bg-gray-200 rounded-full h-4 flex mb-3 overflow-hidden">
|
||||
{ratios && Object.entries(ratios).map(([category, value]) => (
|
||||
{entries.map(([category, value]) => (
|
||||
<div
|
||||
key={category}
|
||||
className="h-full"
|
||||
@@ -396,7 +405,7 @@ const ExamSubjectPage = () => {
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{ratios && Object.entries(ratios).map(([category, value]) => {
|
||||
{entries.map(([category, value]) => {
|
||||
const percent = ratioMode ? value : (total > 0 ? Math.round((value / total) * 1000) / 10 : 0);
|
||||
return (
|
||||
<div key={category} className="flex items-center text-sm">
|
||||
|
||||
@@ -610,7 +610,7 @@ const QuestionManagePage = () => {
|
||||
label="分值"
|
||||
rules={[{ required: true, message: '请输入分值' }]}
|
||||
>
|
||||
<InputNumber min={1} max={100} style={{ width: '100%' }} />
|
||||
<InputNumber min={0} max={100} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -649,16 +649,20 @@ const QuestionManagePage = () => {
|
||||
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) => prevValues.type !== currentValues.type}
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues.type !== currentValues.type || prevValues.score !== currentValues.score
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const type = getFieldValue('type');
|
||||
const score = Number(getFieldValue('score') ?? 0);
|
||||
const requireAnswer = Number.isFinite(score) ? score > 0 : true;
|
||||
if (type === 'judgment') {
|
||||
return (
|
||||
<Form.Item
|
||||
name="answer"
|
||||
label="正确答案"
|
||||
rules={[{ required: true, message: '请选择正确答案' }]}
|
||||
rules={requireAnswer ? [{ required: true, message: '请选择正确答案' }] : []}
|
||||
>
|
||||
<Select placeholder="选择正确答案">
|
||||
<Option value="正确">正确</Option>
|
||||
@@ -671,7 +675,7 @@ const QuestionManagePage = () => {
|
||||
<Form.Item
|
||||
name="answer"
|
||||
label="正确答案"
|
||||
rules={[{ required: true, message: '请输入正确答案' }]}
|
||||
rules={requireAnswer ? [{ required: true, message: '请输入正确答案' }] : []}
|
||||
>
|
||||
<Input placeholder={type === 'multiple' ? '多个答案用逗号分隔' : '请输入正确答案'} />
|
||||
</Form.Item>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { formatDateTime } from '../../utils/validation';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, Switch, Upload, Tabs, Select, Tag } from 'antd';
|
||||
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, Switch, Upload, Tabs, Select, Tag, Descriptions, Divider, Typography } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, ExportOutlined, ImportOutlined, EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons';
|
||||
import api, { userGroupAPI } from '../../services/api';
|
||||
import type { UploadProps } from 'antd';
|
||||
@@ -20,9 +20,7 @@ interface User {
|
||||
interface QuizRecord {
|
||||
id: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
totalScore: number;
|
||||
obtainedScore: number;
|
||||
scorePercentage: number;
|
||||
status: '不及格' | '合格' | '优秀';
|
||||
createdAt: string;
|
||||
@@ -30,6 +28,24 @@ interface QuizRecord {
|
||||
taskName?: string;
|
||||
}
|
||||
|
||||
interface RecordDetailAnswer {
|
||||
id: string;
|
||||
questionId: string;
|
||||
userAnswer: string | string[];
|
||||
score: number;
|
||||
isCorrect: boolean;
|
||||
questionContent?: string;
|
||||
questionType?: string;
|
||||
correctAnswer?: string | string[];
|
||||
questionScore?: number;
|
||||
questionAnalysis?: string;
|
||||
}
|
||||
|
||||
interface RecordDetailResponse {
|
||||
record: QuizRecord;
|
||||
answers: RecordDetailAnswer[];
|
||||
}
|
||||
|
||||
interface UserGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -290,8 +306,9 @@ const UserManagePage = () => {
|
||||
setRecordDetailLoading(true);
|
||||
try {
|
||||
const res = await api.get(`/admin/quiz/records/detail/${recordId}`);
|
||||
setRecordDetail(res.data);
|
||||
return res.data;
|
||||
const data = res.data as RecordDetailResponse;
|
||||
setRecordDetail(data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
message.error('获取记录详情失败');
|
||||
console.error('获取记录详情失败:', error);
|
||||
@@ -489,7 +506,7 @@ const UserManagePage = () => {
|
||||
key: 'status',
|
||||
render: (status: string) => {
|
||||
const color = getStatusColor(status);
|
||||
return <span className={`px-2 py-1 rounded text-xs font-medium bg-${color}-100 text-${color}-800`}>{status}</span>;
|
||||
return <Tag color={color}>{status}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -497,7 +514,7 @@ const UserManagePage = () => {
|
||||
dataIndex: 'obtainedScore',
|
||||
key: 'obtainedScore',
|
||||
render: (score: number, record: QuizRecord) => {
|
||||
const actualScore = score || 0;
|
||||
const actualScore = (score ?? record.totalScore) || 0;
|
||||
const totalScore = record.totalScore || 0;
|
||||
return (
|
||||
<span className={`font-medium ${actualScore >= totalScore * 0.6 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
@@ -511,9 +528,7 @@ const UserManagePage = () => {
|
||||
dataIndex: 'scoreRate',
|
||||
key: 'scoreRate',
|
||||
render: (_: any, record: QuizRecord) => {
|
||||
const obtainedScore = record.obtainedScore || 0;
|
||||
const totalScore = record.totalScore || 0;
|
||||
const rate = totalScore > 0 ? (obtainedScore / totalScore) * 100 : 0;
|
||||
const rate = typeof record.scorePercentage === 'number' ? record.scorePercentage : 0;
|
||||
return `${rate.toFixed(1)}%`;
|
||||
},
|
||||
},
|
||||
@@ -594,94 +609,107 @@ const UserManagePage = () => {
|
||||
open={recordDetailVisible}
|
||||
onCancel={handleCloseRecordDetail}
|
||||
footer={null}
|
||||
width={800}
|
||||
width="90vw"
|
||||
style={{ maxWidth: 1100 }}
|
||||
loading={recordDetailLoading}
|
||||
bodyStyle={{ maxHeight: '75vh', overflowY: 'auto' }}
|
||||
>
|
||||
{recordDetail && (
|
||||
<div>
|
||||
{/* 考试基本信息 */}
|
||||
<div className="mb-6 p-4 bg-gray-50 rounded">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-gray-600 font-medium">科目:</label>
|
||||
<span>{recordDetail.subjectName || '无科目'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-gray-600 font-medium">任务:</label>
|
||||
<span>{recordDetail.taskName || '无任务'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-gray-600 font-medium">总分:</label>
|
||||
<span>{recordDetail.totalScore || 0}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-gray-600 font-medium">得分:</label>
|
||||
<span className="font-semibold text-blue-600">{recordDetail.obtainedScore || 0}</span>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-gray-600 font-medium">考试时间:</label>
|
||||
<span>{formatDateTime(recordDetail.createdAt, { includeSeconds: true })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{recordDetail && (() => {
|
||||
const record = (recordDetail as RecordDetailResponse).record;
|
||||
const answers = (recordDetail as RecordDetailResponse).answers || [];
|
||||
const obtainedScore = record?.totalScore ?? 0;
|
||||
const scorePercentage = typeof record?.scorePercentage === 'number' ? record.scorePercentage : 0;
|
||||
|
||||
{/* 题目列表 */}
|
||||
<h3 className="text-lg font-semibold mb-4">题目详情</h3>
|
||||
<div className="space-y-6">
|
||||
{recordDetail.questions && recordDetail.questions.map((item: any, index: number) => (
|
||||
<div key={item.id} className="p-4 border rounded">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium mr-2">第{index + 1}题</span>
|
||||
<span className="text-sm text-gray-600">
|
||||
{item.question.type === 'single' ? '单选题' :
|
||||
item.question.type === 'multiple' ? '多选题' :
|
||||
item.question.type === 'judgment' ? '判断题' : '文字题'}
|
||||
</span>
|
||||
<span className="ml-2 text-sm">{item.score}分</span>
|
||||
<span className={`ml-2 text-sm ${item.isCorrect ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{item.isCorrect ? '答对' : '答错'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<p className="font-medium">题目:{item.question.content}</p>
|
||||
</div>
|
||||
|
||||
{/* 显示选项(如果有) */}
|
||||
{item.question.options && item.question.options.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<p className="text-sm font-medium text-gray-600 mb-2">选项:</p>
|
||||
<div className="space-y-2">
|
||||
{item.question.options.map((option: string, optIndex: number) => (
|
||||
<div key={optIndex} className="flex items-center">
|
||||
<span className="inline-block w-5 h-5 border rounded text-center text-xs mr-2">
|
||||
{String.fromCharCode(65 + optIndex)}
|
||||
</span>
|
||||
<span>{option}</span>
|
||||
const formatAnswer = (v: any) => {
|
||||
if (Array.isArray(v)) return v.join(', ');
|
||||
return String(v ?? '').trim();
|
||||
};
|
||||
|
||||
const typeLabel = (t?: string) => {
|
||||
if (t === 'single') return '单选题';
|
||||
if (t === 'multiple') return '多选题';
|
||||
if (t === 'judgment') return '判断题';
|
||||
if (t === 'text') return '文字题';
|
||||
return t || '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Descriptions
|
||||
size="small"
|
||||
bordered
|
||||
column={{ xs: 1, sm: 2, md: 3 }}
|
||||
items={[
|
||||
{ key: 'user', label: '用户', children: selectedUser?.name || record?.userId || '-' },
|
||||
{ key: 'subject', label: '科目', children: record?.subjectName || '无科目' },
|
||||
{ key: 'task', label: '任务', children: record?.taskName || '无任务' },
|
||||
{ key: 'totalScore', label: '得分', children: obtainedScore },
|
||||
{ key: 'scoreRate', label: '得分率', children: `${scorePercentage.toFixed(1)}%` },
|
||||
{ key: 'status', label: '状态', children: <Tag color={getStatusColor(record?.status)}>{record?.status || '-'}</Tag> },
|
||||
{ key: 'time', label: '考试时间', span: 3, children: record?.createdAt ? formatDateTime(record.createdAt, { includeSeconds: true }) : '-' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Divider orientation="left" style={{ marginTop: 16 }}>题目详情</Divider>
|
||||
|
||||
<div className="space-y-4">
|
||||
{answers.map((a, index) => {
|
||||
const maxScore = Number(a.questionScore ?? 0);
|
||||
const isZeroScore = maxScore === 0;
|
||||
const correct = formatAnswer(a.correctAnswer);
|
||||
const userAns = formatAnswer(a.userAnswer);
|
||||
const showCorrectAnswer = a.questionType !== 'text' && correct !== '';
|
||||
const showAnalysis = String(a.questionAnalysis ?? '').trim() !== '';
|
||||
const isCorrect = Boolean(a.isCorrect);
|
||||
|
||||
return (
|
||||
<div key={a.id} className="p-4 border rounded bg-white">
|
||||
<div className="flex flex-wrap items-center gap-2 justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">第 {index + 1} 题</span>
|
||||
<Tag color="blue">{typeLabel(a.questionType)}</Tag>
|
||||
<Tag>{maxScore} 分</Tag>
|
||||
<Tag color={isCorrect ? 'green' : 'red'}>{isCorrect ? '答对' : '答错'}</Tag>
|
||||
{isZeroScore ? <Tag color="default">0分题默认正确</Tag> : null}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">得分:{Number(a.score ?? 0)}</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<Typography.Paragraph style={{ marginBottom: 8 }}>
|
||||
<span className="font-medium">题目:</span>
|
||||
<span className="whitespace-pre-wrap">{a.questionContent || ''}</span>
|
||||
</Typography.Paragraph>
|
||||
|
||||
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))' }}>
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 mb-1">你的答案</div>
|
||||
<div className="whitespace-pre-wrap font-medium">{userAns || '未作答'}</div>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 mb-1">正确答案</div>
|
||||
<div className="whitespace-pre-wrap">{showCorrectAnswer ? correct : (a.questionType === 'text' ? '(文字题无标准答案)' : (correct || ''))}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAnalysis ? (
|
||||
<div className="mt-3">
|
||||
<div className="text-sm text-gray-600 mb-1">解析</div>
|
||||
<div className="whitespace-pre-wrap">{String(a.questionAnalysis ?? '')}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 显示答案 */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex">
|
||||
<span className="w-20 text-sm text-gray-600">正确答案:</span>
|
||||
<span className="text-sm">{Array.isArray(item.question.answer) ? item.question.answer.join(', ') : item.question.answer}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-sm text-gray-600">你的答案:</span>
|
||||
<span className="text-sm font-medium">{Array.isArray(item.userAnswer) ? item.userAnswer.join(', ') : item.userAnswer || '未作答'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{answers.length === 0 ? (
|
||||
<div className="text-center py-10 text-gray-500">暂无题目详情</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -78,7 +78,8 @@ const parseLine = (line: string) => {
|
||||
|
||||
const category = parts[1] || '通用';
|
||||
const score = Number(parts[2]);
|
||||
if (!Number.isFinite(score) || score <= 0) return { error: `分值必须是正数:${trimmed}` };
|
||||
if (!Number.isFinite(score) || score < 0) return { error: `分值必须是非负数:${trimmed}` };
|
||||
const allowEmptyAnswer = score === 0;
|
||||
|
||||
const content = parts[3];
|
||||
if (!content) return { error: `题目内容不能为空:${trimmed}` };
|
||||
@@ -112,10 +113,19 @@ const parseLine = (line: string) => {
|
||||
const question: ImportQuestion = { type, category, score, content, answer: '', analysis: String(analysisRaw || '').trim().slice(0, 255) };
|
||||
|
||||
if (type === 'single' || type === 'multiple') {
|
||||
const options = hasPipeDelimiter && !hasCsvDelimiter ? optionsTokens.map((s) => s.trim()).filter(Boolean) : splitMulti(optionsRaw);
|
||||
// 如果使用 | 分隔(推荐格式),选项必须严格按 | 切分;选项文本内允许出现逗号/顿号等标点
|
||||
// 否则(CSV/Tab 分隔)才对 optionsRaw 做更宽松的 split
|
||||
const options = hasPipeDelimiter ? optionsTokens.map((s) => s.trim()).filter(Boolean) : splitMulti(optionsRaw);
|
||||
if (options.length < 2) return { error: `选项至少2个:${trimmed}` };
|
||||
const answerTokens = splitMulti(answerRaw);
|
||||
if (answerTokens.length === 0) return { error: `答案不能为空:${trimmed}` };
|
||||
if (answerTokens.length === 0) {
|
||||
if (allowEmptyAnswer) {
|
||||
question.options = options;
|
||||
question.answer = type === 'multiple' ? [] : '';
|
||||
return { question };
|
||||
}
|
||||
return { error: `答案不能为空:${trimmed}` };
|
||||
}
|
||||
const toValue = (token: string) => {
|
||||
const m = token.trim().match(/^([A-Za-z])$/);
|
||||
if (!m) return token;
|
||||
@@ -130,13 +140,25 @@ const parseLine = (line: string) => {
|
||||
|
||||
if (type === 'judgment') {
|
||||
const a = normalizeJudgmentAnswer(answerRaw);
|
||||
if (!a) return { error: `答案不能为空:${trimmed}` };
|
||||
if (!a) {
|
||||
if (allowEmptyAnswer) {
|
||||
question.answer = '';
|
||||
return { question };
|
||||
}
|
||||
return { error: `答案不能为空:${trimmed}` };
|
||||
}
|
||||
question.answer = a;
|
||||
return { question };
|
||||
}
|
||||
|
||||
const textAnswer = String(answerRaw || '').trim();
|
||||
if (!textAnswer) return { error: `答案不能为空:${trimmed}` };
|
||||
if (!textAnswer) {
|
||||
if (allowEmptyAnswer) {
|
||||
question.answer = '';
|
||||
return { question };
|
||||
}
|
||||
return { error: `答案不能为空:${trimmed}` };
|
||||
}
|
||||
question.answer = textAnswer;
|
||||
return { question };
|
||||
};
|
||||
|
||||
58
test/db-migration-score-zero.test.ts
Normal file
58
test/db-migration-score-zero.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.DB_PATH = ':memory:';
|
||||
|
||||
test('历史数据库 questions.score > 0 约束应迁移为 >= 0', async () => {
|
||||
const { initDbConnection, initDatabase, run } = await import('../api/database');
|
||||
|
||||
// 手工创建“旧版本”最小可启动 schema:让 initDatabase 走“表已存在”分支
|
||||
await initDbConnection();
|
||||
|
||||
await run(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
phone TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
await run(`
|
||||
CREATE TABLE questions (
|
||||
id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
options TEXT,
|
||||
answer TEXT NOT NULL,
|
||||
score INTEGER NOT NULL CHECK(score > 0),
|
||||
category TEXT NOT NULL DEFAULT '通用',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
await run(`
|
||||
CREATE TABLE quiz_records (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
subject_id TEXT,
|
||||
task_id TEXT,
|
||||
total_score INTEGER NOT NULL,
|
||||
correct_count INTEGER NOT NULL,
|
||||
total_count INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
await initDatabase();
|
||||
|
||||
// 迁移后应允许插入 score=0
|
||||
await assert.doesNotReject(async () => {
|
||||
await run(
|
||||
`INSERT INTO questions (id, content, type, options, answer, score, category)
|
||||
VALUES ('q1', '0分题', 'text', NULL, '', 0, '通用')`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -236,7 +236,7 @@ test('前端文本解析支持 | 分隔格式', async () => {
|
||||
'题型|题目类别|分值|题目内容|选项|答案|解析',
|
||||
'单选|通用|5|我国首都是哪里?|北京|上海|广州|深圳|A|我国首都为北京',
|
||||
'多选|通用|5|以下哪些是水果?|苹果|白菜|香蕉|西红柿|A,C,D|水果包括苹果/香蕉/西红柿',
|
||||
'判断|通用|2|地球是圆的||正确|地球接近球体',
|
||||
'判断|通用|0|地球是圆的||正确|地球接近球体',
|
||||
].join('\n');
|
||||
|
||||
const res = parseTextQuestions(input);
|
||||
@@ -259,6 +259,69 @@ test('前端文本解析支持 | 分隔格式', async () => {
|
||||
assert.ok(judgment);
|
||||
assert.equal(judgment.answer, '正确');
|
||||
assert.equal(judgment.analysis, '地球接近球体');
|
||||
assert.equal(judgment.score, 0);
|
||||
});
|
||||
|
||||
test('前端文本解析:| 分隔时选项允许包含逗号/顿号', async () => {
|
||||
const { parseTextQuestions } = await import('../src/utils/questionTextImport');
|
||||
|
||||
const input = [
|
||||
'题型|题目类别|分值|题目内容|选项|答案|解析',
|
||||
'单选|通用|5|带标点的选项是否应完整保留?|选项A,包含中文逗号|选项B、包含顿号|选项C(括号)|选项D。句号|A|应能正常解析',
|
||||
].join('\n');
|
||||
|
||||
const res = parseTextQuestions(input);
|
||||
assert.deepEqual(res.errors, []);
|
||||
assert.equal(res.questions.length, 1);
|
||||
|
||||
const q = res.questions[0];
|
||||
assert.equal(q.type, 'single');
|
||||
assert.deepEqual(q.options, ['选项A,包含中文逗号', '选项B、包含顿号', '选项C(括号)', '选项D。句号']);
|
||||
assert.equal(q.answer, '选项A,包含中文逗号');
|
||||
assert.equal(q.analysis, '应能正常解析');
|
||||
});
|
||||
|
||||
test('题库文本导入应允许0分题目', async () => {
|
||||
const { initDatabase, get } = await import('../api/database');
|
||||
await initDatabase();
|
||||
|
||||
const { app } = await import('../api/server');
|
||||
const server = app.listen(0);
|
||||
|
||||
try {
|
||||
const addr = server.address();
|
||||
assert.ok(addr && typeof addr === 'object');
|
||||
const baseUrl = `http://127.0.0.1:${addr.port}`;
|
||||
|
||||
const res = await jsonFetch(baseUrl, '/api/questions/import-text', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
mode: 'incremental',
|
||||
questions: [
|
||||
{
|
||||
content: '0分判断题示例',
|
||||
type: 'judgment',
|
||||
category: '通用',
|
||||
options: undefined,
|
||||
answer: '正确',
|
||||
analysis: '该题用于验证0分允许导入',
|
||||
score: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.json?.success, true);
|
||||
assert.equal(res.json?.data?.inserted, 1);
|
||||
|
||||
const row = await get(`SELECT * FROM questions WHERE content = ?`, ['0分判断题示例']);
|
||||
assert.ok(row);
|
||||
assert.equal(row.score, 0);
|
||||
assert.equal(row.type, 'judgment');
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
test('题目类别列表应返回题库数量统计', async () => {
|
||||
|
||||
@@ -109,3 +109,80 @@ test('得分占比=总得分/试卷总分;40%应判定为不及格', async ()
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('0分题目提交时默认判定正确且不要求作答', async () => {
|
||||
const { initDatabase, run, get } = await import('../api/database');
|
||||
await initDatabase();
|
||||
|
||||
const { app } = await import('../api/server');
|
||||
const server = app.listen(0);
|
||||
|
||||
const jsonFetch = async (baseUrl: string, path: string, options?: { method?: string; body?: unknown }) => {
|
||||
const res = await fetch(`${baseUrl}${path}`, {
|
||||
method: options?.method ?? 'GET',
|
||||
headers: options?.body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: options?.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
let json: any = null;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
return { status: res.status, json, text };
|
||||
};
|
||||
|
||||
try {
|
||||
const addr = server.address();
|
||||
assert.ok(addr && typeof addr === 'object');
|
||||
const baseUrl = `http://127.0.0.1:${addr.port}`;
|
||||
|
||||
const userId = randomUUID();
|
||||
await run(`INSERT INTO users (id, name, phone, password) VALUES (?, ?, ?, ?)`, [
|
||||
userId,
|
||||
'测试用户',
|
||||
`138${Math.floor(Math.random() * 1e8).toString().padStart(8, '0')}`,
|
||||
'',
|
||||
]);
|
||||
|
||||
const q0 = randomUUID();
|
||||
await run(
|
||||
`INSERT INTO questions (id, content, type, options, answer, analysis, score, category)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[q0, '0分题', 'single', JSON.stringify(['A', 'B']), '', '用于验证0分默认正确', 0, '通用'],
|
||||
);
|
||||
|
||||
const res = await jsonFetch(baseUrl, '/api/quiz/submit', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
userId,
|
||||
answers: [
|
||||
{
|
||||
questionId: q0,
|
||||
// 不传 userAnswer
|
||||
score: 0,
|
||||
isCorrect: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.json?.success, true);
|
||||
assert.equal(res.json?.data?.totalScore, 0);
|
||||
assert.equal(res.json?.data?.correctCount, 1);
|
||||
assert.equal(res.json?.data?.totalCount, 1);
|
||||
|
||||
const recordId = res.json?.data?.recordId;
|
||||
assert.ok(recordId);
|
||||
|
||||
const row = await get(`SELECT * FROM quiz_answers WHERE record_id = ?`, [recordId]);
|
||||
assert.ok(row);
|
||||
assert.equal(row.is_correct, 1);
|
||||
assert.equal(row.score, 0);
|
||||
assert.equal(row.user_answer, '');
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user