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:
SpecialX
2026-07-22 20:29:59 +08:00
parent 8ab4fae9d5
commit 33ebb9a652
29 changed files with 3321 additions and 62 deletions

View File

@@ -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";
}