feat(portal-shell): questions + textbooks 模块 3 页迁移(教师域 §9.1 B2)
§9.1 line 630-631 教师域: - /shell/teacher/questions (列表,1 页) - /shell/teacher/textbooks + /shell/teacher/textbooks/[id] (列表+详情,2 页) 契约:🟡 混合 - question(id) ✅ 真实单查(schema 第 775-778 行确认) - textbook(id) ✅ 真实单查 - 列表查询 ❌ schema 无 → MSW 兜底 + @contract-pending - textbookChapters(textbookId) ❌ schema 无 → MSW 兜底 新增文件: - src/lib/api/questions.ts (5 hooks) - src/lib/api/textbooks.ts (5 hooks) - src/lib/api/operations/{questions,textbooks}.graphql.ts (10 documents) - src/features/teacher/questions/ (clients + transformations + tests) - src/features/teacher/textbooks/ (clients + transformations + tests) - src/app/shell/teacher/{questions,textbooks}/ (3 page.tsx + 2 loading + 2 error) 修改文件: - src/lib/api/teacher.ts + operations/teacher.graphql.ts → 重命名 legacy widget API 以解决命名冲突: Question → QuestionBankItem Textbook → LegacyTextbook Chapter → LegacyChapter TextbookFilter → LegacyTextbookFilter useTextbooks → useLegacyTextbooks GET_QUESTIONS_DOC → GET_QUESTION_BANK_DOC GET_TEXTBOOKS_DOC → GET_LEGACY_TEXTBOOKS_DOC - src/widgets/teacher/{question-bank,textbook-manager}/index.tsx → 更新引用为重命名后的 legacy API - src/mocks/graphql-data.ts → 添加 questions/textbooks mock + GetQuestionBank/GetLegacyTextbooks handler - src/messages/{zh-CN,en}.json (questions + textbooks i18n) - src/lib/api/{index,operations/index}.ts (导出 questions + textbooks) - src/shared/lib/route-permissions.ts (questions + textbooks 路由权限) - scripts/check-page-count.ts (baseline 37 → 40) DoD 验收(§11.3 11 项): - typecheck 0 errors - lint 0 errors - vitest 469 tests passed (新增 64 tests) - lint:tokens 0 errors - check:pages 40 PASS - route-permissions 已声明 - 三态齐备 - @contract-pending + MSW 兜底 - i18n zh-CN + en 同步 关联:ARCHITECTURE.md §5.3 / §5.4 / §5.5 / §9.1 / §10 P2 / §11.3 / §11.4 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Questions 数据变换工具(ARCHITECTURE.md §11.3 DoD - 纯函数单测)
|
||||
*
|
||||
* 所有格式化/映射函数均为纯函数,便于 vitest 单测。
|
||||
* 关联:ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
|
||||
*/
|
||||
|
||||
import type { Question, QuestionListItem } from "@/lib/api";
|
||||
|
||||
/** 题型中文标签映射(对齐 schema Question.type 字符串语义) */
|
||||
export const QUESTION_TYPE_LABEL: Record<string, string> = {
|
||||
single_choice: "单选题",
|
||||
multiple_choice: "多选题",
|
||||
fill_blank: "填空题",
|
||||
short_answer: "简答题",
|
||||
essay: "论述题",
|
||||
true_false: "判断题",
|
||||
};
|
||||
|
||||
/** 题目状态中文标签映射 */
|
||||
export const QUESTION_STATUS_LABEL: Record<string, string> = {
|
||||
DRAFT: "草稿",
|
||||
PUBLISHED: "已发布",
|
||||
ARCHIVED: "已归档",
|
||||
};
|
||||
|
||||
/**
|
||||
* 将题型映射为中文标签。未知题型回退为原始值。
|
||||
*/
|
||||
export function formatQuestionType(type: string): string {
|
||||
return QUESTION_TYPE_LABEL[type] ?? type;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将题目状态映射为中文标签。未知状态回退为原始值。
|
||||
*/
|
||||
export function formatQuestionStatus(status: string): string {
|
||||
return QUESTION_STATUS_LABEL[status] ?? status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化难度。
|
||||
*
|
||||
* schema Question.difficulty 是 Float,但业务列表 mock 数据常用字符串枚举
|
||||
* (easy/medium/hard)。本函数同时支持两种输入:
|
||||
* - 字符串枚举:直接映射("easy" → "简单")
|
||||
* - 数值:0~0.4 → 简单,0.4~0.7 → 中等,0.7~1.0 → 困难
|
||||
* - 其他:回退原始字符串
|
||||
*/
|
||||
export function formatDifficulty(difficulty: string | number): string {
|
||||
if (typeof difficulty === "number") {
|
||||
if (!Number.isFinite(difficulty)) return "--";
|
||||
if (difficulty <= 0.4) return "简单";
|
||||
if (difficulty <= 0.7) return "中等";
|
||||
return "困难";
|
||||
}
|
||||
switch (difficulty) {
|
||||
case "easy":
|
||||
return "简单";
|
||||
case "medium":
|
||||
return "中等";
|
||||
case "hard":
|
||||
return "困难";
|
||||
default:
|
||||
return difficulty;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 ISO 日期字符串为本地化展示(zh-CN,含年月日时分)。
|
||||
* 输入无效时返回占位符。
|
||||
*/
|
||||
export function formatQuestionDate(isoDate: string | null | undefined): string {
|
||||
if (!isoDate) return "--";
|
||||
const d = new Date(isoDate);
|
||||
if (Number.isNaN(d.getTime())) return "--";
|
||||
return d.toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断题干文本用于列表展示。
|
||||
* - 超过 maxLen 字符时截断并加省略号
|
||||
* - 移除换行符(列表单行展示)
|
||||
* - maxLen 默认 60
|
||||
*/
|
||||
export function truncateContent(content: string, maxLen = 60): string {
|
||||
const text = content.replace(/\s+/g, " ").trim();
|
||||
if (text.length <= maxLen) return text;
|
||||
return `${text.slice(0, maxLen)}...`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从题目详情中提取列表项视图模型(裁剪字段)。
|
||||
*
|
||||
* 注:Question 详情无 subjectId/textbookId 字段(schema 未提供),
|
||||
* 列表项的这两个字段由 MSW mock 扩展,详情→列表裁剪时置为 null。
|
||||
*/
|
||||
export function toQuestionListItem(question: Question): QuestionListItem {
|
||||
return {
|
||||
id: question.id,
|
||||
type: question.type,
|
||||
content: question.content,
|
||||
difficulty: String(question.difficulty),
|
||||
status: question.status,
|
||||
source: question.source,
|
||||
knowledgePointId: question.knowledgePointId,
|
||||
subjectId: null,
|
||||
textbookId: null,
|
||||
createdAt: question.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据题型返回 Tailwind 徽章语义类名。
|
||||
*/
|
||||
export function questionTypeToBadgeClass(type: string): string {
|
||||
switch (type) {
|
||||
case "single_choice":
|
||||
case "multiple_choice":
|
||||
return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
|
||||
case "fill_blank":
|
||||
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
|
||||
case "short_answer":
|
||||
case "essay":
|
||||
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
|
||||
case "true_false":
|
||||
return "bg-purple-500/10 text-purple-600 dark:text-purple-400";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据难度返回 Tailwind 文本语义类名。
|
||||
* 支持字符串枚举与数值输入。
|
||||
*/
|
||||
export function difficultyToColorClass(difficulty: string | number): string {
|
||||
const label = formatDifficulty(difficulty);
|
||||
switch (label) {
|
||||
case "简单":
|
||||
return "text-emerald-600";
|
||||
case "中等":
|
||||
return "text-amber-600";
|
||||
case "困难":
|
||||
return "text-destructive";
|
||||
default:
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据题目状态返回 Tailwind 徽章语义类名。
|
||||
*/
|
||||
export function questionStatusToBadgeClass(status: string): string {
|
||||
switch (status) {
|
||||
case "DRAFT":
|
||||
return "bg-muted text-muted-foreground";
|
||||
case "PUBLISHED":
|
||||
return "bg-primary/10 text-primary";
|
||||
case "ARCHIVED":
|
||||
return "bg-muted text-muted-foreground";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断题目是否处于可编辑状态(DRAFT)。
|
||||
*/
|
||||
export function isQuestionEditable(status: string): boolean {
|
||||
return status === "DRAFT";
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断题目是否已发布(PUBLISHED)。
|
||||
*/
|
||||
export function isQuestionPublished(status: string): boolean {
|
||||
return status === "PUBLISHED";
|
||||
}
|
||||
Reference in New Issue
Block a user