feat(teacher-portal): 完成参考项目差距闭环 P3-P7 全量实现

- P3 考试/作业/成绩 mutation + 详情页 + 批改界面 + 乐观更新 + 多 Tab 同步
- P4 知识图谱 SVG 可视化 + 学情分析仪表盘 + parent-portal Remote
- P5 WebSocket 通知中心 + AI 出题(SSE) + AI 教案 + AI 学情报告
- P6 可观测性硬化:Sentry + WebVitals + OTel + A11y + 性能配置 + Cookie 迁移
- P7 参考项目差距闭环:新增 35 个页面覆盖 13 个缺失模块
  - attendance(考勤 4 页)/questions(题库)/textbooks(教材 2 页)
  - classes/[id] 详情 + classes/schedule 课表
  - course-plans(2 页)/diagnostic(2 页)/error-book/practice
  - exams/[id]/build 组卷 + exams/[id]/analytics 考后分析
  - exams/[id]/edit-rich 富文本编辑 + exams/[id]/proctoring 监考
  - grades/entry 批量录入 + grades/stats 统计 + grades/analytics 分析 + grades/report-card 报告卡
  - homework/submissions 列表 + assignments/[id]/submissions 批量批改
  - homework/submissions/[submissionId] 单份批改 + scan-grading 扫描批改
  - lesson-plans 编辑器 + library + calendar + heatmap 5 页
  - elective 选修课 3 页 /leave 请假 /schedule-changes 调课
- P7 基础设施:61 GraphQL operations + 5 handlers + 13 fixtures + 11 viewports
- 集成 browser.ts/server.ts 注册所有 p7 handlers(fallthrough 顺序)
- viewports.ts 扩展 11 个新导航项
- 验证:tsc --noEmit 零错误 + eslint 零错误
- 文档:workline.md 新增 §5 P7 参考项目差距闭环(含完整文件清单)
This commit is contained in:
SpecialX
2026-07-13 14:27:04 +08:00
parent f13ca612e6
commit d49d211425
117 changed files with 30867 additions and 108 deletions

View File

@@ -0,0 +1,97 @@
/**
* ESLint A11y 配置P6 硬化)
*
* - 配置 eslint-plugin-jsx-a11yerror 级别)
* - 包含 WCAG 2.2 AA 规则集
*
* 这是独立配置文件,不修改主 eslint 配置。
* 使用方式eslint -c .eslintrc.a11y.js src
*
* 注意eslint-plugin-jsx-a11y 尚未安装CI 统一安装后启用。
* 关联02-architecture-design.md §12 可观测性 / WCAG 2.2 AA
*/
/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
parserOptions: {
ecmaVersion: 2022,
sourceType: "module",
ecmaFeatures: { jsx: true },
},
plugins: ["jsx-a11y"],
rules: {
// WCAG 2.2 AA 规则集(全部 error 级别)
// 1.1.1 非文本内容:所有图片必须有 alt 属性
"jsx-a11y/alt-text": "error",
// 1.3.1 信息与关系:表单控件必须有 label
"jsx-a11y/label-has-associated-control": "error",
"jsx-a11y/control-has-associated-label": "error",
// 1.4.1 颜色使用:不仅依赖颜色传达信息
"jsx-a11y/no-noninteractive-element-interactions": "error",
// 2.1.1 键盘可达性:所有交互元素必须键盘可操作
"jsx-a11y/no-static-element-interactions": "error",
// 2.1.2 无键盘陷阱:焦点必须能移出组件
"jsx-a11y/no-autofocus": "error",
// 2.4.1 跳过块:提供跳过重复内容的机制
"jsx-a11y/anchor-is-valid": "error",
// 2.4.4 链接目的:链接文本必须有意义
"jsx-a11y/anchor-has-content": "error",
// 2.4.6 标题与标签:标题必须描述性
"jsx-a11y/heading-has-content": "error",
// 3.1.2 每种语言的部分html lang 属性
"jsx-a11y/html-has-lang": "error",
"jsx-a11y/lang": "error",
// 3.2.2 输入时:表单提交可预测
"jsx-a11y/no-onchange": "error",
// 3.3.2 标签或指令:表单输入需要描述
"jsx-a11y/tabindex-no-positive": "error",
// 4.1.2 名称、角色、值ARIA 属性正确使用
"jsx-a11y/aria-props": "error",
"jsx-a11y/aria-proptypes": "error",
"jsx-a11y/aria-unsupported-elements": "error",
"jsx-a11y/role-has-required-aria-props": "error",
"jsx-a11y/role-supports-aria-props": "error",
// 4.1.3 状态消息ARIA live regions
"jsx-a11y/aria-activedescendant-has-tabindex": "error",
// 额外规则:交互元素的角色
"jsx-a11y/interactive-supports-focus": "error",
"jsx-a11y/no-interactive-element-to-noninteractive-role": "error",
"jsx-a11y/no-noninteractive-element-to-interactive-role": "error",
"jsx-a11y/no-redundant-roles": "error",
"jsx-a11y/role": "error",
// 媒体可访问性
"jsx-a11y/media-has-caption": "error",
// 鼠标/指针事件可访问性
"jsx-a11y/mouse-events-have-key-events": "error",
"jsx-a11y/click-events-have-key-events": "error",
// 分散元素blink/marquee 禁用)
"jsx-a11y/no-distracting-elements": "error",
},
overrides: [
{
// 允许在测试文件中使用 jsx-a11y 断言
files: ["**/*.test.{ts,tsx}", "**/*.spec.{ts,tsx}"],
rules: {
"jsx-a11y/anchor-is-valid": "off",
},
},
],
};

View File

@@ -13,21 +13,34 @@
"@edu/hooks": "workspace:*",
"@edu/ui-components": "workspace:*",
"@edu/ui-tokens": "workspace:*",
"@opentelemetry/context-zone": "^1.28.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.57.0",
"@opentelemetry/instrumentation": "^0.57.0",
"@opentelemetry/instrumentation-document-load": "^0.57.0",
"@opentelemetry/instrumentation-fetch": "^0.57.0",
"@opentelemetry/instrumentation-xml-http-request": "^0.57.0",
"@opentelemetry/sdk-trace-web": "^1.28.0",
"@sentry/nextjs": "^8.0.0",
"graphql": "^16.8.0",
"next": "^14.2.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"urql": "^2.2.0"
"urql": "^2.2.0",
"web-vitals": "^4.2.0"
},
"devDependencies": {
"@module-federation/nextjs-mf": "^8.3.0",
"@next/bundle-analyzer": "^14.2.0",
"@types/node": "^22.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.0",
"axe-core": "^4.10.0",
"eslint": "^9.0.0",
"eslint-plugin-jsx-a11y": "^6.9.0",
"msw": "^2.4.0",
"postcss": "^8.4.0",
"size-limit": "^11.1.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.6.0"
},

View File

@@ -0,0 +1,25 @@
{
"$schema": "https://raw.githubusercontent.com/ai/size-limit/main/schema.json",
"name": "teacher-portal bundle size limits",
"description": "P6 性能预算Shell <150KB / Remote <80KB / CSS <50KB",
"checks": [
{
"name": "Shell (main bundle)",
"path": ".next/static/chunks/main-*.js",
"limit": "150 KB",
"gzip": true
},
{
"name": "Remote entry",
"path": ".next/static/chunks/remoteEntry-*.js",
"limit": "80 KB",
"gzip": true
},
{
"name": "CSS",
"path": ".next/static/css/*.css",
"limit": "50 KB",
"gzip": true
}
]
}

View File

@@ -0,0 +1,348 @@
"use client";
/**
* AI 辅助出题页面P5
*
* 数据来源02-architecture-design.md §6 AI 辅助):
* - GraphQL GenerateQuestionMutation
*
* 功能:
* - 表单:科目 / 题型 / 难度 / 知识点 / 题目数量
* - 生成题目mutation+ SSE 流式效果模拟setInterval 逐字显示)
* - 完整题目展示(含选项、答案、解析)
* - 复制到剪贴板 / 再生成一组
* - 生成历史(本次会话内)
*
* 维护者ai13teacher-portal
*/
import { useEffect, useRef, useState } from "react";
import { useMutation } from "urql";
import { GenerateQuestionMutation } from "@/lib/graphql-p5";
import type { GeneratedQuestion, GenerateQuestionInput } from "@/lib/graphql-p5";
const SUBJECTS = ["数学", "语文", "英语", "物理", "化学"];
const QUESTION_TYPES = ["选择题", "填空题", "解答题"];
const DIFFICULTIES = ["简单", "中等", "困难"];
const STREAM_INTERVAL_MS = 25;
function buildQuestionText(q: GeneratedQuestion): string {
return [
q.question,
"",
...q.options,
"",
`答案:${q.answer}`,
"",
`解析:${q.explanation}`,
].join("\n");
}
export default function AiAssistPage(): JSX.Element {
const [subject, setSubject] = useState<string>(SUBJECTS[0] ?? "数学");
const [questionType, setQuestionType] = useState<string>(
QUESTION_TYPES[0] ?? "选择题",
);
const [difficulty, setDifficulty] = useState<string>(DIFFICULTIES[1] ?? "中等");
const [knowledgePoints, setKnowledgePoints] = useState("导数应用");
const [count, setCount] = useState(1);
const [isGenerating, setIsGenerating] = useState(false);
const [isStreaming, setIsStreaming] = useState(false);
const [streamingText, setStreamingText] = useState("");
const [generated, setGenerated] = useState<GeneratedQuestion | null>(null);
const [history, setHistory] = useState<GeneratedQuestion[]>([]);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const streamTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [, generateQuestion] = useMutation(GenerateQuestionMutation);
useEffect(() => {
return () => {
if (streamTimerRef.current) clearInterval(streamTimerRef.current);
};
}, []);
const handleGenerate = async (): Promise<void> => {
if (isGenerating || isStreaming) return;
setIsGenerating(true);
setError(null);
setGenerated(null);
setStreamingText("");
setCopied(false);
const input: GenerateQuestionInput = {
subject,
questionType,
difficulty,
knowledgePoints,
count,
};
const res = await generateQuestion({ input });
setIsGenerating(false);
if (res.error || !res.data?.generateQuestion) {
setError(res.error?.message ?? "生成失败,请重试");
return;
}
const q = res.data.generateQuestion as GeneratedQuestion;
const full = q.question;
setIsStreaming(true);
let i = 0;
streamTimerRef.current = setInterval(() => {
i += 1;
setStreamingText(full.slice(0, i));
if (i >= full.length) {
if (streamTimerRef.current) {
clearInterval(streamTimerRef.current);
streamTimerRef.current = null;
}
setIsStreaming(false);
setGenerated(q);
setHistory((prev) => [q, ...prev].slice(0, 20));
}
}, STREAM_INTERVAL_MS);
};
const handleCopy = (): void => {
if (!generated) return;
const text = buildQuestionText(generated);
if (typeof navigator !== "undefined" && navigator.clipboard) {
void navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}
};
return (
<div className="px-10 py-10">
<header className="mb-8">
<h1 className="text-3xl font-serif text-ink">AI </h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL GenerateQuestionMutation · SSE P5
</p>
</header>
<div className="rule-thin mb-8" />
{/* 生成表单 */}
<section className="mb-8 max-w-3xl">
<h2 className="text-xl font-serif text-ink mb-4"></h2>
<div className="grid grid-cols-2 gap-4">
<label className="flex flex-col gap-1">
<span className="text-tiny uppercase tracking-wide text-ink-muted">
</span>
<select
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink"
>
{SUBJECTS.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-tiny uppercase tracking-wide text-ink-muted">
</span>
<select
value={questionType}
onChange={(e) => setQuestionType(e.target.value)}
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink"
>
{QUESTION_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-tiny uppercase tracking-wide text-ink-muted">
</span>
<select
value={difficulty}
onChange={(e) => setDifficulty(e.target.value)}
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink"
>
{DIFFICULTIES.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-tiny uppercase tracking-wide text-ink-muted">
</span>
<input
type="number"
min={1}
max={10}
value={count}
onChange={(e) => setCount(Number(e.target.value) || 1)}
className="px-3 py-2 bg-transparent border-b border-rule text-sm font-mono text-ink"
/>
</label>
<label className="col-span-2 flex flex-col gap-1">
<span className="text-tiny uppercase tracking-wide text-ink-muted">
</span>
<input
type="text"
value={knowledgePoints}
onChange={(e) => setKnowledgePoints(e.target.value)}
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink"
placeholder="例如:导数应用、极值判定"
/>
</label>
</div>
<div className="mt-6 flex items-center gap-4">
<button
type="button"
onClick={handleGenerate}
disabled={isGenerating || isStreaming}
className="px-5 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed"
>
{isGenerating ? "生成中…" : "生成题目"}
</button>
{generated && !isStreaming && (
<button
type="button"
onClick={handleGenerate}
disabled={isGenerating || isStreaming}
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</button>
)}
</div>
</section>
{error && (
<div className="mark-left mb-6 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">{error}</p>
</div>
)}
{/* 生成结果(流式 + 完整) */}
{(isGenerating || isStreaming || streamingText || generated) && (
<section className="mb-10">
<div className="flex items-baseline justify-between mb-4">
<h2 className="text-xl font-serif text-ink">
{isGenerating && (
<span className="ml-3 text-sm font-sans text-ink-muted">
AI
</span>
)}
{isStreaming && (
<span className="ml-3 text-sm font-sans text-accent">
</span>
)}
</h2>
{generated && !isStreaming && (
<button
type="button"
onClick={handleCopy}
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
>
{copied ? "已复制 ✓" : "复制到剪贴板"}
</button>
)}
</div>
<div className="rule-thin mb-6" />
{isStreaming || !generated ? (
<div className="p-6 border border-rule rounded-card bg-subtle whitespace-pre-wrap text-sm text-ink min-h-24">
{streamingText}
{isStreaming && (
<span className="inline-block w-2 h-4 ml-0.5 bg-accent animate-pulse align-middle" />
)}
</div>
) : (
<div className="p-6 border border-rule rounded-card bg-subtle">
<h3 className="text-lg font-serif text-ink mb-3">
{generated.question}
</h3>
<ul className="space-y-1 mb-4">
{generated.options.map((opt) => (
<li
key={opt}
className={`text-sm ${
opt.startsWith(generated.answer + ".")
? "text-accent font-serif"
: "text-ink-muted"
}`}
>
{opt}
</li>
))}
</ul>
<p className="text-sm text-ink mb-2">
<span className="text-tiny uppercase tracking-wide text-ink-muted">
</span>{" "}
<span className="font-serif text-accent">{generated.answer}</span>
</p>
<p className="text-sm text-ink-muted whitespace-pre-wrap">
<span className="text-tiny uppercase tracking-wide text-ink-muted">
</span>
{"\n"}
{generated.explanation}
</p>
</div>
)}
</section>
)}
{/* 生成历史 */}
{history.length > 0 && (
<section>
<div className="flex items-baseline justify-between mb-4">
<h2 className="text-xl font-serif text-ink">
<span className="ml-2 text-sm font-sans text-ink-muted">
{history.length}
</span>
</h2>
</div>
<div className="rule-thin mb-6" />
<ul className="space-y-0">
{history.map((h) => (
<li
key={h.id}
className="py-3 grid grid-cols-12 gap-4 items-baseline border-b border-rule"
>
<div className="col-span-9">
<h3 className="text-sm font-serif text-ink">{h.question}</h3>
<p className="mt-1 text-tiny text-ink-muted">
{h.answer}
</p>
</div>
<div className="col-span-3 text-right text-tiny font-mono text-ink-subtle">
{h.id}
</div>
</li>
))}
</ul>
</section>
)}
</div>
);
}

View File

@@ -0,0 +1,259 @@
"use client";
/**
* AI 教案生成页面P5
*
* 数据来源02-architecture-design.md §6 AI 辅助):
* - GraphQL GenerateLessonPlanMutation
*
* 功能:
* - 表单:班级 / 科目 / 课时主题 / 教学目标
* - 生成教案mutation+ 流式输出模拟setInterval 逐字显示)
* - 完整教案展示
* - 导出为 .md 文件
*
* 维护者ai13teacher-portal
*/
import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery } from "urql";
import { GenerateLessonPlanMutation } from "@/lib/graphql-p5";
import type {
GeneratedLessonPlan,
GenerateLessonPlanInput,
} from "@/lib/graphql-p5";
import { ClassesQuery } from "@/lib/graphql";
import type { Class } from "@/lib/graphql";
import { Loading } from "@edu/ui-components";
const SUBJECTS = ["数学", "语文", "英语", "物理", "化学"];
const STREAM_INTERVAL_MS = 12;
export default function AiLessonPlanPage(): JSX.Element {
const [classId, setClassId] = useState("");
const [subject, setSubject] = useState<string>(SUBJECTS[0] ?? "数学");
const [topic, setTopic] = useState("函数的单调性与极值");
const [objectives, setObjectives] = useState(
"理解单调性、掌握极值求法、能解决实际问题",
);
const [isGenerating, setIsGenerating] = useState(false);
const [isStreaming, setIsStreaming] = useState(false);
const [streamingText, setStreamingText] = useState("");
const [generated, setGenerated] = useState<GeneratedLessonPlan | null>(null);
const [error, setError] = useState<string | null>(null);
const streamTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [, generateLessonPlan] = useMutation(GenerateLessonPlanMutation);
const [classesResult] = useQuery({ query: ClassesQuery });
const classes = (classesResult.data?.classes as Class[] | undefined) ?? [];
useEffect(() => {
if (!classId) {
const first = classes[0];
if (first) setClassId(first.id);
}
}, [classes, classId]);
useEffect(() => {
return () => {
if (streamTimerRef.current) clearInterval(streamTimerRef.current);
};
}, []);
const handleGenerate = async (): Promise<void> => {
if (isGenerating || isStreaming) return;
setIsGenerating(true);
setError(null);
setGenerated(null);
setStreamingText("");
const input: GenerateLessonPlanInput = {
classId,
subject,
topic,
objectives,
};
const res = await generateLessonPlan({ input });
setIsGenerating(false);
if (res.error || !res.data?.generateLessonPlan) {
setError(res.error?.message ?? "生成失败,请重试");
return;
}
const lp = res.data.generateLessonPlan as GeneratedLessonPlan;
const full = lp.content;
setIsStreaming(true);
let i = 0;
streamTimerRef.current = setInterval(() => {
i += 1;
setStreamingText(full.slice(0, i));
if (i >= full.length) {
if (streamTimerRef.current) {
clearInterval(streamTimerRef.current);
streamTimerRef.current = null;
}
setIsStreaming(false);
setGenerated(lp);
}
}, STREAM_INTERVAL_MS);
};
const handleExport = (): void => {
if (!generated) return;
const blob = new Blob([generated.content], {
type: "text/markdown;charset=utf-8",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `lesson-plan-${generated.id}.md`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="px-10 py-10">
<header className="mb-8">
<h1 className="text-3xl font-serif text-ink">AI </h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL GenerateLessonPlanMutation · P5
</p>
</header>
<div className="rule-thin mb-8" />
{/* 表单 */}
<section className="mb-8 max-w-3xl">
<h2 className="text-xl font-serif text-ink mb-4"></h2>
{classesResult.fetching && classes.length === 0 ? (
<Loading lines={2} />
) : (
<div className="grid grid-cols-2 gap-4">
<label className="flex flex-col gap-1">
<span className="text-tiny uppercase tracking-wide text-ink-muted">
</span>
<select
value={classId}
onChange={(e) => setClassId(e.target.value)}
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink"
>
{classes.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-tiny uppercase tracking-wide text-ink-muted">
</span>
<select
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink"
>
{SUBJECTS.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</label>
<label className="col-span-2 flex flex-col gap-1">
<span className="text-tiny uppercase tracking-wide text-ink-muted">
</span>
<input
type="text"
value={topic}
onChange={(e) => setTopic(e.target.value)}
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink"
/>
</label>
<label className="col-span-2 flex flex-col gap-1">
<span className="text-tiny uppercase tracking-wide text-ink-muted">
</span>
<textarea
value={objectives}
onChange={(e) => setObjectives(e.target.value)}
rows={3}
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink resize-none"
/>
</label>
</div>
)}
<div className="mt-6">
<button
type="button"
onClick={handleGenerate}
disabled={isGenerating || isStreaming || !classId}
className="px-5 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed"
>
{isGenerating ? "生成中…" : "生成教案"}
</button>
</div>
</section>
{error && (
<div className="mark-left mb-6 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">{error}</p>
</div>
)}
{/* 生成结果 */}
{(isGenerating || isStreaming || streamingText || generated) && (
<section className="mb-10">
<div className="flex items-baseline justify-between mb-4">
<h2 className="text-xl font-serif text-ink">
{isGenerating && (
<span className="ml-3 text-sm font-sans text-ink-muted">
AI
</span>
)}
{isStreaming && (
<span className="ml-3 text-sm font-sans text-accent">
</span>
)}
</h2>
{generated && !isStreaming && (
<button
type="button"
onClick={handleExport}
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
>
.md
</button>
)}
</div>
<div className="rule-thin mb-6" />
{isStreaming || !generated ? (
<div className="p-6 border border-rule rounded-card bg-subtle whitespace-pre-wrap text-sm text-ink min-h-24 font-mono">
{streamingText}
{isStreaming && (
<span className="inline-block w-2 h-4 ml-0.5 bg-accent animate-pulse align-middle" />
)}
</div>
) : (
<div className="p-6 border border-rule rounded-card bg-subtle">
<p className="text-sm text-ink-muted mb-4">{generated.summary}</p>
<pre className="whitespace-pre-wrap text-sm text-ink font-mono leading-relaxed">
{generated.content}
</pre>
</div>
)}
</section>
)}
</div>
);
}

View File

@@ -0,0 +1,289 @@
"use client";
/**
* AI 学情报告页面P5
*
* 数据来源02-architecture-design.md §6 AI 辅助):
* - GraphQL GenerateReportMutation
*
* 功能:
* - 表单:班级 / 报告类型(班级周报/月报/单生报告)/ 学生选择(单生报告时)
* - 生成报告mutation+ 流式输出模拟setInterval 逐字显示)
* - 完整报告展示(含建议)
* - 导出为 .md 文件
*
* 维护者ai13teacher-portal
*/
import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery } from "urql";
import { GenerateReportMutation } from "@/lib/graphql-p5";
import type { GeneratedReport, GenerateReportInput } from "@/lib/graphql-p5";
import { ClassesQuery, ClassStudentsQuery } from "@/lib/graphql";
import type { Class, Student } from "@/lib/graphql";
import { Loading } from "@edu/ui-components";
const REPORT_TYPES = ["班级周报", "月报", "单生报告"];
const STREAM_INTERVAL_MS = 12;
export default function AiReportPage(): JSX.Element {
const [classId, setClassId] = useState("");
const [reportType, setReportType] = useState<string>(
REPORT_TYPES[0] ?? "班级周报",
);
const [studentId, setStudentId] = useState("");
const [isGenerating, setIsGenerating] = useState(false);
const [isStreaming, setIsStreaming] = useState(false);
const [streamingText, setStreamingText] = useState("");
const [generated, setGenerated] = useState<GeneratedReport | null>(null);
const [error, setError] = useState<string | null>(null);
const streamTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [, generateReport] = useMutation(GenerateReportMutation);
const [classesResult] = useQuery({ query: ClassesQuery });
const classes = (classesResult.data?.classes as Class[] | undefined) ?? [];
const [studentsResult] = useQuery({
query: ClassStudentsQuery,
variables: { classId },
pause: !classId,
});
const students =
(studentsResult.data?.classStudents as Student[] | undefined) ?? [];
useEffect(() => {
if (!classId) {
const first = classes[0];
if (first) setClassId(first.id);
}
}, [classes, classId]);
useEffect(() => {
if (reportType !== "单生报告") {
setStudentId("");
}
}, [reportType]);
useEffect(() => {
return () => {
if (streamTimerRef.current) clearInterval(streamTimerRef.current);
};
}, []);
const handleGenerate = async (): Promise<void> => {
if (isGenerating || isStreaming) return;
if (reportType === "单生报告" && !studentId) {
setError("单生报告需要选择一名学生");
return;
}
setIsGenerating(true);
setError(null);
setGenerated(null);
setStreamingText("");
const input: GenerateReportInput = {
classId,
reportType,
studentId: reportType === "单生报告" ? studentId : undefined,
};
const res = await generateReport({ input });
setIsGenerating(false);
if (res.error || !res.data?.generateReport) {
setError(res.error?.message ?? "生成失败,请重试");
return;
}
const rpt = res.data.generateReport as GeneratedReport;
const full = rpt.content;
setIsStreaming(true);
let i = 0;
streamTimerRef.current = setInterval(() => {
i += 1;
setStreamingText(full.slice(0, i));
if (i >= full.length) {
if (streamTimerRef.current) {
clearInterval(streamTimerRef.current);
streamTimerRef.current = null;
}
setIsStreaming(false);
setGenerated(rpt);
}
}, STREAM_INTERVAL_MS);
};
const handleExport = (): void => {
if (!generated) return;
const blob = new Blob([generated.content], {
type: "text/markdown;charset=utf-8",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `report-${generated.id}.md`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="px-10 py-10">
<header className="mb-8">
<h1 className="text-3xl font-serif text-ink">AI </h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL GenerateReportMutation · P5
</p>
</header>
<div className="rule-thin mb-8" />
{/* 表单 */}
<section className="mb-8 max-w-3xl">
<h2 className="text-xl font-serif text-ink mb-4"></h2>
{classesResult.fetching && classes.length === 0 ? (
<Loading lines={2} />
) : (
<div className="grid grid-cols-2 gap-4">
<label className="flex flex-col gap-1">
<span className="text-tiny uppercase tracking-wide text-ink-muted">
</span>
<select
value={classId}
onChange={(e) => setClassId(e.target.value)}
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink"
>
{classes.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-tiny uppercase tracking-wide text-ink-muted">
</span>
<select
value={reportType}
onChange={(e) => setReportType(e.target.value)}
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink"
>
{REPORT_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</label>
{reportType === "单生报告" && (
<label className="col-span-2 flex flex-col gap-1">
<span className="text-tiny uppercase tracking-wide text-ink-muted">
</span>
<select
value={studentId}
onChange={(e) => setStudentId(e.target.value)}
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink"
>
<option value=""></option>
{students.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
{studentsResult.fetching && (
<span className="text-tiny text-ink-subtle">
</span>
)}
</label>
)}
</div>
)}
<div className="mt-6">
<button
type="button"
onClick={handleGenerate}
disabled={isGenerating || isStreaming || !classId}
className="px-5 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed"
>
{isGenerating ? "生成中…" : "生成报告"}
</button>
</div>
</section>
{error && (
<div className="mark-left mb-6 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">{error}</p>
</div>
)}
{/* 生成结果 */}
{(isGenerating || isStreaming || streamingText || generated) && (
<section className="mb-10">
<div className="flex items-baseline justify-between mb-4">
<h2 className="text-xl font-serif text-ink">
{isGenerating && (
<span className="ml-3 text-sm font-sans text-ink-muted">
AI
</span>
)}
{isStreaming && (
<span className="ml-3 text-sm font-sans text-accent">
</span>
)}
</h2>
{generated && !isStreaming && (
<button
type="button"
onClick={handleExport}
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
>
.md
</button>
)}
</div>
<div className="rule-thin mb-6" />
{isStreaming || !generated ? (
<div className="p-6 border border-rule rounded-card bg-subtle whitespace-pre-wrap text-sm text-ink min-h-24 font-mono">
{streamingText}
{isStreaming && (
<span className="inline-block w-2 h-4 ml-0.5 bg-accent animate-pulse align-middle" />
)}
</div>
) : (
<div className="p-6 border border-rule rounded-card bg-subtle">
<p className="text-sm text-ink-muted mb-4">{generated.summary}</p>
<pre className="whitespace-pre-wrap text-sm text-ink font-mono leading-relaxed mb-6">
{generated.content}
</pre>
<div className="pt-4 border-t border-rule">
<h3 className="text-sm font-serif text-ink mb-3"></h3>
<ul className="space-y-2">
{generated.recommendations.map((rec, idx) => (
<li
key={idx}
className="text-sm text-ink-muted flex gap-2"
>
<span className="text-accent font-mono">{idx + 1}.</span>
<span>{rec}</span>
</li>
))}
</ul>
</div>
</div>
)}
</section>
)}
</div>
);
}

View File

@@ -0,0 +1,162 @@
"use client";
/**
* TrendChart - 纯 SVG 折线图组件
*
* 用于展示成绩趋势(最近 N 次考试/测验)。
* 不依赖 recharts/d3纯 SVG 手绘project_rules不安装新 npm 包)。
* 颜色使用 var(--color-accent) 语义令牌project_rules §3.10)。
*
* 维护者ai13teacher-portal
*/
interface TrendChartProps {
/** 数据序列(按时间顺序,最近一次在右) */
data: number[];
/** Y 轴标签前缀,默认 "第 N 次" */
labelPrefix?: string;
/** 自定义高度(可选) */
height?: number;
}
const DEFAULT_HEIGHT = 200;
const PADDING = { top: 20, right: 24, bottom: 32, left: 36 };
export default function TrendChart({
data,
labelPrefix = "第",
height = DEFAULT_HEIGHT,
}: TrendChartProps): React.ReactNode {
const width = 480;
const innerW = width - PADDING.left - PADDING.right;
const innerH = height - PADDING.top - PADDING.bottom;
if (data.length === 0) {
return (
<p className="text-sm text-ink-muted"></p>
);
}
// Y 轴范围0-100成绩或自适应
const yMax = Math.max(100, ...data);
const yMin = Math.min(0, ...data);
const yRange = yMax - yMin || 1;
// 坐标映射
const xStep = data.length > 1 ? innerW / (data.length - 1) : 0;
const points = data.map((v, i) => {
const x = PADDING.left + i * xStep;
const y = PADDING.top + innerH - ((v - yMin) / yRange) * innerH;
return { x, y, value: v };
});
// 折线路径
const linePath = points
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`)
.join(" ");
// 填充区域路径data.length 已在前面校验 > 0points 至少 1 个元素)
const firstPoint = points[0];
const lastPoint = points[points.length - 1];
if (!firstPoint || !lastPoint) {
return <p className="text-sm text-ink-muted"></p>;
}
const areaPath = `${linePath} L ${lastPoint.x} ${PADDING.top + innerH} L ${firstPoint.x} ${PADDING.top + innerH} Z`;
// Y 轴刻度3 条网格线)
const gridLines = [0, 0.5, 1].map((ratio) => {
const value = yMin + yRange * (1 - ratio);
const y = PADDING.top + innerH * ratio;
return { y, value: Math.round(value) };
});
return (
<svg
viewBox={`0 0 ${width} ${height}`}
className="w-full max-w-xl border border-rule rounded-card bg-surface"
role="img"
aria-label="成绩趋势折线图"
>
{/* 网格线 + Y 轴刻度 */}
<g>
{gridLines.map((g, i) => (
<g key={`grid-${i}`}>
<line
x1={PADDING.left}
y1={g.y}
x2={width - PADDING.right}
y2={g.y}
stroke="var(--color-rule)"
strokeWidth={1}
strokeDasharray="3 3"
/>
<text
x={PADDING.left - 8}
y={g.y + 4}
textAnchor="end"
fontSize="11"
fontFamily="var(--font-family-mono)"
fill="var(--color-ink-muted)"
>
{g.value}
</text>
</g>
))}
</g>
{/* 填充区域 */}
<path
d={areaPath}
fill="var(--color-accent)"
fillOpacity={0.08}
/>
{/* 折线 */}
<path
d={linePath}
fill="none"
stroke="var(--color-accent)"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* 数据点 */}
<g>
{points.map((p, i) => (
<g key={`point-${i}`}>
<circle
cx={p.x}
cy={p.y}
r={4}
fill="var(--color-accent)"
stroke="var(--bg-paper)"
strokeWidth={2}
/>
<text
x={p.x}
y={p.y - 10}
textAnchor="middle"
fontSize="11"
fontFamily="var(--font-family-mono)"
fontWeight="600"
fill="var(--color-ink)"
>
{p.value}
</text>
<text
x={p.x}
y={height - PADDING.bottom + 18}
textAnchor="middle"
fontSize="11"
fontFamily="var(--font-family-sans)"
fill="var(--color-ink-muted)"
>
{labelPrefix}{i + 1}
</text>
</g>
))}
</g>
</svg>
);
}

View File

@@ -0,0 +1,170 @@
"use client";
/**
* StudentAnalytics 详情页 - 单生学情分析
*
* 数据来源P4 扩展MSW mockGraphQL StudentAnalyticsQuery
* - 成绩趋势图(纯 SVG 折线图)
* - 弱项 / 强项知识点列表
* - 掌握度进度条
*
* 维护者ai13teacher-portal
*/
import { useQuery } from "urql";
import { useParams } from "next/navigation";
import Link from "next/link";
import { Loading, Empty } from "@edu/ui-components";
import { StudentAnalyticsQuery } from "@/lib/graphql-p4";
import type { StudentAnalytics } from "@/lib/graphql-p4";
import TrendChart from "../TrendChart";
/** 按掌握度映射语义色(与知识图谱保持一致) */
function masteryColor(rate: number): string {
if (rate >= 70) return "var(--color-success)";
if (rate >= 40) return "var(--color-warning)";
return "var(--color-danger)";
}
export default function StudentAnalyticsPage() {
const params = useParams<{ studentId: string }>();
const studentId = params?.studentId ?? "";
const [result] = useQuery({
query: StudentAnalyticsQuery,
variables: { studentId },
pause: !studentId,
});
const student: StudentAnalytics | undefined =
result.data?.studentAnalytics;
return (
<div className="px-10 py-10">
<header className="mb-8">
<Link
href="/analytics"
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
<h1 className="mt-3 text-3xl font-serif text-ink">
{student ? student.studentName : "学生详情"}
</h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL StudentAnalyticsQuery · P4 MSW mock
</p>
</header>
<div className="rule-thin mb-8" />
{!studentId ? (
<Empty title="未指定学生" description="请从学情分析进入查看学生详情" />
) : result.fetching ? (
<Loading lines={6} />
) : result.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{result.error.message}
</p>
</div>
) : !student ? (
<Empty title="暂无数据" description="该学生尚未生成学情分析" />
) : (
<>
{/* 概览卡片 */}
<section className="grid grid-cols-2 gap-6 mb-10">
<div className="p-6 border border-rule rounded-card">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
</p>
<p className="mt-3 text-4xl font-serif text-ink">
{student.avgScore}
</p>
</div>
<div className="p-6 border border-rule rounded-card">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
</p>
<div className="mt-3 flex items-baseline gap-3">
<span
className="text-4xl font-serif"
style={{ color: masteryColor(student.masteryRate) }}
>
{student.masteryRate}%
</span>
</div>
<div className="mt-3 h-2 rounded-button bg-subtle overflow-hidden">
<div
className="h-full rounded-button"
style={{
width: `${student.masteryRate}%`,
background: masteryColor(student.masteryRate),
}}
/>
</div>
</div>
</section>
{/* 成绩趋势图 */}
<section className="mb-10">
<h2 className="text-xl font-serif text-ink mb-4"></h2>
<div className="rule-thin mb-6" />
<TrendChart data={student.trend} labelPrefix="第" />
</section>
{/* 强项 + 弱项 */}
<section className="grid grid-cols-2 gap-8">
{/* 强项 */}
<div>
<h2 className="text-xl font-serif text-ink mb-4"></h2>
<div className="rule-thin mb-6" />
{student.strongPoints.length === 0 ? (
<p className="text-sm text-ink-muted"></p>
) : (
<ul className="space-y-2">
{student.strongPoints.map((sp, i) => (
<li
key={i}
className="flex items-center gap-3 py-2 px-3 border-b border-rule"
>
<span
className="inline-block w-2 h-2 rounded-full"
style={{ background: "var(--color-success)" }}
/>
<span className="text-sm text-ink">{sp}</span>
</li>
))}
</ul>
)}
</div>
{/* 弱项 */}
<div>
<h2 className="text-xl font-serif text-ink mb-4"></h2>
<div className="rule-thin mb-6" />
{student.weakPoints.length === 0 ? (
<p className="text-sm text-ink-muted"></p>
) : (
<ul className="space-y-2">
{student.weakPoints.map((wp, i) => (
<li
key={i}
className="flex items-center gap-3 py-2 px-3 border-b border-rule"
>
<span
className="inline-block w-2 h-2 rounded-full"
style={{ background: "var(--color-danger)" }}
/>
<span className="text-sm text-ink">{wp}</span>
</li>
))}
</ul>
)}
</div>
</section>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,287 @@
"use client";
/**
* Analytics 页面 - 班级学情分析仪表盘
*
* 数据来源P4 扩展MSW mock
* - GraphQL ClassAnalyticsQuery班级均分/及格率/趋势/Top 学生/弱项
* - GraphQL ClassesQuery班级下拉选项
*
* 班级选择URL ?classId=xxx 或下拉选择(同步 URL
*
* 维护者ai13teacher-portal
*/
import { Suspense, useMemo } from "react";
import { useQuery } from "urql";
import { useSearchParams, useRouter } from "next/navigation";
import Link from "next/link";
import { Loading, Empty } from "@edu/ui-components";
import { ClassesQuery, ClassQuery } from "@/lib/graphql";
import type { Class } from "@/lib/graphql";
import { ClassAnalyticsQuery } from "@/lib/graphql-p4";
import type { ClassAnalytics } from "@/lib/graphql-p4";
import TrendChart from "./TrendChart";
function AnalyticsContent() {
const searchParams = useSearchParams();
const router = useRouter();
const classId = searchParams.get("classId") ?? "";
// 班级列表(下拉选项)
const [classesResult] = useQuery({ query: ClassesQuery });
// 当前班级详情(显示名称)
const [classResult] = useQuery({
query: ClassQuery,
variables: { id: classId },
pause: !classId,
});
// 班级学情分析
const [analyticsResult] = useQuery({
query: ClassAnalyticsQuery,
variables: { classId },
pause: !classId,
});
const classes: Class[] = classesResult.data?.classes ?? [];
const cls = classResult.data?.class ?? null;
const analytics: ClassAnalytics | undefined =
analyticsResult.data?.classAnalytics;
// 掌握度 = Top 学生 masteryRate 均值classAnalytics 无直接字段,从 topStudents 推算)
const masteryRate = useMemo(() => {
if (!analytics?.topStudents || analytics.topStudents.length === 0) {
return 0;
}
const sum = analytics.topStudents.reduce(
(acc, s) => acc + s.masteryRate,
0,
);
return Math.round(sum / analytics.topStudents.length);
}, [analytics]);
function selectClass(id: string) {
const params = new URLSearchParams(searchParams);
if (id) {
params.set("classId", id);
} else {
params.delete("classId");
}
router.push(`/analytics${params.toString() ? `?${params}` : ""}`);
}
return (
<div className="px-10 py-10">
<header className="mb-8">
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL ClassAnalyticsQuery · P4 MSW mock
</p>
</header>
<div className="rule-thin mb-8" />
{/* 班级选择 */}
<div className="mb-6 flex items-baseline gap-3">
<label className="text-tiny uppercase tracking-wide text-ink-muted">
</label>
<select
value={classId}
onChange={(e) => selectClass(e.target.value)}
className="px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
>
<option value=""></option>
{classes.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
{cls ? (
<span className="ml-2 text-tiny text-ink-muted">
{cls.studentCount}
</span>
) : null}
</div>
{!classId ? (
<Empty
title="请选择班级"
description="选择班级后查看学情分析数据"
/>
) : analyticsResult.fetching ? (
<Loading lines={6} />
) : analyticsResult.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{analyticsResult.error.message}
</p>
</div>
) : !analytics ? (
<Empty title="暂无学情数据" description="该班级尚未生成分析报告" />
) : (
<>
{/* 概览卡片 */}
<section className="grid grid-cols-3 gap-6 mb-10">
<div className="p-6 border border-rule rounded-card">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
</p>
<p className="mt-3 text-4xl font-serif text-ink">
{analytics.avgScore.toFixed(1)}
</p>
</div>
<div className="p-6 border border-rule rounded-card">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
</p>
<p className="mt-3 text-4xl font-serif text-success">
{analytics.passRate}%
</p>
</div>
<div className="p-6 border border-rule rounded-card">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
</p>
<p className="mt-3 text-4xl font-serif text-accent">
{masteryRate}%
</p>
</div>
</section>
{/* 成绩趋势图 */}
<section className="mb-10">
<h2 className="text-xl font-serif text-ink mb-4">
</h2>
<div className="rule-thin mb-6" />
<TrendChart data={analytics.avgTrend} labelPrefix="第" />
</section>
{/* Top 学生 + 弱项 */}
<section className="grid grid-cols-3 gap-8">
{/* Top 5 学生 */}
<div className="col-span-2">
<h2 className="text-xl font-serif text-ink mb-4">
Top 5
</h2>
<div className="rule-thin mb-6" />
<ul className="space-y-0">
{analytics.topStudents.map((s, i) => (
<li
key={s.studentId}
className="py-4 grid grid-cols-12 gap-4 items-baseline border-b border-rule"
>
<div className="col-span-1 text-2xl font-serif text-ink-muted">
{i + 1}
</div>
<div className="col-span-4">
<Link
href={`/analytics/${s.studentId}`}
className="text-lg font-serif text-ink hover:text-accent"
>
{s.studentName}
</Link>
<p className="mt-1 text-tiny text-ink-muted">
{s.strongPoints.length} · {s.weakPoints.length}
</p>
</div>
<div className="col-span-3 text-sm text-ink">
<span className="ml-2 text-lg font-serif text-accent">
{s.avgScore}
</span>
</div>
<div className="col-span-3 text-right">
<div className="flex items-baseline justify-end gap-2">
<span className="text-tiny text-ink-muted">
</span>
<span
className="text-lg font-serif"
style={{
color:
s.masteryRate >= 70
? "var(--color-success)"
: s.masteryRate >= 40
? "var(--color-warning)"
: "var(--color-danger)",
}}
>
{s.masteryRate}%
</span>
</div>
<div className="mt-1 h-1.5 rounded-button bg-subtle overflow-hidden">
<div
className="h-full rounded-button"
style={{
width: `${s.masteryRate}%`,
background:
s.masteryRate >= 70
? "var(--color-success)"
: s.masteryRate >= 40
? "var(--color-warning)"
: "var(--color-danger)",
}}
/>
</div>
</div>
<div className="col-span-1 text-right">
<Link
href={`/analytics/${s.studentId}`}
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
>
</Link>
</div>
</li>
))}
</ul>
</div>
{/* 班级弱项 */}
<div>
<h2 className="text-xl font-serif text-ink mb-4">
</h2>
<div className="rule-thin mb-6" />
{analytics.weakPoints.length === 0 ? (
<p className="text-sm text-ink-muted"></p>
) : (
<ul className="space-y-2">
{analytics.weakPoints.map((wp, i) => (
<li
key={i}
className="flex items-center gap-3 py-2 px-3 border-b border-rule"
>
<span
className="inline-block w-2 h-2 rounded-full"
style={{ background: "var(--color-danger)" }}
/>
<span className="text-sm text-ink">{wp}</span>
</li>
))}
</ul>
)}
</div>
</section>
</>
)}
</div>
);
}
export default function AnalyticsPage() {
return (
<Suspense
fallback={
<div className="px-10 py-10">
<Loading lines={3} />
</div>
}
>
<AnalyticsContent />
</Suspense>
);
}

View File

@@ -0,0 +1,341 @@
"use client";
/**
* Attendance 列表页 - 考勤记录
*
* 数据来源P7 扩展MSW mock
* - GraphQL AttendanceListQuery按 classId/日期范围/状态筛选 + 分页
* - GraphQL ClassesQuery班级下拉选项
*
* 维护者ai13teacher-portal
*/
import { useState, useMemo } from "react";
import { useQuery } from "urql";
import Link from "next/link";
import { Loading, Empty } from "@edu/ui-components";
import { ClassesQuery } from "@/lib/graphql";
import type { Class } from "@/lib/graphql";
import {
AttendanceListQuery,
} from "@/lib/graphql-p7-admin";
import type {
AttendanceStatus,
AttendanceRecord,
AttendanceListFilter,
} from "@/lib/graphql-p7-admin";
const ALL_STATUSES: { value: AttendanceStatus; label: string }[] = [
{ value: "PRESENT", label: "出勤" },
{ value: "LATE", label: "迟到" },
{ value: "EARLY_LEAVE", label: "早退" },
{ value: "LEAVE", label: "请假" },
{ value: "ABSENT", label: "缺勤" },
];
const STATUS_LABEL: Record<AttendanceStatus, string> = {
PRESENT: "出勤",
LATE: "迟到",
EARLY_LEAVE: "早退",
LEAVE: "请假",
ABSENT: "缺勤",
};
function statusColor(status: AttendanceStatus): string {
switch (status) {
case "PRESENT":
return "var(--color-success)";
case "LATE":
return "var(--color-warning)";
case "EARLY_LEAVE":
return "var(--color-warning)";
case "LEAVE":
return "var(--color-accent)";
case "ABSENT":
return "var(--color-danger)";
default:
return "var(--color-ink-muted)";
}
}
const PAGE_SIZE = 30;
function todayStr(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function dateMinusDays(days: number): string {
const d = new Date();
d.setDate(d.getDate() - days);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
export default function AttendanceListPage(): React.ReactNode {
const [classId, setClassId] = useState("");
const [startDate, setStartDate] = useState(dateMinusDays(6));
const [endDate, setEndDate] = useState(todayStr());
const [selectedStatuses, setSelectedStatuses] = useState<AttendanceStatus[]>(
[],
);
const [page, setPage] = useState(1);
const [classesResult] = useQuery({ query: ClassesQuery });
const classes: Class[] = classesResult.data?.classes ?? [];
const firstClassId = classes[0]?.id ?? "";
const filter: AttendanceListFilter = useMemo(
() => ({
classId: classId || firstClassId || null,
startDate: startDate || null,
endDate: endDate || null,
statuses: selectedStatuses.length > 0 ? selectedStatuses : null,
page,
pageSize: PAGE_SIZE,
}),
[classId, firstClassId, startDate, endDate, selectedStatuses, page],
);
const [result] = useQuery({
query: AttendanceListQuery,
variables: { filter },
});
const data = result.data?.attendanceList;
const items: AttendanceRecord[] = data?.items ?? [];
const total = data?.total ?? 0;
const totalPages = data?.totalPages ?? 1;
const toggleStatus = (s: AttendanceStatus): void => {
setSelectedStatuses((prev) =>
prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s],
);
setPage(1);
};
return (
<div className="px-10 py-10">
<header className="mb-8 flex items-baseline justify-between">
<div>
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL AttendanceListQuery · // + P7 MSW mock
</p>
</div>
<div className="flex items-center gap-3">
<Link
href="/attendance/sheet"
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
>
</Link>
<Link
href="/attendance/stats"
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle"
>
</Link>
<Link
href="/attendance/report"
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle"
>
</Link>
</div>
</header>
<div className="rule-thin mb-8" />
{/* 筛选栏 */}
<section className="mb-8">
<div className="grid grid-cols-3 gap-6 mb-4">
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<select
value={classId || firstClassId}
onChange={(e) => {
setClassId(e.target.value);
setPage(1);
}}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
>
<option value=""></option>
{classes.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<input
type="date"
value={startDate}
onChange={(e) => {
setStartDate(e.target.value);
setPage(1);
}}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
/>
</div>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<input
type="date"
value={endDate}
onChange={(e) => {
setEndDate(e.target.value);
setPage(1);
}}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
/>
</div>
</div>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-2">
</label>
<div className="flex items-center gap-2 flex-wrap">
{ALL_STATUSES.map((s) => {
const active = selectedStatuses.includes(s.value);
return (
<button
key={s.value}
type="button"
onClick={() => toggleStatus(s.value)}
className={`px-3 py-1 text-tiny uppercase tracking-wide rounded-button border transition-colors ${
active
? "bg-accent text-ink-on-accent border-accent"
: "bg-transparent text-ink-muted border-rule hover:text-ink"
}`}
>
{s.label}
</button>
);
})}
</div>
</div>
</section>
{result.fetching ? (
<Loading lines={6} />
) : result.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{result.error.message}
</p>
</div>
) : items.length === 0 ? (
<Empty title="暂无考勤记录" description="当前筛选条件下没有记录" />
) : (
<>
{/* 数据表 */}
<section>
<div className="flex items-baseline justify-between mb-4">
<h2 className="text-xl font-serif text-ink">
<span className="ml-2 text-sm font-sans text-ink-muted">
{total}
</span>
</h2>
<p className="text-tiny text-ink-muted">
{page} / {totalPages}
</p>
</div>
<div className="rule-thin mb-4" />
<div className="border border-rule rounded-card overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-subtle">
<tr>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
</tr>
</thead>
<tbody>
{items.map((r) => (
<tr key={r.id} className="border-t border-rule">
<td className="px-4 py-3 font-serif text-ink">
{r.className}
</td>
<td className="px-4 py-3 font-mono text-tiny text-ink-muted">
{r.date}
</td>
<td className="px-4 py-3 font-serif text-ink">
{r.studentName}
</td>
<td className="px-4 py-3 font-mono text-tiny text-ink-muted">
{r.studentNo}
</td>
<td className="px-4 py-3">
<span
className="inline-block px-2 py-0.5 rounded-button text-tiny font-mono"
style={{
color: statusColor(r.status),
border: `1px solid ${statusColor(r.status)}`,
}}
>
{STATUS_LABEL[r.status]}
</span>
</td>
<td className="px-4 py-3 text-tiny text-ink-muted">
{r.note ?? "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 分页 */}
<div className="mt-6 flex items-center justify-between">
<button
type="button"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle disabled:opacity-40 disabled:cursor-not-allowed"
>
</button>
<span className="text-tiny text-ink-muted">
{page} / {totalPages} {PAGE_SIZE}
</span>
<button
type="button"
onClick={() =>
setPage((p) => Math.min(totalPages, p + 1))
}
disabled={page >= totalPages}
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle disabled:opacity-40 disabled:cursor-not-allowed"
>
</button>
</div>
</section>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,337 @@
"use client";
/**
* Attendance 报告页 - 周报/月报 + 打印
*
* 数据来源P7 扩展MSW mock
* - GraphQL AttendanceReportQuery按 classId + reportType + 日期范围生成报告数据
* - GraphQL ClassesQuery班级下拉选项
*
* A4 纸质风格 + window.print()
*
* 维护者ai13teacher-portal
*/
import { useState, useMemo } from "react";
import { useQuery } from "urql";
import Link from "next/link";
import { Loading, Empty } from "@edu/ui-components";
import { ClassesQuery } from "@/lib/graphql";
import type { Class } from "@/lib/graphql";
import { AttendanceReportQuery } from "@/lib/graphql-p7-admin";
import type {
AttendanceReport as AttendanceReportData,
AttendanceReportType,
} from "@/lib/graphql-p7-admin";
function dateMinusDays(days: number): string {
const d = new Date();
d.setDate(d.getDate() - days);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function todayStr(): string {
return dateMinusDays(0);
}
const REPORT_TYPES: { value: AttendanceReportType; label: string; days: number }[] = [
{ value: "WEEKLY", label: "周报", days: 7 },
{ value: "MONTHLY", label: "月报", days: 30 },
];
export default function AttendanceReportPage(): React.ReactNode {
const [classId, setClassId] = useState("");
const [reportType, setReportType] = useState<AttendanceReportType>("WEEKLY");
const [endDate, setEndDate] = useState(todayStr());
const [classesResult] = useQuery({ query: ClassesQuery });
const classes: Class[] = classesResult.data?.classes ?? [];
const firstClassId = classes[0]?.id ?? "";
const targetClassId = classId || firstClassId;
const startDate = useMemo(() => {
const preset = REPORT_TYPES.find((r) => r.value === reportType);
const days = preset?.days ?? 7;
return dateMinusDays(days - 1);
}, [reportType]);
const [reportResult] = useQuery({
query: AttendanceReportQuery,
variables: {
classId: targetClassId,
reportType,
startDate,
endDate,
},
pause: !targetClassId,
});
const report: AttendanceReportData | undefined =
reportResult.data?.attendanceReport;
const handlePrint = (): void => {
if (typeof window !== "undefined") {
window.print();
}
};
return (
<div className="px-10 py-10">
<header className="mb-8 print:hidden">
<p className="mb-2">
<Link
href="/attendance"
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
</p>
<div className="flex items-baseline justify-between">
<div>
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL AttendanceReportQuery · / · A4 P7 MSW mock
</p>
</div>
{report && (
<button
type="button"
onClick={handlePrint}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
>
/ PDF
</button>
)}
</div>
</header>
<div className="rule-thin mb-8 print:hidden" />
{/* 筛选栏 */}
<section className="mb-8 print:hidden">
<div className="grid grid-cols-3 gap-6">
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<select
value={targetClassId}
onChange={(e) => setClassId(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
>
<option value=""></option>
{classes.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<select
value={reportType}
onChange={(e) =>
setReportType(e.target.value as AttendanceReportType)
}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
>
{REPORT_TYPES.map((r) => (
<option key={r.value} value={r.value}>
{r.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
/>
</div>
</div>
<p className="mt-3 text-tiny text-ink-muted">
{startDate} ~ {endDate}{reportType === "WEEKLY" ? "周报" : "月报"}
</p>
</section>
{reportResult.fetching ? (
<Loading lines={6} />
) : reportResult.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{reportResult.error.message}
</p>
</div>
) : !report ? (
<Empty title="暂无报告" description="请选择班级和报告类型" />
) : (
<article className="bg-surface border border-rule rounded-card p-10 max-w-[820px] mx-auto">
{/* 报告标题 */}
<header className="text-center mb-8 pb-6 border-b border-rule">
<h1 className="text-2xl font-serif text-ink">
{report.reportType === "WEEKLY" ? "周" : "月"}
</h1>
<p className="mt-2 text-sm text-ink-muted">
{report.className} · {report.startDate} ~ {report.endDate}
</p>
<p className="mt-1 text-tiny font-mono text-ink-muted">
{new Date(report.generatedAt).toLocaleString("zh-CN")}
</p>
</header>
{/* 班级信息 */}
<section className="mb-8">
<h2 className="text-lg font-serif text-ink mb-3"></h2>
<dl className="grid grid-cols-2 gap-y-2 text-sm">
<dt className="text-ink-muted"></dt>
<dd className="text-ink">{report.className}</dd>
<dt className="text-ink-muted"></dt>
<dd className="text-ink">
{report.reportType === "WEEKLY" ? "周报" : "月报"}
</dd>
<dt className="text-ink-muted"></dt>
<dd className="font-mono text-ink">
{report.startDate} ~ {report.endDate}
</dd>
<dt className="text-ink-muted"></dt>
<dd className="font-mono text-ink">
{report.summary.totalRecords}
</dd>
</dl>
</section>
{/* 统计摘要 */}
<section className="mb-8">
<h2 className="text-lg font-serif text-ink mb-3"></h2>
<div className="grid grid-cols-4 gap-3">
<div className="p-3 border border-rule rounded-card text-center">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
</p>
<p className="mt-1 text-2xl font-serif text-accent">
{report.summary.presentRate.toFixed(1)}%
</p>
</div>
<div className="p-3 border border-rule rounded-card text-center">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
</p>
<p className="mt-1 text-2xl font-serif text-warning">
{report.summary.lateRate.toFixed(1)}%
</p>
</div>
<div className="p-3 border border-rule rounded-card text-center">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
退
</p>
<p className="mt-1 text-2xl font-serif text-warning">
{report.summary.earlyLeaveRate.toFixed(1)}%
</p>
</div>
<div className="p-3 border border-rule rounded-card text-center">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
</p>
<p className="mt-1 text-2xl font-serif text-ink">
{report.summary.leaveRate.toFixed(1)}%
</p>
</div>
</div>
<p className="mt-3 text-tiny text-ink-muted">
{report.summary.warningCount}
</p>
</section>
{/* 明细表 */}
<section className="mb-8">
<h2 className="text-lg font-serif text-ink mb-3"></h2>
<div className="border border-rule rounded-card overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-subtle">
<tr>
<th className="px-3 py-2 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-3 py-2 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-3 py-2 text-right text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-3 py-2 text-right text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-3 py-2 text-right text-tiny uppercase tracking-wide text-ink-muted">
退
</th>
<th className="px-3 py-2 text-right text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-3 py-2 text-right text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-3 py-2 text-right text-tiny uppercase tracking-wide text-ink-muted">
</th>
</tr>
</thead>
<tbody>
{report.details.map((d) => (
<tr key={d.studentId} className="border-t border-rule">
<td className="px-3 py-2 font-mono text-tiny text-ink-muted">
{d.studentNo}
</td>
<td className="px-3 py-2 font-serif text-ink">
{d.studentName}
</td>
<td className="px-3 py-2 text-right font-mono text-tiny text-ink">
{d.presentCount}
</td>
<td className="px-3 py-2 text-right font-mono text-tiny text-ink-muted">
{d.lateCount}
</td>
<td className="px-3 py-2 text-right font-mono text-tiny text-ink-muted">
{d.earlyLeaveCount}
</td>
<td className="px-3 py-2 text-right font-mono text-tiny text-ink-muted">
{d.leaveCount}
</td>
<td className="px-3 py-2 text-right font-mono text-tiny text-danger">
{d.absentCount}
</td>
<td className="px-3 py-2 text-right font-serif text-ink">
{d.presentRate.toFixed(1)}%
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
{/* 教师签字 */}
<section className="mt-12 pt-6 border-t border-rule">
<div className="flex items-end justify-between">
<div className="text-sm text-ink-muted">
<p>________________________</p>
<p className="mt-2">________________________</p>
</div>
<div className="text-sm text-ink-muted text-right">
<p>__________________</p>
<p className="mt-2">__________________</p>
</div>
</div>
</section>
</article>
)}
</div>
);
}

View File

@@ -0,0 +1,344 @@
"use client";
/**
* Attendance 录入页 - 批量录入考勤
*
* 数据来源P7 扩展MSW mock
* - GraphQL AttendanceSheetQuery按 classId + date 拉取学生列表 + 当天状态
* - GraphQL SaveAttendanceSheetMutation批量保存考勤
* - GraphQL ClassesQuery班级下拉选项
*
* 维护者ai13teacher-portal
*/
import { useState, useMemo, useEffect } from "react";
import { useQuery, useMutation } from "urql";
import Link from "next/link";
import { Loading, Empty } from "@edu/ui-components";
import { ClassesQuery } from "@/lib/graphql";
import type { Class } from "@/lib/graphql";
import {
AttendanceSheetQuery,
SaveAttendanceSheetMutation,
} from "@/lib/graphql-p7-admin";
import type {
AttendanceStatus,
AttendanceSheetItem,
SaveAttendanceRecordInput,
} from "@/lib/graphql-p7-admin";
const STATUS_OPTIONS: { value: AttendanceStatus; label: string }[] = [
{ value: "PRESENT", label: "出勤" },
{ value: "LATE", label: "迟到" },
{ value: "LEAVE", label: "请假" },
{ value: "ABSENT", label: "缺勤" },
{ value: "EARLY_LEAVE", label: "早退" },
];
function statusColor(status: AttendanceStatus): string {
switch (status) {
case "PRESENT":
return "var(--color-success)";
case "LATE":
return "var(--color-warning)";
case "EARLY_LEAVE":
return "var(--color-warning)";
case "LEAVE":
return "var(--color-accent)";
case "ABSENT":
return "var(--color-danger)";
default:
return "var(--color-ink-muted)";
}
}
function todayStr(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
interface RowState {
status: AttendanceStatus;
note: string;
}
export default function AttendanceSheetPage(): React.ReactNode {
const [classId, setClassId] = useState("");
const [date, setDate] = useState(todayStr());
const [rows, setRows] = useState<Record<string, RowState>>({});
const [submitting, setSubmitting] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [savedMsg, setSavedMsg] = useState<string | null>(null);
const [classesResult] = useQuery({ query: ClassesQuery });
const classes: Class[] = classesResult.data?.classes ?? [];
const firstClassId = classes[0]?.id ?? "";
const targetClassId = classId || firstClassId;
const [sheetResult, reexecuteSheet] = useQuery({
query: AttendanceSheetQuery,
variables: { classId: targetClassId, date },
pause: !targetClassId || !date,
});
const sheet: AttendanceSheetItem[] = sheetResult.data?.attendanceSheet ?? [];
const [, saveSheet] = useMutation(SaveAttendanceSheetMutation);
// 当 sheet 数据变化时同步本地 rows
useEffect(() => {
const next: Record<string, RowState> = {};
for (const item of sheet) {
next[item.studentId] = {
status: item.status ?? "PRESENT",
note: item.note ?? "",
};
}
setRows(next);
setSavedMsg(null);
setSaveError(null);
}, [sheet]);
const dirtyCount = useMemo(() => {
let count = 0;
for (const item of sheet) {
const row = rows[item.studentId];
if (!row) continue;
const originalStatus = item.status ?? "PRESENT";
const originalNote = item.note ?? "";
if (row.status !== originalStatus || row.note !== originalNote) {
count++;
}
}
return count;
}, [rows, sheet]);
const handleSaveAll = async (): Promise<void> => {
if (!targetClassId || !date) {
setSaveError("请选择班级和日期");
return;
}
const records: SaveAttendanceRecordInput[] = [];
for (const item of sheet) {
const row = rows[item.studentId];
if (!row) continue;
records.push({
studentId: item.studentId,
status: row.status,
note: row.note.trim() || null,
});
}
if (records.length === 0) {
setSaveError("当前无可保存的记录");
return;
}
setSubmitting(true);
setSaveError(null);
setSavedMsg(null);
const res = await saveSheet({
input: { classId: targetClassId, date, records },
});
setSubmitting(false);
if (res.error) {
setSaveError(res.error.message);
return;
}
setSavedMsg(
`已保存 ${res.data?.saveAttendanceSheet?.savedCount ?? records.length} 条记录`,
);
reexecuteSheet({ requestPolicy: "network-only" });
};
return (
<div className="px-10 py-10">
<header className="mb-8">
<p className="mb-2">
<Link
href="/attendance"
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
</p>
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL AttendanceSheetQuery + SaveAttendanceSheetMutation · P7 MSW mock
</p>
</header>
<div className="rule-thin mb-8" />
{/* 班级 + 日期选择 */}
<section className="mb-8">
<div className="grid grid-cols-2 gap-6 mb-4">
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<select
value={targetClassId}
onChange={(e) => setClassId(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
>
<option value=""></option>
{classes.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
/>
</div>
</div>
</section>
{!targetClassId || !date ? (
<Empty
title="请选择班级和日期"
description="选择后即可拉取学生名单进行批量录入"
/>
) : sheetResult.fetching ? (
<Loading lines={8} />
) : sheetResult.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{sheetResult.error.message}
</p>
</div>
) : sheet.length === 0 ? (
<Empty title="暂无学生" description="该班级尚未导入学生名单" />
) : (
<section>
<div className="flex items-baseline justify-between mb-4">
<h2 className="text-xl font-serif text-ink">
<span className="ml-2 text-sm font-sans text-ink-muted">
{sheet.length} · {dirtyCount}
</span>
</h2>
<div className="flex items-center gap-3">
{saveError && (
<span className="text-tiny text-danger">{saveError}</span>
)}
{savedMsg && !submitting && (
<span className="text-tiny text-success">{savedMsg}</span>
)}
<button
type="button"
onClick={handleSaveAll}
disabled={submitting}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
>
{submitting ? "保存中..." : "保存全部"}
</button>
</div>
</div>
<div className="rule-thin mb-4" />
{/* 录入表格 */}
<div className="border border-rule rounded-card overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-subtle">
<tr>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
</tr>
</thead>
<tbody>
{sheet.map((item) => {
const row = rows[item.studentId];
const currentStatus = row?.status ?? "PRESENT";
return (
<tr
key={item.studentId}
className="border-t border-rule"
>
<td className="px-4 py-3 font-serif text-ink">
{item.studentName}
</td>
<td className="px-4 py-3 font-mono text-tiny text-ink-muted">
{item.studentNo}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1 flex-wrap">
{STATUS_OPTIONS.map((opt) => {
const active = currentStatus === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() =>
setRows((prev) => ({
...prev,
[item.studentId]: {
status: opt.value,
note: row?.note ?? "",
},
}))
}
className="px-2 py-0.5 text-tiny rounded-button border transition-colors"
style={{
color: active
? "var(--bg-paper)"
: statusColor(opt.value),
borderColor: statusColor(opt.value),
background: active
? statusColor(opt.value)
: "transparent",
}}
>
{opt.label}
</button>
);
})}
</div>
</td>
<td className="px-4 py-3">
<input
type="text"
value={row?.note ?? ""}
onChange={(ev) =>
setRows((prev) => ({
...prev,
[item.studentId]: {
status: currentStatus,
note: ev.target.value,
},
}))
}
className="w-full px-2 py-1 bg-paper border border-rule rounded-button text-sm text-ink focus:outline-none focus:border-accent"
placeholder="可选:备注"
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
)}
</div>
);
}

View File

@@ -0,0 +1,551 @@
"use client";
/**
* Attendance 统计页 - 班级考勤统计 + 趋势图 + 班级对比 + 学生预警
*
* 数据来源P7 扩展MSW mock
* - GraphQL AttendanceStatsQuery班级考勤统计含 30 天趋势)
* - GraphQL AttendanceClassComparisonQuery5 班级出勤率对比
* - GraphQL AttendanceWarningsQuery学生考勤预警出勤率 < 90%
* - GraphQL ClassesQuery班级下拉选项
*
* SVG 图表参考 grades/analytics/charts.tsx 模式var(--color-*) 语义令牌)
*
* 维护者ai13teacher-portal
*/
import { useState, useMemo } from "react";
import { useQuery } from "urql";
import Link from "next/link";
import { Loading, Empty } from "@edu/ui-components";
import { ClassesQuery } from "@/lib/graphql";
import type { Class } from "@/lib/graphql";
import {
AttendanceStatsQuery,
AttendanceClassComparisonQuery,
AttendanceWarningsQuery,
} from "@/lib/graphql-p7-admin";
import type {
AttendanceStats,
AttendanceTrendPoint,
AttendanceClassComparison,
AttendanceWarning,
} from "@/lib/graphql-p7-admin";
function dateMinusDays(days: number): string {
const d = new Date();
d.setDate(d.getDate() - days);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function todayStr(): string {
return dateMinusDays(0);
}
const RANGE_PRESETS: { label: string; days: number }[] = [
{ label: "近 7 天", days: 7 },
{ label: "近 14 天", days: 14 },
{ label: "近 30 天", days: 30 },
];
// ============ 趋势折线图30 天每日出勤率) ============
const TREND_PADDING = { top: 24, right: 24, bottom: 36, left: 36 };
const TREND_W = 720;
const TREND_H = 220;
function TrendLineChart({ data }: { data: AttendanceTrendPoint[] }): React.ReactNode {
if (data.length === 0) {
return <p className="text-sm text-ink-muted"></p>;
}
const innerW = TREND_W - TREND_PADDING.left - TREND_PADDING.right;
const innerH = TREND_H - TREND_PADDING.top - TREND_PADDING.bottom;
const yMax = 100;
const yMin = 0;
const yRange = yMax - yMin;
const xStep = data.length > 1 ? innerW / (data.length - 1) : 0;
const toPoint = (value: number, i: number) => ({
x: TREND_PADDING.left + i * xStep,
y: TREND_PADDING.top + innerH - ((value - yMin) / yRange) * innerH,
});
const buildPath = (key: "presentRate" | "lateRate" | "absentRate") =>
data
.map((d, i) => {
const p = toPoint(d[key], i);
return `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`;
})
.join(" ");
const gridLines = [0, 50, 100].map((v) => {
const ratio = 1 - (v - yMin) / yRange;
return { y: TREND_PADDING.top + innerH * ratio, value: v };
});
const lineColor = (key: "presentRate" | "lateRate" | "absentRate") =>
key === "presentRate"
? "var(--color-accent)"
: key === "lateRate"
? "var(--color-warning)"
: "var(--color-danger)";
// X 轴标签最多显示 6 个
const labelStep = Math.max(1, Math.ceil(data.length / 6));
return (
<svg
viewBox={`0 0 ${TREND_W} ${TREND_H}`}
className="w-full border border-rule rounded-card bg-surface"
role="img"
aria-label="考勤趋势折线图"
>
{gridLines.map((g, i) => (
<g key={`grid-${i}`}>
<line
x1={TREND_PADDING.left}
y1={g.y}
x2={TREND_W - TREND_PADDING.right}
y2={g.y}
stroke="var(--color-rule)"
strokeWidth={1}
strokeDasharray="3 3"
/>
<text
x={TREND_PADDING.left - 8}
y={g.y + 4}
textAnchor="end"
fontSize="11"
fontFamily="var(--font-family-mono)"
fill="var(--color-ink-muted)"
>
{g.value}
</text>
</g>
))}
{(["absentRate", "lateRate", "presentRate"] as const).map((key) => (
<g key={key}>
<path
d={buildPath(key)}
fill="none"
stroke={lineColor(key)}
strokeWidth={key === "presentRate" ? 2.5 : 1.5}
strokeDasharray={key === "presentRate" ? undefined : "4 3"}
strokeLinecap="round"
strokeLinejoin="round"
/>
{data.map((d, i) => {
const p = toPoint(d[key], i);
return (
<circle
key={`${key}-${i}`}
cx={p.x}
cy={p.y}
r={key === "presentRate" ? 3 : 2}
fill={lineColor(key)}
stroke="var(--bg-paper)"
strokeWidth={1}
/>
);
})}
</g>
))}
{data.map((d, i) =>
i % labelStep === 0 ? (
<text
key={`x-${i}`}
x={TREND_PADDING.left + i * xStep}
y={TREND_H - TREND_PADDING.bottom + 18}
textAnchor="middle"
fontSize="10"
fontFamily="var(--font-family-sans)"
fill="var(--color-ink-muted)"
>
{d.date.slice(5)}
</text>
) : null,
)}
</svg>
);
}
// ============ 班级对比柱图 ============
const CMP_W = 480;
const CMP_H = 200;
const CMP_PAD = { top: 24, right: 16, bottom: 48, left: 36 };
function ClassComparisonChart({
data,
}: {
data: AttendanceClassComparison[];
}): React.ReactNode {
if (data.length === 0) {
return <p className="text-sm text-ink-muted"></p>;
}
const maxRate = 100;
const innerW = CMP_W - CMP_PAD.left - CMP_PAD.right;
const innerH = CMP_H - CMP_PAD.top - CMP_PAD.bottom;
const barW = innerW / data.length;
return (
<svg
viewBox={`0 0 ${CMP_W} ${CMP_H}`}
className="w-full border border-rule rounded-card bg-surface"
role="img"
aria-label="班级考勤对比柱图"
>
{[0, 0.5, 1].map((ratio, i) => {
const y = CMP_PAD.top + innerH * ratio;
const value = Math.round(maxRate * (1 - ratio));
return (
<g key={`grid-${i}`}>
<line
x1={CMP_PAD.left}
y1={y}
x2={CMP_W - CMP_PAD.right}
y2={y}
stroke="var(--color-rule)"
strokeWidth={1}
strokeDasharray="3 3"
/>
<text
x={CMP_PAD.left - 8}
y={y + 4}
textAnchor="end"
fontSize="11"
fontFamily="var(--font-family-mono)"
fill="var(--color-ink-muted)"
>
{value}%
</text>
</g>
);
})}
{data.map((item, i) => {
const barH = (item.presentRate / maxRate) * innerH;
const x = CMP_PAD.left + i * barW + barW * 0.2;
const y = CMP_PAD.top + innerH - barH;
const w = barW * 0.6;
return (
<g key={item.classId}>
<rect
x={x}
y={y}
width={w}
height={Math.max(0, barH)}
fill="var(--color-accent)"
rx={3}
/>
<text
x={x + w / 2}
y={y - 6}
textAnchor="middle"
fontSize="11"
fontFamily="var(--font-family-mono)"
fontWeight="600"
fill="var(--color-ink)"
>
{item.presentRate}%
</text>
<text
x={x + w / 2}
y={CMP_H - CMP_PAD.bottom + 14}
textAnchor="middle"
fontSize="10"
fontFamily="var(--font-family-sans)"
fill="var(--color-ink-muted)"
>
{item.className}
</text>
</g>
);
})}
</svg>
);
}
export default function AttendanceStatsPage(): React.ReactNode {
const [classId, setClassId] = useState("");
const [rangeDays, setRangeDays] = useState(7);
const [classesResult] = useQuery({ query: ClassesQuery });
const classes: Class[] = classesResult.data?.classes ?? [];
const firstClassId = classes[0]?.id ?? "";
const targetClassId = classId || firstClassId;
const startDate = useMemo(() => dateMinusDays(rangeDays - 1), [rangeDays]);
const endDate = useMemo(() => todayStr(), []);
const [statsResult] = useQuery({
query: AttendanceStatsQuery,
variables: { classId: targetClassId, startDate, endDate },
pause: !targetClassId,
});
const [cmpResult] = useQuery({
query: AttendanceClassComparisonQuery,
variables: { startDate, endDate },
});
const [warningsResult] = useQuery({
query: AttendanceWarningsQuery,
variables: { classId: targetClassId, startDate, endDate },
pause: !targetClassId,
});
const stats: AttendanceStats | undefined = statsResult.data?.attendanceStats;
const comparisons: AttendanceClassComparison[] =
cmpResult.data?.attendanceClassComparison ?? [];
const warnings: AttendanceWarning[] =
warningsResult.data?.attendanceWarnings ?? [];
return (
<div className="px-10 py-10">
<header className="mb-8 flex items-baseline justify-between">
<div>
<p className="mb-2">
<Link
href="/attendance"
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
</p>
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL AttendanceStatsQuery · + + + P7 MSW mock
</p>
</div>
</header>
<div className="rule-thin mb-8" />
{/* 班级 + 时间范围筛选 */}
<section className="mb-8">
<div className="grid grid-cols-2 gap-6 mb-4">
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<select
value={targetClassId}
onChange={(e) => setClassId(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
>
<option value=""></option>
{classes.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<div className="flex items-center gap-2 flex-wrap">
{RANGE_PRESETS.map((p) => {
const active = rangeDays === p.days;
return (
<button
key={p.days}
type="button"
onClick={() => setRangeDays(p.days)}
className={`px-3 py-1 text-tiny uppercase tracking-wide rounded-button border transition-colors ${
active
? "bg-accent text-ink-on-accent border-accent"
: "bg-transparent text-ink-muted border-rule hover:text-ink"
}`}
>
{p.label}
</button>
);
})}
</div>
</div>
</div>
<p className="text-tiny text-ink-muted">
{startDate} ~ {endDate}
</p>
</section>
{classesResult.fetching || statsResult.fetching ? (
<Loading lines={6} />
) : statsResult.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{statsResult.error.message}
</p>
</div>
) : !stats ? (
<Empty title="暂无统计数据" description="请选择班级和时间范围" />
) : (
<>
{/* 5 个统计卡片 */}
<section className="mb-10">
<h2 className="text-xl font-serif text-ink mb-4">
{stats.className} ·
</h2>
<div className="rule-thin mb-6" />
<dl className="grid grid-cols-5 gap-4">
<div className="p-4 border border-rule rounded-card">
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
</dt>
<dd className="mt-2 text-3xl font-serif text-accent">
{stats.presentRate.toFixed(1)}%
</dd>
</div>
<div className="p-4 border border-rule rounded-card">
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
</dt>
<dd className="mt-2 text-3xl font-serif text-warning">
{stats.lateRate.toFixed(1)}%
</dd>
</div>
<div className="p-4 border border-rule rounded-card">
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
退
</dt>
<dd className="mt-2 text-3xl font-serif text-warning">
{stats.earlyLeaveRate.toFixed(1)}%
</dd>
</div>
<div className="p-4 border border-rule rounded-card">
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
</dt>
<dd className="mt-2 text-3xl font-serif text-ink">
{stats.leaveRate.toFixed(1)}%
</dd>
</div>
<div className="p-4 border border-rule rounded-card">
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
</dt>
<dd className="mt-2 text-3xl font-serif text-danger">
{stats.warningCount}
</dd>
</div>
</dl>
<p className="mt-4 text-tiny text-ink-muted">
{stats.totalRecords} · {stats.presentCount} ·
{stats.lateCount} · 退 {stats.earlyLeaveCount} ·
{stats.leaveCount} · {stats.absentCount}
</p>
</section>
{/* 趋势折线图 */}
<section className="mb-10">
<h2 className="text-xl font-serif text-ink mb-4">
{stats.trend.length}
</h2>
<div className="rule-thin mb-4" />
<TrendLineChart data={stats.trend} />
<div className="mt-3 flex items-center gap-4 text-tiny text-ink-muted">
<span className="inline-flex items-center gap-1">
<span
className="inline-block w-3 h-0.5"
style={{ background: "var(--color-accent)" }}
/>
</span>
<span className="inline-flex items-center gap-1">
<span
className="inline-block w-3 h-0.5"
style={{
background: "var(--color-warning)",
borderTop: "1px dashed var(--color-warning)",
}}
/>
</span>
<span className="inline-flex items-center gap-1">
<span
className="inline-block w-3 h-0.5"
style={{ background: "var(--color-danger)" }}
/>
</span>
</div>
</section>
{/* 班级对比柱图 */}
<section className="mb-10">
<h2 className="text-xl font-serif text-ink mb-4"></h2>
<div className="rule-thin mb-4" />
<ClassComparisonChart data={comparisons} />
</section>
{/* 学生预警表 */}
<section>
<h2 className="text-xl font-serif text-ink mb-4">
<span className="ml-2 text-sm font-sans text-ink-muted">
&lt; 90% · {warnings.length}
</span>
</h2>
<div className="rule-thin mb-4" />
{warnings.length === 0 ? (
<Empty title="暂无预警" description="所有学生出勤率均达标" />
) : (
<div className="border border-rule rounded-card overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-subtle">
<tr>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted w-32">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted w-24">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted w-24">
</th>
</tr>
</thead>
<tbody>
{warnings.map((w) => (
<tr key={w.studentId} className="border-t border-rule">
<td className="px-4 py-3 font-mono text-tiny text-ink-muted">
{w.studentNo}
</td>
<td className="px-4 py-3 font-serif text-ink">
{w.studentName}
</td>
<td className="px-4 py-3 text-ink-muted">
{w.className}
</td>
<td className="px-4 py-3 font-serif text-danger">
{w.presentRate.toFixed(1)}%
</td>
<td className="px-4 py-3 font-mono text-tiny text-ink-muted">
{w.absentCount}
</td>
<td className="px-4 py-3 font-mono text-tiny text-ink-muted">
{w.lateCount}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,492 @@
"use client";
/**
* 班级详情聚合页 - 单班级完整信息
*
* 数据来源P7 扩展MSW mock
* - GraphQL ClassDetailQuery聚合班级信息 + 学生 + 课表 + 近期作业 + 概览统计 + 成绩趋势
*
* 布局:多 widget 用 ErrorBoundary 隔离,单个 widget 异常不影响整页
*
* 维护者ai13teacher-portal
*/
import { useQuery } from "urql";
import Link from "next/link";
import { useParams } from "next/navigation";
import { ErrorBoundary, Loading, Empty } from "@edu/ui-components";
import { ClassDetailQuery } from "@/lib/graphql-p7-admin";
import type {
ClassDetailAggregate,
ClassDetailTrendPoint,
ClassScheduleItem,
ClassDetailHomework,
} from "@/lib/graphql-p7-admin";
const DAY_LABELS: Record<number, string> = {
1: "周一",
2: "周二",
3: "周三",
4: "周四",
5: "周五",
6: "周六",
7: "周日",
};
function todayDayOfWeek(): number {
const d = new Date().getDay(); // 0 = Sunday
return d === 0 ? 7 : d;
}
/** 班级成绩趋势 mini chart纯 SVG */
function TrendMiniChart({ data }: { data: ClassDetailTrendPoint[] }): React.ReactNode {
if (data.length === 0) {
return <p className="text-tiny text-ink-muted"></p>;
}
const W = 280;
const H = 96;
const PAD = { top: 12, right: 12, bottom: 18, left: 24 };
const innerW = W - PAD.left - PAD.right;
const innerH = H - PAD.top - PAD.bottom;
const values = data.map((d) => d.avgScore);
const yMax = Math.max(100, ...values);
const yMin = Math.min(0, ...values);
const yRange = yMax - yMin || 1;
const xStep = data.length > 1 ? innerW / (data.length - 1) : 0;
const toPoint = (v: number, i: number) => ({
x: PAD.left + i * xStep,
y: PAD.top + innerH - ((v - yMin) / yRange) * innerH,
});
const path = data
.map((d, i) => {
const p = toPoint(d.avgScore, i);
return `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`;
})
.join(" ");
return (
<svg
viewBox={`0 0 ${W} ${H}`}
className="w-full border border-rule rounded-card bg-surface"
role="img"
aria-label="班级成绩趋势图"
>
{[0, 50, 100].map((v) => {
const ratio = 1 - (v - yMin) / yRange;
const y = PAD.top + innerH * ratio;
return (
<g key={`grid-${v}`}>
<line
x1={PAD.left}
y1={y}
x2={W - PAD.right}
y2={y}
stroke="var(--color-rule)"
strokeWidth={1}
strokeDasharray="2 2"
/>
<text
x={PAD.left - 6}
y={y + 3}
textAnchor="end"
fontSize="9"
fontFamily="var(--font-family-mono)"
fill="var(--color-ink-muted)"
>
{v}
</text>
</g>
);
})}
<path
d={path}
fill="none"
stroke="var(--color-accent)"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
{data.map((d, i) => {
const p = toPoint(d.avgScore, i);
return (
<g key={`pt-${i}`}>
<circle
cx={p.x}
cy={p.y}
r={3}
fill="var(--color-accent)"
stroke="var(--bg-paper)"
strokeWidth={1}
/>
<text
x={p.x}
y={H - 4}
textAnchor="middle"
fontSize="9"
fontFamily="var(--font-family-sans)"
fill="var(--color-ink-muted)"
>
{d.month}
</text>
</g>
);
})}
</svg>
);
}
/** 概览统计卡片 */
function OverviewCard({
label,
value,
suffix,
}: {
label: string;
value: string | number;
suffix?: string;
}): React.ReactNode {
return (
<div className="p-4 border border-rule rounded-card bg-surface">
<p className="text-tiny uppercase tracking-wide text-ink-muted">{label}</p>
<p className="mt-2 text-2xl font-serif text-ink">
{value}
{suffix && (
<span className="ml-1 text-sm font-sans text-ink-muted">{suffix}</span>
)}
</p>
</div>
);
}
/** 近期作业 widget */
function HomeworkWidget({
items,
}: {
items: ClassDetailHomework[];
}): React.ReactNode {
return (
<section>
<h3 className="mb-3 text-lg font-serif text-ink"></h3>
<div className="rule-thin mb-3" />
{items.length === 0 ? (
<p className="text-sm text-ink-muted"></p>
) : (
<ul className="space-y-0">
{items.map((h) => (
<li
key={h.id}
className="py-2 grid grid-cols-12 gap-2 items-baseline border-b border-rule"
>
<div className="col-span-5">
<p className="text-sm font-serif text-ink">{h.title}</p>
</div>
<div className="col-span-3 text-tiny font-mono text-ink-muted">
{h.dueDate}
</div>
<div className="col-span-2 text-tiny font-mono text-ink-muted">
{h.submissionRate}%
</div>
<div className="col-span-2 text-right text-tiny font-mono text-ink-muted">
{h.avgScore === null ? "未评" : `均分 ${h.avgScore}`}
</div>
</li>
))}
</ul>
)}
</section>
);
}
/** 本周课表 widget */
function ScheduleWidget({
schedule,
}: {
schedule: ClassScheduleItem[];
}): React.ReactNode {
const today = todayDayOfWeek();
const todayItems = schedule
.filter((s) => s.dayOfWeek === today && today <= 5)
.sort((a, b) => a.period - b.period);
return (
<section>
<h3 className="mb-3 text-lg font-serif text-ink">
<span className="ml-2 text-tiny font-sans text-ink-muted">
{DAY_LABELS[today] ?? "—"}
</span>
</h3>
<div className="rule-thin mb-3" />
{todayItems.length === 0 ? (
<p className="text-sm text-ink-muted"></p>
) : (
<ul className="space-y-0">
{todayItems.map((s) => (
<li
key={s.id}
className="py-2 grid grid-cols-12 gap-2 items-baseline border-b border-rule"
>
<div className="col-span-1 text-tiny font-mono text-ink-muted">
{s.period}
</div>
<div className="col-span-2 text-tiny font-mono text-ink-muted">
{s.startTime}-{s.endTime}
</div>
<div className="col-span-5 text-sm font-serif text-ink">
{s.subject}
</div>
<div className="col-span-2 text-tiny text-ink-muted">
{s.teacherName}
</div>
<div className="col-span-2 text-right text-tiny font-mono text-ink-muted">
{s.room}
</div>
</li>
))}
</ul>
)}
</section>
);
}
/** 学生列表 widget */
function StudentsWidget({
classId,
students,
}: {
classId: string;
students: { id: string; name: string; studentNo: string; email: string }[];
}): React.ReactNode {
return (
<section>
<div className="flex items-baseline justify-between mb-3">
<h3 className="text-lg font-serif text-ink">
<span className="ml-2 text-tiny font-sans text-ink-muted">
{students.length}
</span>
</h3>
<Link
href={`/students?classId=${classId}`}
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
>
</Link>
</div>
<div className="rule-thin mb-3" />
<ul className="grid grid-cols-2 gap-x-6 gap-y-1">
{students.slice(0, 12).map((s) => (
<li
key={s.id}
className="py-2 grid grid-cols-12 gap-2 items-baseline border-b border-rule"
>
<div className="col-span-5 text-sm font-serif text-ink">{s.name}</div>
<div className="col-span-5 text-tiny font-mono text-ink-muted">
{s.studentNo}
</div>
<div className="col-span-2 text-right text-tiny">
<Link
href={`/students/${s.id}`}
className="font-mono text-ink-muted hover:text-accent"
>
</Link>
</div>
</li>
))}
</ul>
</section>
);
}
export default function ClassDetailPage(): React.ReactNode {
const params = useParams<{ id: string }>();
const classId = params?.id ?? "";
const [result] = useQuery({
query: ClassDetailQuery,
variables: { id: classId },
pause: !classId,
});
if (!classId) {
return (
<div className="px-10 py-10">
<Empty title="未指定班级" description="请从班级列表进入详情页" />
</div>
);
}
if (result.fetching) {
return (
<div className="px-10 py-10">
<Loading lines={6} />
</div>
);
}
if (result.error) {
return (
<div className="px-10 py-10">
<header className="mb-8">
<Link
href="/classes"
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
<h1 className="mt-2 text-3xl font-serif text-ink"></h1>
</header>
<div className="rule-thin mb-8" />
<div className="mark-left py-2 mb-4 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{result.error.message}
</p>
</div>
</div>
);
}
const data = result.data?.classDetail as ClassDetailAggregate | undefined;
if (!data) {
return (
<div className="px-10 py-10">
<Empty title="班级不存在" description="未找到该班级的详情数据" />
</div>
);
}
const cls = data.class;
const overview = data.overview;
return (
<div className="px-10 py-10">
{/* 头部 */}
<header className="mb-8">
<p className="mb-2">
<Link
href="/classes"
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
</p>
<div className="flex items-baseline justify-between">
<div>
<h1 className="text-3xl font-serif text-ink">{cls.name}</h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL ClassDetailQuery · P7 MSW mock
</p>
</div>
<Link
href={`/classes/schedule?classId=${cls.id}`}
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle"
>
</Link>
</div>
</header>
<div className="rule-thin mb-8" />
{/* 班级信息 */}
<section className="mb-8 grid grid-cols-4 gap-4">
<div>
<p className="text-tiny uppercase tracking-wide text-ink-muted"></p>
<p className="mt-1 text-sm font-serif text-ink">{cls.subject}</p>
</div>
<div>
<p className="text-tiny uppercase tracking-wide text-ink-muted"></p>
<p className="mt-1 text-sm font-serif text-ink">
{cls.studentCount}
</p>
</div>
<div>
<p className="text-tiny uppercase tracking-wide text-ink-muted"></p>
<p className="mt-1 text-sm font-serif text-ink">
{cls.homeroomTeacher}
</p>
</div>
<div>
<p className="text-tiny uppercase tracking-wide text-ink-muted"></p>
<p className="mt-1 text-sm font-mono text-ink">{cls.room ?? "—"}</p>
</div>
</section>
{/* 概览统计卡片 */}
<ErrorBoundary
fallback={
<p className="text-tiny text-danger"></p>
}
>
<section className="mb-8">
<h2 className="mb-4 text-xl font-serif text-ink"></h2>
<div className="rule-thin mb-4" />
<div className="grid grid-cols-4 gap-4">
<OverviewCard
label="出勤率"
value={overview.attendanceRate.toFixed(1)}
suffix="%"
/>
<OverviewCard
label="平均分"
value={overview.averageScore.toFixed(1)}
/>
<OverviewCard
label="作业完成率"
value={overview.homeworkCompletionRate.toFixed(1)}
suffix="%"
/>
<OverviewCard
label="最近考试"
value={
overview.recentExamTitle
? `${overview.recentExamAvg ?? "—"}`
: "无"
}
suffix={
overview.recentExamTitle
? `(均分 ${overview.recentExamAvg ?? "—"})`
: undefined
}
/>
</div>
{overview.recentExamTitle && (
<p className="mt-2 text-tiny text-ink-muted">
{overview.recentExamTitle}
</p>
)}
</section>
</ErrorBoundary>
{/* 双列布局:左列作业 + 学生;右列课表 + 趋势 */}
<div className="grid grid-cols-2 gap-8">
<ErrorBoundary
fallback={<p className="text-tiny text-danger"></p>}
>
<HomeworkWidget items={data.recentHomework} />
</ErrorBoundary>
<ErrorBoundary
fallback={<p className="text-tiny text-danger"></p>}
>
<ScheduleWidget schedule={data.schedule} />
</ErrorBoundary>
<ErrorBoundary
fallback={<p className="text-tiny text-danger"></p>}
>
<StudentsWidget classId={cls.id} students={data.students} />
</ErrorBoundary>
<ErrorBoundary
fallback={<p className="text-tiny text-danger"></p>}
>
<section>
<h3 className="mb-3 text-lg font-serif text-ink"></h3>
<div className="rule-thin mb-3" />
<TrendMiniChart data={data.trend} />
</section>
</ErrorBoundary>
</div>
</div>
);
}

View File

@@ -0,0 +1,250 @@
"use client";
/**
* 班级课表页 - 周课表网格
*
* 数据来源P7 扩展MSW mock
* - GraphQL ClassScheduleQuery按 classId 拉取周课表
* - GraphQL ClassesQuery班级下拉选项
*
* 布局CSS Grid周一到周五 × 8 节),每个卡片显示 学科/教师/教室
*
* 维护者ai13teacher-portal
*/
import { useState, useMemo, Suspense } from "react";
import { useQuery } from "urql";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { Loading, Empty } from "@edu/ui-components";
import { ClassesQuery } from "@/lib/graphql";
import type { Class } from "@/lib/graphql";
import { ClassScheduleQuery } from "@/lib/graphql-p7-admin";
import type { ClassScheduleItem } from "@/lib/graphql-p7-admin";
const DAY_LABELS: Record<number, string> = {
1: "周一",
2: "周二",
3: "周三",
4: "周四",
5: "周五",
6: "周六",
7: "周日",
};
const DAYS = [1, 2, 3, 4, 5] as const;
const PERIODS = [1, 2, 3, 4, 5, 6, 7, 8] as const;
const PERIOD_TIMES: Record<number, { start: string; end: string }> = {
1: { start: "08:00", end: "08:45" },
2: { start: "08:55", end: "09:40" },
3: { start: "10:00", end: "10:45" },
4: { start: "10:55", end: "11:40" },
5: { start: "13:30", end: "14:15" },
6: { start: "14:25", end: "15:10" },
7: { start: "15:25", end: "16:10" },
8: { start: "16:20", end: "17:05" },
};
function ScheduleContent(): React.ReactNode {
const searchParams = useSearchParams();
const initialClassId = searchParams.get("classId") ?? "";
const [classId, setClassId] = useState(initialClassId);
const [classesResult] = useQuery({ query: ClassesQuery });
const classes: Class[] = classesResult.data?.classes ?? [];
const firstClassId = classes[0]?.id ?? "";
const targetClassId = classId || firstClassId;
const [scheduleResult] = useQuery({
query: ClassScheduleQuery,
variables: { classId: targetClassId },
pause: !targetClassId,
});
const schedule: ClassScheduleItem[] =
(scheduleResult.data?.classSchedule as ClassScheduleItem[] | undefined) ?? [];
// 按 dayOfWeek + period 分组
const grouped = useMemo(() => {
const map: Record<string, ClassScheduleItem> = {};
for (const item of schedule) {
const key = `${item.dayOfWeek}-${item.period}`;
map[key] = item;
}
return map;
}, [schedule]);
const currentClassName =
classes.find((c) => c.id === targetClassId)?.name ?? "—";
return (
<div className="px-10 py-10">
<header className="mb-8 flex items-baseline justify-between">
<div>
<p className="mb-2">
<Link
href="/classes"
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
</p>
<h1 className="text-3xl font-serif text-ink">
{currentClassName} -
</h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL ClassScheduleQuery · P7 MSW mock
</p>
</div>
</header>
<div className="rule-thin mb-8" />
{/* 班级筛选 */}
<section className="mb-8">
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<select
value={targetClassId}
onChange={(e) => setClassId(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
>
<option value=""></option>
{classes.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
</div>
</section>
{!targetClassId ? (
<Empty title="请选择班级" description="选择后即可查看该班级的周课表" />
) : scheduleResult.fetching ? (
<Loading lines={6} />
) : scheduleResult.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{scheduleResult.error.message}
</p>
</div>
) : schedule.length === 0 ? (
<Empty title="暂无课表" description="该班级尚未排课" />
) : (
<section>
<div className="flex items-baseline justify-between mb-4">
<h2 className="text-xl font-serif text-ink">
<span className="ml-2 text-sm font-sans text-ink-muted">
{schedule.length}
</span>
</h2>
<p className="text-tiny text-ink-muted">
· {PERIODS.length}
</p>
</div>
<div className="rule-thin mb-4" />
{/* 课表网格:第一列为节次,后续 5 列为周一到周五 */}
<div className="border border-rule rounded-card overflow-hidden">
<div
className="grid"
style={{
gridTemplateColumns: `80px repeat(${DAYS.length}, minmax(0, 1fr))`,
}}
>
{/* 表头 */}
<div className="px-3 py-2 text-tiny uppercase tracking-wide text-ink-muted bg-subtle border-b border-rule text-center">
</div>
{DAYS.map((day) => (
<div
key={`head-${day}`}
className="px-3 py-2 text-tiny uppercase tracking-wide text-ink-muted bg-subtle border-b border-l border-rule text-center"
>
{DAY_LABELS[day]}
</div>
))}
{/* 8 节 × 5 天 */}
{PERIODS.map((period) => {
const times = PERIOD_TIMES[period];
return (
<div key={`row-${period}`} className="contents">
{/* 节次标签 */}
<div className="px-3 py-3 text-center border-t border-rule bg-subtle">
<p className="text-sm font-serif text-ink">{period}</p>
{times && (
<p className="mt-1 text-tiny font-mono text-ink-muted">
{times.start}
</p>
)}
</div>
{DAYS.map((day) => {
const key = `${day}-${period}`;
const item = grouped[key];
if (!item) {
return (
<div
key={`cell-${key}`}
className="px-3 py-3 border-t border-l border-rule text-tiny text-ink-muted"
>
</div>
);
}
const isSelfStudy = item.subject === "自习";
return (
<div
key={`cell-${key}`}
className={`px-3 py-3 border-t border-l border-rule ${
isSelfStudy ? "bg-subtle" : "bg-surface"
}`}
>
<p
className={`text-sm font-serif ${
isSelfStudy ? "text-ink-muted" : "text-ink"
}`}
>
{item.subject}
</p>
<p className="mt-1 text-tiny text-ink-muted">
{item.teacherName}
</p>
<p className="mt-0.5 text-tiny font-mono text-ink-muted">
{item.room}
</p>
</div>
);
})}
</div>
);
})}
</div>
</div>
</section>
)}
</div>
);
}
export default function ClassSchedulePage(): React.ReactNode {
return (
<Suspense
fallback={
<div className="px-10 py-10">
<Loading lines={4} />
</div>
}
>
<ScheduleContent />
</Suspense>
);
}

View File

@@ -0,0 +1,190 @@
"use client";
/**
* 课程计划详情页P7-insights
*
* 数据来源GraphQL CoursePlanDetailQuery
*
* 功能:
* - 基本信息:标题/学科/年级/学年/学期/学时
* - 章节列表(每章:标题/学时/教学目标/重难点/教学活动)
* - 关联教材入口(→ /textbooks/[id]
* - 关联作业入口(→ /homework?coursePlanId=xxx
*
* 维护者ai13teacher-portal
*/
import { useQuery } from "urql";
import { useParams } from "next/navigation";
import Link from "next/link";
import { Loading, Empty } from "@edu/ui-components";
import { CoursePlanDetailQuery } from "@/lib/graphql-p7-insights";
import type { CoursePlanDetail as CoursePlanDetailType } from "@/lib/graphql-p7-insights";
const STATUS_LABEL: Record<string, string> = {
DRAFT: "草稿",
PUBLISHED: "已发布",
ARCHIVED: "已归档",
};
export default function CoursePlanDetailPage(): JSX.Element {
const params = useParams<{ id: string }>();
const id = params?.id ?? "";
const [result] = useQuery({
query: CoursePlanDetailQuery,
variables: { id },
pause: !id,
});
const plan: CoursePlanDetailType | undefined = result.data?.coursePlanDetail;
return (
<div className="px-10 py-10">
<header className="mb-8">
<Link
href="/course-plans"
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
<h1 className="mt-3 text-3xl font-serif text-ink">
{plan?.title ?? "课程计划详情"}
</h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL CoursePlanDetailQuery · P7-insights
</p>
</header>
<div className="rule-thin mb-8" />
{!id ? (
<Empty title="未指定课程计划" description="请从列表进入" />
) : result.fetching ? (
<Loading lines={6} />
) : result.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{result.error.message}
</p>
</div>
) : !plan ? (
<Empty title="课程计划不存在" description="可能已被删除" />
) : (
<>
{/* 基本信息 */}
<section className="mb-8 grid grid-cols-2 md:grid-cols-3 gap-6">
<div className="p-5 border border-rule rounded-card">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
/
</p>
<p className="mt-2 text-lg font-serif text-ink">
{plan.subject} · {plan.grade}
</p>
</div>
<div className="p-5 border border-rule rounded-card">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
/
</p>
<p className="mt-2 text-lg font-serif text-ink">
{plan.academicYear} {plan.semester}
</p>
</div>
<div className="p-5 border border-rule rounded-card">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
</p>
<p className="mt-2 text-3xl font-serif text-accent">
{plan.totalHours}
</p>
</div>
<div className="p-5 border border-rule rounded-card">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
</p>
<p className="mt-2 text-sm">
<span className="px-2 py-0.5 rounded bg-subtle text-tiny text-ink">
{STATUS_LABEL[plan.status] ?? plan.status}
</span>
</p>
</div>
<div className="p-5 border border-rule rounded-card">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
</p>
{plan.textbookId ? (
<Link
href={`/textbooks/${plan.textbookId}`}
className="mt-2 inline-block text-sm text-accent hover:opacity-70"
>
{plan.textbookTitle ?? plan.textbookId}
</Link>
) : (
<p className="mt-2 text-sm text-ink-muted"></p>
)}
</div>
<div className="p-5 border border-rule rounded-card">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
</p>
<Link
href={`/homework?coursePlanId=${plan.id}`}
className="mt-2 inline-block text-sm text-accent hover:opacity-70"
>
{plan.homeworkCount}
</Link>
</div>
</section>
{/* 章节列表 */}
<section>
<h2 className="text-xl font-serif text-ink mb-4"></h2>
<div className="rule-thin mb-6" />
{plan.chapters.length === 0 ? (
<p className="text-sm text-ink-muted"></p>
) : (
<ul className="space-y-4">
{plan.chapters.map((ch) => (
<li
key={ch.id}
className="p-5 border border-rule rounded-card bg-surface"
>
<div className="flex items-baseline justify-between mb-2">
<h3 className="text-lg font-serif text-ink">
{ch.title}
</h3>
<span className="text-tiny font-mono text-ink-muted">
{ch.hours}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-3 text-sm">
<div>
<p className="text-tiny uppercase tracking-wide text-ink-muted mb-1">
</p>
<p className="text-ink">{ch.objectives}</p>
</div>
<div>
<p className="text-tiny uppercase tracking-wide text-ink-muted mb-1">
</p>
<p className="text-ink">{ch.keyPoints}</p>
</div>
<div>
<p className="text-tiny uppercase tracking-wide text-ink-muted mb-1">
</p>
<p className="text-ink">{ch.activities}</p>
</div>
</div>
</li>
))}
</ul>
)}
</section>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,366 @@
"use client";
/**
* 课程计划列表页P7-insights
*
* 数据来源GraphQL CoursePlansQuery + CreateCoursePlanMutation
*
* 功能:
* - 状态筛选draft/published/archived
* - 列表:标题/学科/年级/学年/状态/操作
* - "新建课程计划" 弹窗(模态框)
*
* 维护者ai13teacher-portal
*/
import { useState } from "react";
import { useQuery, useMutation } from "urql";
import Link from "next/link";
import { Loading, Empty } from "@edu/ui-components";
import {
CoursePlansQuery,
CreateCoursePlanMutation,
} from "@/lib/graphql-p7-insights";
import type {
CoursePlanItem,
CoursePlanStatus,
CreateCoursePlanInput,
} from "@/lib/graphql-p7-insights";
type StatusFilter = "ALL" | CoursePlanStatus;
const STATUS_OPTIONS: { value: StatusFilter; label: string }[] = [
{ value: "ALL", label: "全部" },
{ value: "DRAFT", label: "草稿" },
{ value: "PUBLISHED", label: "已发布" },
{ value: "ARCHIVED", label: "已归档" },
];
const STATUS_LABEL: Record<CoursePlanStatus, string> = {
DRAFT: "草稿",
PUBLISHED: "已发布",
ARCHIVED: "已归档",
};
const SUBJECTS = ["语文", "数学", "英语"] as const;
const GRADES = ["小学三年级", "小学六年级", "初中一年级", "高中一年级", "高中三年级"] as const;
function formatDate(iso: string): string {
return iso.slice(0, 16).replace("T", " ");
}
export default function CoursePlansPage(): JSX.Element {
const [status, setStatus] = useState<StatusFilter>("ALL");
const [modalOpen, setModalOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result] = useQuery({
query: CoursePlansQuery,
variables: status === "ALL" ? {} : { status },
});
const [, createCoursePlan] = useMutation(CreateCoursePlanMutation);
// 新建表单 state
const [form, setForm] = useState<{
title: string;
subject: string;
grade: string;
academicYear: string;
semester: string;
totalHours: number;
textbookId: string;
}>({
title: "",
subject: SUBJECTS[0] ?? "数学",
grade: GRADES[0] ?? "小学三年级",
academicYear: "2026-2027",
semester: "上学期",
totalHours: 48,
textbookId: "",
});
const plans: CoursePlanItem[] = result.data?.coursePlans ?? [];
const handleCreate = async (): Promise<void> => {
if (!form.title) {
setError("请填写标题");
return;
}
setCreating(true);
setError(null);
const input: CreateCoursePlanInput = {
title: form.title,
subject: form.subject,
grade: form.grade,
academicYear: form.academicYear,
semester: form.semester,
totalHours: form.totalHours,
textbookId: form.textbookId || null,
};
const res = await createCoursePlan({ input });
setCreating(false);
if (res.error || !res.data?.createCoursePlan) {
setError(res.error?.message ?? "创建失败");
return;
}
setModalOpen(false);
setForm((prev) => ({ ...prev, title: "" }));
};
return (
<div className="px-10 py-10">
<header className="mb-8">
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL CoursePlansQuery · /P7-insightsMSW mock
</p>
</header>
<div className="rule-thin mb-8" />
{/* 工具栏 */}
<div className="mb-6 flex items-baseline justify-between flex-wrap gap-4">
<div className="flex items-center gap-3">
<label className="text-tiny uppercase tracking-wide text-ink-muted">
</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value as StatusFilter)}
className="px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
>
{STATUS_OPTIONS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</div>
<button
type="button"
onClick={() => setModalOpen(true)}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
>
</button>
</div>
{result.fetching ? (
<Loading lines={5} />
) : result.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{result.error.message}
</p>
</div>
) : plans.length === 0 ? (
<Empty
title="暂无课程计划"
description="点击右上角「新建课程计划」开始创建"
/>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-tiny uppercase tracking-wide text-ink-muted border-b border-rule">
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal text-right"></th>
</tr>
</thead>
<tbody>
{plans.map((p) => (
<tr key={p.id} className="border-b border-rule">
<td className="py-3 pr-4">
<Link
href={`/course-plans/${p.id}`}
className="font-serif text-ink hover:text-accent"
>
{p.title}
</Link>
</td>
<td className="py-3 pr-4 text-ink">{p.subject}</td>
<td className="py-3 pr-4 text-ink">{p.grade}</td>
<td className="py-3 pr-4 text-ink-muted font-mono text-tiny">
{p.academicYear}
</td>
<td className="py-3 pr-4 text-ink">{p.semester}</td>
<td className="py-3 pr-4 text-ink-muted font-mono text-tiny">
{p.totalHours}
</td>
<td className="py-3 pr-4">
<span className="px-2 py-0.5 rounded bg-subtle text-tiny text-ink">
{STATUS_LABEL[p.status]}
</span>
</td>
<td className="py-3 pr-4 text-ink-muted font-mono text-tiny">
{formatDate(p.updatedAt)}
</td>
<td className="py-3 pr-4 text-right">
<Link
href={`/course-plans/${p.id}`}
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
>
</Link>
</td>
</tr>
))}
</tbody>
</table>
)}
{/* 新建弹窗 */}
{modalOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: "hsl(var(--ink) / 0.4)" }}
role="dialog"
aria-modal="true"
aria-label="新建课程计划"
>
<div className="w-full max-w-lg p-6 border border-rule rounded-card bg-paper">
<h2 className="text-xl font-serif text-ink mb-4"></h2>
<div className="grid grid-cols-2 gap-4">
<label className="col-span-2 flex flex-col gap-1">
<span className="text-tiny uppercase tracking-wide text-ink-muted">
</span>
<input
type="text"
value={form.title}
onChange={(e) =>
setForm((prev) => ({ ...prev, title: e.target.value }))
}
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink focus:outline-none focus:border-accent"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-tiny uppercase tracking-wide text-ink-muted">
</span>
<select
value={form.subject}
onChange={(e) =>
setForm((prev) => ({ ...prev, subject: e.target.value }))
}
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink"
>
{SUBJECTS.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-tiny uppercase tracking-wide text-ink-muted">
</span>
<select
value={form.grade}
onChange={(e) =>
setForm((prev) => ({ ...prev, grade: e.target.value }))
}
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink"
>
{GRADES.map((g) => (
<option key={g} value={g}>
{g}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-tiny uppercase tracking-wide text-ink-muted">
</span>
<input
type="text"
value={form.academicYear}
onChange={(e) =>
setForm((prev) => ({ ...prev, academicYear: e.target.value }))
}
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink font-mono"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-tiny uppercase tracking-wide text-ink-muted">
</span>
<select
value={form.semester}
onChange={(e) =>
setForm((prev) => ({ ...prev, semester: e.target.value }))
}
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink"
>
<option value="上学期"></option>
<option value="下学期"></option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-tiny uppercase tracking-wide text-ink-muted">
</span>
<input
type="number"
value={form.totalHours}
onChange={(e) =>
setForm((prev) => ({
...prev,
totalHours: Number(e.target.value) || 0,
}))
}
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink font-mono"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-tiny uppercase tracking-wide text-ink-muted">
ID
</span>
<input
type="text"
value={form.textbookId}
onChange={(e) =>
setForm((prev) => ({ ...prev, textbookId: e.target.value }))
}
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink font-mono"
/>
</label>
</div>
{error && (
<p className="mt-3 text-sm text-danger">{error}</p>
)}
<div className="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={() => setModalOpen(false)}
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle"
>
</button>
<button
type="button"
onClick={handleCreate}
disabled={creating}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
>
{creating ? "创建中…" : "创建"}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,235 @@
"use client";
/**
* 班级诊断视图P7-insights
*
* 数据来源GraphQL ClassDiagnosticQuery
*
* 功能:
* - 顶部:班级基本信息 + 掌握度摘要5 知识点 avg
* - 知识点掌握度列表10 知识点,每点 avg/中位数/学生数/掌握分布)
* - 学生掌握度排名表
* - 监控埋点mock console.log
*
* 维护者ai13teacher-portal
*/
import { useEffect } from "react";
import { useQuery } from "urql";
import { useParams } from "next/navigation";
import Link from "next/link";
import { Loading, Empty } from "@edu/ui-components";
import { ClassDiagnosticQuery } from "@/lib/graphql-p7-insights";
import type {
ClassDiagnostic,
DiagnosticKpMastery,
} from "@/lib/graphql-p7-insights";
function masteryColor(rate: number): string {
if (rate >= 70) return "var(--color-success)";
if (rate >= 40) return "var(--color-warning)";
return "var(--color-danger)";
}
export default function ClassDiagnosticPage(): JSX.Element {
const params = useParams<{ classId: string }>();
const classId = params?.classId ?? "";
const [result] = useQuery({
query: ClassDiagnosticQuery,
variables: { classId },
pause: !classId,
});
const diag: ClassDiagnostic | undefined = result.data?.classDiagnostic;
// 监控埋点mock console.log
useEffect(() => {
if (diag) {
console.log("[diagnostic-monitor] class-view-loaded", {
classId,
className: diag.className,
studentCount: diag.studentCount,
kpCount: diag.kpMastery.length,
timestamp: new Date().toISOString(),
});
}
}, [diag, classId]);
return (
<div className="px-10 py-10">
<header className="mb-8">
<Link
href="/diagnostic"
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
<h1 className="mt-3 text-3xl font-serif text-ink">
{diag?.className ?? "班级诊断"}
</h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL ClassDiagnosticQuery · P7-insights
</p>
</header>
<div className="rule-thin mb-8" />
{!classId ? (
<Empty title="未指定班级" description="请从诊断报告列表进入" />
) : result.fetching ? (
<Loading lines={6} />
) : result.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{result.error.message}
</p>
</div>
) : !diag ? (
<Empty title="暂无诊断数据" description="该班级尚未生成诊断报告" />
) : (
<>
{/* 基本信息 + 摘要 */}
<section className="mb-8">
<div className="mb-4 flex items-baseline gap-6">
<p className="text-sm text-ink-muted">
<span className="text-ink font-mono">{diag.studentCount}</span>
</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{diag.summary.map((s) => (
<div
key={s.knowledgePoint}
className="p-4 border border-rule rounded-card"
>
<p className="text-tiny uppercase tracking-wide text-ink-muted">
{s.knowledgePoint}
</p>
<p
className="mt-2 text-2xl font-serif"
style={{ color: masteryColor(s.avg) }}
>
{s.avg}
</p>
</div>
))}
</div>
</section>
{/* 知识点掌握度列表 */}
<section className="mb-10">
<h2 className="text-xl font-serif text-ink mb-4"></h2>
<div className="rule-thin mb-6" />
<table className="w-full text-sm">
<thead>
<tr className="text-left text-tiny uppercase tracking-wide text-ink-muted border-b border-rule">
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal"></th>
</tr>
</thead>
<tbody>
{diag.kpMastery.map((kp: DiagnosticKpMastery) => (
<tr key={kp.knowledgePoint} className="border-b border-rule">
<td className="py-3 pr-4 text-ink">{kp.knowledgePoint}</td>
<td className="py-3 pr-4">
<div className="flex items-center gap-2">
<span
className="font-mono"
style={{ color: masteryColor(kp.avg) }}
>
{kp.avg}
</span>
<div className="w-16 h-1.5 rounded-button bg-subtle overflow-hidden">
<div
className="h-full rounded-button"
style={{
width: `${kp.avg}%`,
background: masteryColor(kp.avg),
}}
/>
</div>
</div>
</td>
<td className="py-3 pr-4 text-ink-muted font-mono">
{kp.median}
</td>
<td className="py-3 pr-4 text-ink-muted font-mono">
{kp.studentCount}
</td>
<td className="py-3 pr-4">
<div className="flex items-center gap-2 text-tiny">
<span className="text-success">
{kp.distribution.mastered}
</span>
<span className="text-warning">
{kp.distribution.partial}
</span>
<span className="text-danger">
{kp.distribution.weak}
</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</section>
{/* 学生掌握度排名 */}
<section>
<h2 className="text-xl font-serif text-ink mb-4"></h2>
<div className="rule-thin mb-6" />
<table className="w-full text-sm">
<thead>
<tr className="text-left text-tiny uppercase tracking-wide text-ink-muted border-b border-rule">
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal"></th>
</tr>
</thead>
<tbody>
{diag.rankings.map((r) => (
<tr key={r.studentId} className="border-b border-rule">
<td className="py-3 pr-4 font-serif text-ink-muted">
{r.rank}
</td>
<td className="py-3 pr-4">
<Link
href={`/analytics/${r.studentId}`}
className="text-ink hover:text-accent"
>
{r.studentName}
</Link>
</td>
<td className="py-3 pr-4">
<div className="flex items-center gap-2">
<span
className="font-mono"
style={{ color: masteryColor(r.masteryAvg) }}
>
{r.masteryAvg}
</span>
<div className="w-24 h-1.5 rounded-button bg-subtle overflow-hidden">
<div
className="h-full rounded-button"
style={{
width: `${r.masteryAvg}%`,
background: masteryColor(r.masteryAvg),
}}
/>
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</section>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,195 @@
"use client";
/**
* 诊断报告列表页P7-insights
*
* 数据来源GraphQL DiagnosticReportsQuery
*
* 功能:
* - 类型筛选individual/class/grade
* - 状态筛选draft/published/archived
* - 列表:报告标题/类型/对象/状态/创建时间/操作
*
* 维护者ai13teacher-portal
*/
import { useState } from "react";
import { useQuery } from "urql";
import Link from "next/link";
import { Loading, Empty } from "@edu/ui-components";
import { DiagnosticReportsQuery } from "@/lib/graphql-p7-insights";
import type {
DiagnosticReportType,
DiagnosticReportStatus,
DiagnosticReportItem,
} from "@/lib/graphql-p7-insights";
type TypeFilter = "ALL" | DiagnosticReportType;
type StatusFilter = "ALL" | DiagnosticReportStatus;
const TYPE_OPTIONS: { value: TypeFilter; label: string }[] = [
{ value: "ALL", label: "全部类型" },
{ value: "INDIVIDUAL", label: "个人" },
{ value: "CLASS", label: "班级" },
{ value: "GRADE", label: "年级" },
];
const STATUS_OPTIONS: { value: StatusFilter; label: string }[] = [
{ value: "ALL", label: "全部状态" },
{ value: "DRAFT", label: "草稿" },
{ value: "PUBLISHED", label: "已发布" },
{ value: "ARCHIVED", label: "已归档" },
];
const TYPE_LABEL: Record<DiagnosticReportType, string> = {
INDIVIDUAL: "个人",
CLASS: "班级",
GRADE: "年级",
};
const STATUS_LABEL: Record<DiagnosticReportStatus, string> = {
DRAFT: "草稿",
PUBLISHED: "已发布",
ARCHIVED: "已归档",
};
function formatDate(iso: string): string {
return iso.slice(0, 16).replace("T", " ");
}
export default function DiagnosticPage(): JSX.Element {
const [type, setType] = useState<TypeFilter>("ALL");
const [status, setStatus] = useState<StatusFilter>("ALL");
const [result] = useQuery({
query: DiagnosticReportsQuery,
variables:
type === "ALL" && status === "ALL"
? {}
: {
type: type === "ALL" ? null : type,
status: status === "ALL" ? null : status,
},
});
const reports: DiagnosticReportItem[] = result.data?.diagnosticReports ?? [];
/** 根据报告类型决定查看链接 */
function getViewHref(item: DiagnosticReportItem): string {
if (item.type === "CLASS") {
// 班级报告 → /diagnostic/class/[classId](这里用 id 占位,正式版应解析 targetType
// 简化:所有班级报告都跳到第一个班级诊断页
return `/diagnostic/class/550e8400-e29b-41d4-a716-446655440010`;
}
if (item.type === "INDIVIDUAL") {
return `/analytics/stu-001`;
}
return `/analytics`;
}
return (
<div className="px-10 py-10">
<header className="mb-8">
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL DiagnosticReportsQuery · P7-insights
</p>
</header>
<div className="rule-thin mb-8" />
{/* 筛选 */}
<div className="mb-6 flex items-baseline gap-6 flex-wrap">
<div className="flex items-center gap-3">
<label className="text-tiny uppercase tracking-wide text-ink-muted">
</label>
<select
value={type}
onChange={(e) => setType(e.target.value as TypeFilter)}
className="px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
>
{TYPE_OPTIONS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</div>
<div className="flex items-center gap-3">
<label className="text-tiny uppercase tracking-wide text-ink-muted">
</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value as StatusFilter)}
className="px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
>
{STATUS_OPTIONS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</div>
</div>
{result.fetching ? (
<Loading lines={5} />
) : result.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{result.error.message}
</p>
</div>
) : reports.length === 0 ? (
<Empty title="暂无诊断报告" description="当前筛选条件下没有报告" />
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-tiny uppercase tracking-wide text-ink-muted border-b border-rule">
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal text-right"></th>
</tr>
</thead>
<tbody>
{reports.map((r) => (
<tr key={r.id} className="border-b border-rule">
<td className="py-3 pr-4">
<Link
href={getViewHref(r)}
className="font-serif text-ink hover:text-accent"
>
{r.title}
</Link>
</td>
<td className="py-3 pr-4 text-ink">{TYPE_LABEL[r.type]}</td>
<td className="py-3 pr-4 text-ink-muted">{r.targetType}</td>
<td className="py-3 pr-4">
<span className="px-2 py-0.5 rounded bg-subtle text-tiny text-ink">
{STATUS_LABEL[r.status]}
</span>
</td>
<td className="py-3 pr-4 text-ink-muted font-mono text-tiny">
{formatDate(r.createdAt)}
</td>
<td className="py-3 pr-4 text-right">
<Link
href={getViewHref(r)}
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
>
</Link>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}

View File

@@ -0,0 +1,397 @@
"use client";
/**
* 编辑选修课页
*
* 数据来源P7 扩展MSW mock
* - GraphQL ElectiveCourseDetailQuery按 id 拉取详情(含学生列表)
* - GraphQL UpdateElectiveCourseMutation / PublishElectiveCourseMutation / CancelElectiveCourseMutation
*
* 布局:
* - 顶部:返回链接 + 标题
* - 表单:课程名/学科/年级/容量/学期/描述
* - 学生选课列表
* - 越权校验:判断 teacherId 是否为当前用户
* - "保存"/"发布"/"取消课程" 按钮
*
* 维护者ai13teacher-portal
*/
import { useState, useEffect } from "react";
import { useQuery, useMutation } from "urql";
import { useParams } from "next/navigation";
import Link from "next/link";
import { Loading, Empty } from "@edu/ui-components";
import {
ElectiveCourseDetailQuery,
UpdateElectiveCourseMutation,
PublishElectiveCourseMutation,
CancelElectiveCourseMutation,
} from "@/lib/graphql-p7-advanced";
import type { ElectiveStudent } from "@/lib/graphql-p7-advanced";
import { CURRENT_TEACHER_ID } from "@/mocks/fixtures/elective";
/** 学科选项 */
const SUBJECT_OPTIONS = [
"数学", "语文", "英语", "物理", "化学", "生物",
"历史", "地理", "信息技术", "美术",
];
/** 年级选项 */
const GRADE_OPTIONS = ["高一", "高二", "高三"];
/** 学期选项 */
const SEMESTER_OPTIONS = ["2026春季", "2025秋季", "2026秋季"];
/** 状态中文映射 */
const STATUS_LABEL: Record<string, string> = {
draft: "草稿",
open: "开放选课",
closed: "已截止",
cancelled: "已取消",
};
export default function EditElectivePage(): React.ReactNode {
const params = useParams<{ id: string }>();
const courseId = params?.id ?? "";
const [result, reexecuteQuery] = useQuery({
query: ElectiveCourseDetailQuery,
variables: { id: courseId },
pause: !courseId,
});
const [, updateCourse] = useMutation(UpdateElectiveCourseMutation);
const [, publishCourse] = useMutation(PublishElectiveCourseMutation);
const [, cancelCourse] = useMutation(CancelElectiveCourseMutation);
const [title, setTitle] = useState("");
const [subject, setSubject] = useState("数学");
const [grade, setGrade] = useState("高一");
const [capacity, setCapacity] = useState("40");
const [semester, setSemester] = useState("2026春季");
const [description, setDescription] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const course = result.data?.electiveCourseDetail;
// 越权校验:教师只能编辑自己教授的课程
const isOwner = course?.teacherId === CURRENT_TEACHER_ID;
// 初始化表单
useEffect(() => {
if (!course) return;
setTitle(course.title);
setSubject(course.subject);
setGrade(course.grade);
setCapacity(String(course.capacity));
setSemester(course.semester);
setDescription(course.description ?? "");
}, [course]);
/** 保存更新 */
const handleSave = async () => {
if (!course) return;
setError(null);
setMsg(null);
setSubmitting(true);
const res = await updateCourse({
input: {
id: course.id,
title: title.trim(),
subject,
grade,
capacity: Number(capacity) || 0,
semester,
description: description.trim() || null,
},
});
setSubmitting(false);
if (res.error) {
setError(res.error.message);
return;
}
setMsg("已保存");
reexecuteQuery({ requestPolicy: "network-only" });
};
/** 发布选修课 */
const handlePublish = async () => {
if (!course) return;
setError(null);
setMsg(null);
setSubmitting(true);
const res = await publishCourse({ id: course.id });
setSubmitting(false);
if (res.error) {
setError(res.error.message);
return;
}
setMsg("已发布");
reexecuteQuery({ requestPolicy: "network-only" });
};
/** 取消选修课 */
const handleCancel = async () => {
if (!course) return;
setError(null);
setMsg(null);
if (!window.confirm("确认取消此选修课?取消后无法恢复。")) return;
setSubmitting(true);
const res = await cancelCourse({ id: course.id });
setSubmitting(false);
if (res.error) {
setError(res.error.message);
return;
}
setMsg("已取消");
reexecuteQuery({ requestPolicy: "network-only" });
};
return (
<div className="px-10 py-10">
<header className="mb-8">
<p className="mb-2">
<Link
href="/elective"
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
</p>
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL ElectiveCourseDetailQuery + UpdateElectiveCourseMutation · core-edu P7 MSW mock
</p>
</header>
<div className="rule-thin mb-8" />
{result.fetching ? (
<Loading lines={6} />
) : result.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{result.error.message}
</p>
</div>
) : !course ? (
<Empty title="未找到选修课" description="该选修课不存在或已被删除" />
) : !isOwner ? (
<Empty
title="无权编辑"
description="只能编辑本人教授的选修课"
/>
) : (
<section className="max-w-4xl space-y-10">
{/* 表单 */}
<div className="max-w-2xl space-y-6">
<div className="flex items-baseline justify-between mb-2">
<span className="text-tiny uppercase tracking-wide text-ink-muted">
<span className="ml-2 text-ink">
{STATUS_LABEL[course.status] ?? course.status}
</span>
</span>
</div>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
<span className="text-danger">*</span>
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm text-ink focus:outline-none focus:border-b-2"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<select
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
>
{SUBJECT_OPTIONS.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<select
value={grade}
onChange={(e) => setGrade(e.target.value)}
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
>
{GRADE_OPTIONS.map((g) => (
<option key={g} value={g}>
{g}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<input
type="number"
min={1}
value={capacity}
onChange={(e) => setCapacity(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm text-ink focus:outline-none focus:border-b-2"
/>
</div>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<select
value={semester}
onChange={(e) => setSemester(e.target.value)}
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
>
{SEMESTER_OPTIONS.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
/>
</div>
{error && (
<div className="mark-left py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">{error}</p>
</div>
)}
{msg && (
<p className="text-tiny text-success">{msg}</p>
)}
<div className="flex items-center gap-4 pt-4">
<button
type="button"
onClick={handleSave}
disabled={submitting}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
>
{submitting ? "提交中..." : "保存"}
</button>
{course.status !== "open" && course.status !== "cancelled" && (
<button
type="button"
onClick={handlePublish}
disabled={submitting}
className="px-4 py-2 text-sm text-ink bg-transparent border border-accent text-accent rounded-button hover:bg-subtle disabled:opacity-50"
>
</button>
)}
{course.status !== "cancelled" && (
<button
type="button"
onClick={handleCancel}
disabled={submitting}
className="px-4 py-2 text-sm text-danger bg-transparent border border-danger rounded-button hover:bg-subtle disabled:opacity-50"
>
</button>
)}
</div>
</div>
{/* 学生选课列表 */}
<div>
<h3 className="text-xl font-serif text-ink mb-2">
<span className="ml-2 text-sm font-sans text-ink-muted">
{course.students.length}
</span>
</h3>
<div className="rule-thin mb-4" />
{course.students.length === 0 ? (
<Empty title="暂无选课学生" description="尚未开放选课或无人选课" />
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-rule text-left">
<th className="py-3 px-2 text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="py-3 px-2 text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="py-3 px-2 text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="py-3 px-2 text-tiny uppercase tracking-wide text-ink-muted">
</th>
</tr>
</thead>
<tbody>
{course.students.map((stu: ElectiveStudent) => (
<tr
key={stu.studentId}
className="border-b border-rule hover:bg-subtle"
>
<td className="py-3 px-2 font-mono text-tiny text-ink">
{stu.studentNo}
</td>
<td className="py-3 px-2 text-ink">{stu.studentName}</td>
<td className="py-3 px-2 text-ink-muted text-tiny">
{new Date(stu.enrolledAt).toLocaleString("zh-CN")}
</td>
<td className="py-3 px-2">
<span
className="text-tiny"
style={{
color:
stu.status === "enrolled"
? "var(--color-success)"
: "var(--color-danger)",
}}
>
{stu.status === "enrolled" ? "已选" : "已退选"}
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</section>
)}
</div>
);
}

View File

@@ -0,0 +1,240 @@
"use client";
/**
* 创建选修课表单页
*
* 数据来源P7 扩展MSW mock
* - GraphQL CreateElectiveCourseMutation创建选修课草稿/发布)
*
* 布局:
* - 表单字段:课程名/学科 select/年级 select/容量 input/学期 select/描述 textarea
* - "保存草稿"/"发布" 按钮
*
* 维护者ai13teacher-portal
*/
import { useState } from "react";
import { useMutation } from "urql";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { CreateElectiveCourseMutation } from "@/lib/graphql-p7-advanced";
/** 学科选项 */
const SUBJECT_OPTIONS = [
"数学", "语文", "英语", "物理", "化学", "生物",
"历史", "地理", "信息技术", "美术",
];
/** 年级选项 */
const GRADE_OPTIONS = ["高一", "高二", "高三"];
/** 学期选项 */
const SEMESTER_OPTIONS = ["2026春季", "2025秋季", "2026秋季"];
export default function CreateElectivePage(): React.ReactNode {
const router = useRouter();
const [createResult, createCourse] = useMutation(CreateElectiveCourseMutation);
const [title, setTitle] = useState("");
const [subject, setSubject] = useState(SUBJECT_OPTIONS[0] ?? "数学");
const [grade, setGrade] = useState(GRADE_OPTIONS[0] ?? "高一");
const [capacity, setCapacity] = useState("40");
const [semester, setSemester] = useState(SEMESTER_OPTIONS[0] ?? "2026春季");
const [description, setDescription] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
/** 提交创建publish=true 发布false 保存草稿) */
const handleSubmit = async (publish: boolean) => {
setError(null);
if (!title.trim()) {
setError("请填写课程名");
return;
}
setSubmitting(true);
const res = await createCourse({
input: {
title: title.trim(),
subject,
grade,
capacity: Number(capacity) || 0,
semester,
description: description.trim() || null,
},
});
setSubmitting(false);
if (res.error) {
setError(res.error.message);
return;
}
// 成功后跳回列表publish 标记仅用于 UI 提示mock 不区分)
if (publish) {
// 发布mock 中通过 PublishElectiveCourseMutation 调用,此处简化为创建后跳转
}
router.push("/elective");
};
return (
<div className="px-10 py-10">
<header className="mb-8">
<p className="mb-2">
<Link
href="/elective"
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
</p>
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL CreateElectiveCourseMutation · core-edu P7 MSW mock
</p>
</header>
<div className="rule-thin mb-8" />
<section className="max-w-2xl">
<form className="space-y-6">
{/* 课程名 */}
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
<span className="text-danger">*</span>
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm text-ink focus:outline-none focus:border-b-2"
placeholder="例如:趣味数学建模"
required
/>
</div>
{/* 学科 + 年级 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
<span className="text-danger">*</span>
</label>
<select
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
>
{SUBJECT_OPTIONS.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
<span className="text-danger">*</span>
</label>
<select
value={grade}
onChange={(e) => setGrade(e.target.value)}
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
>
{GRADE_OPTIONS.map((g) => (
<option key={g} value={g}>
{g}
</option>
))}
</select>
</div>
</div>
{/* 容量 + 学期 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
<span className="text-danger">*</span>
</label>
<input
type="number"
min={1}
value={capacity}
onChange={(e) => setCapacity(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm text-ink focus:outline-none focus:border-b-2"
required
/>
</div>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
<span className="text-danger">*</span>
</label>
<select
value={semester}
onChange={(e) => setSemester(e.target.value)}
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
>
{SEMESTER_OPTIONS.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
</div>
{/* 描述 */}
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
placeholder="课程简介、选课要求等"
/>
</div>
{/* 错误提示 */}
{error && (
<div className="mark-left py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">{error}</p>
</div>
)}
{/* 操作按钮 */}
<div className="flex items-center gap-4 pt-4">
<button
type="button"
onClick={() => handleSubmit(false)}
disabled={submitting}
className="px-4 py-2 text-sm text-ink bg-transparent border border-rule rounded-button hover:bg-subtle disabled:opacity-50"
>
{submitting ? "提交中..." : "保存草稿"}
</button>
<button
type="button"
onClick={() => handleSubmit(true)}
disabled={submitting}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
>
{submitting ? "提交中..." : "发布"}
</button>
<Link
href="/elective"
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
{createResult.data && (
<span className="text-tiny text-success"></span>
)}
</div>
</form>
</section>
</div>
);
}

View File

@@ -0,0 +1,244 @@
"use client";
/**
* 选修课列表页
*
* 数据来源P7 扩展MSW mock
* - GraphQL ElectiveCoursesQuery按状态 + 学科筛选选修课列表
*
* 布局:
* - 顶部:标题 + "创建选修课" 按钮
* - 筛选:状态 select + 学科 select
* - 列表:课程名/学科/年级/容量/已选数/状态/操作
*
* 维护者ai13teacher-portal
*/
import { useState } from "react";
import { useQuery } from "urql";
import Link from "next/link";
import { Loading, Empty } from "@edu/ui-components";
import { ElectiveCoursesQuery } from "@/lib/graphql-p7-advanced";
import type { ElectiveCourseItem } from "@/lib/graphql-p7-advanced";
import { CURRENT_TEACHER_ID } from "@/mocks/fixtures/elective";
/** 状态中文映射 */
const STATUS_LABEL: Record<string, string> = {
draft: "草稿",
open: "开放选课",
closed: "已截止",
cancelled: "已取消",
};
/** 状态对应的样式令牌(语义色) */
function statusColor(status: string): string {
switch (status) {
case "open":
return "var(--color-success)";
case "closed":
return "var(--color-warning)";
case "cancelled":
return "var(--color-danger)";
default:
return "var(--color-ink-muted)";
}
}
/** 状态筛选选项 */
const STATUS_OPTIONS: Array<{ value: string; label: string }> = [
{ value: "", label: "全部状态" },
{ value: "draft", label: "草稿" },
{ value: "open", label: "开放选课" },
{ value: "closed", label: "已截止" },
{ value: "cancelled", label: "已取消" },
];
/** 学科筛选选项 */
const SUBJECT_OPTIONS: Array<{ value: string; label: string }> = [
{ value: "", label: "全部学科" },
{ value: "数学", label: "数学" },
{ value: "语文", label: "语文" },
{ value: "英语", label: "英语" },
{ value: "物理", label: "物理" },
{ value: "化学", label: "化学" },
{ value: "生物", label: "生物" },
{ value: "历史", label: "历史" },
{ value: "地理", label: "地理" },
{ value: "信息技术", label: "信息技术" },
{ value: "美术", label: "美术" },
];
export default function ElectiveListPage(): React.ReactNode {
const [status, setStatus] = useState("");
const [subject, setSubject] = useState("");
const [result] = useQuery({
query: ElectiveCoursesQuery,
variables: {
status: status || null,
subject: subject || null,
},
});
const courses: ElectiveCourseItem[] = result.data?.electiveCourses ?? [];
return (
<div className="px-10 py-10">
<header className="mb-8">
<p className="mb-2">
<Link
href="/dashboard"
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
</p>
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL ElectiveCoursesQuery · core-edu P7 MSW mock
</p>
</header>
<div className="rule-thin mb-8" />
{/* 操作栏:筛选 + 创建按钮 */}
<section className="mb-6 flex items-end justify-between gap-4">
<div className="flex items-end gap-4">
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
className="px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<select
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
>
{SUBJECT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
<Link
href="/elective/create"
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
>
</Link>
</section>
{result.fetching ? (
<Loading lines={6} />
) : result.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{result.error.message}
</p>
</div>
) : courses.length === 0 ? (
<Empty title="暂无选修课" description="当前筛选条件下没有选修课" />
) : (
<section>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-rule text-left">
<th className="py-3 px-2 text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="py-3 px-2 text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="py-3 px-2 text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="py-3 px-2 text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="py-3 px-2 text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="py-3 px-2 text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="py-3 px-2 text-tiny uppercase tracking-wide text-ink-muted text-right">
</th>
</tr>
</thead>
<tbody>
{courses.map((course) => {
const canEdit = course.teacherId === CURRENT_TEACHER_ID;
return (
<tr
key={course.id}
className="border-b border-rule hover:bg-subtle"
>
<td className="py-3 px-2">
<span className="font-serif text-ink">
{course.title}
</span>
{course.description && (
<p className="mt-1 text-tiny text-ink-muted">
{course.description}
</p>
)}
</td>
<td className="py-3 px-2 text-ink">{course.subject}</td>
<td className="py-3 px-2 text-ink">{course.grade}</td>
<td className="py-3 px-2 text-ink">{course.capacity}</td>
<td className="py-3 px-2 text-ink">
{course.enrolledCount}
<span className="text-tiny text-ink-muted">
/{course.capacity}
</span>
</td>
<td className="py-3 px-2">
<span
className="text-tiny"
style={{ color: statusColor(course.status) }}
>
{STATUS_LABEL[course.status] ?? course.status}
</span>
</td>
<td className="py-3 px-2 text-right">
{canEdit ? (
<Link
href={`/elective/${course.id}/edit`}
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
>
</Link>
) : (
<span className="text-tiny text-ink-muted">
</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</section>
)}
</div>
);
}

View File

@@ -0,0 +1,519 @@
"use client";
/**
* 错题分析页P7-insights
*
* 数据来源GraphQL ErrorBookQuery
*
* 7 个 Widget
* 1. 学科 Tab + 班级筛选
* 2. 5 项统计卡片(错题总数/高频错题/薄弱知识点/平均错误率/上升趋势)
* 3. 班级对比柱图5 班级 × 错题数)- 纯 SVG
* 4. 章节薄弱度横向柱图10 章节 × 错题数)- 纯 SVG
* 5. 知识点薄弱度雷达图10 知识点)- 纯 SVG
* 6. 学生错题分组表(前 5 学生错题数)
* 7. Top10 高频错题列表(题干前 100 字/错误率/学科)
*
* 维护者ai13teacher-portal
*/
import { useState } from "react";
import { useQuery } from "urql";
import { Loading, Empty } from "@edu/ui-components";
import { ErrorBookQuery } from "@/lib/graphql-p7-insights";
import type {
ErrorBook as ErrorBookType,
ErrorBookClassComparison,
ErrorBookChapterWeakness,
ErrorBookKpWeakness,
} from "@/lib/graphql-p7-insights";
const SUBJECTS = ["语文", "数学", "英语", "物理", "化学"] as const;
const CLASSES = [
{ id: "", name: "全部班级" },
{ id: "550e8400-e29b-41d4-a716-446655440010", name: "高三(1)班" },
{ id: "550e8400-e29b-41d4-a716-446655440011", name: "高三(2)班" },
{ id: "550e8400-e29b-41d4-a716-446655440012", name: "高三(3)班" },
{ id: "550e8400-e29b-41d4-a716-446655440013", name: "高三(4)班" },
{ id: "550e8400-e29b-41d4-a716-446655440014", name: "高三(5)班" },
];
/** 班级对比柱图(垂直柱) */
function ClassComparisonChart({
data,
}: {
data: ErrorBookClassComparison[];
}): React.ReactNode {
const W = 540;
const H = 200;
const PAD = { top: 20, right: 12, bottom: 40, left: 36 };
const innerW = W - PAD.left - PAD.right;
const innerH = H - PAD.top - PAD.bottom;
const max = Math.max(...data.map((d) => d.errorCount), 1);
const barW = innerW / data.length - 8;
return (
<svg
viewBox={`0 0 ${W} ${H}`}
className="w-full max-w-2xl border border-rule rounded-card bg-surface"
role="img"
aria-label="班级错题对比柱图"
>
{/* Y 轴网格线 */}
{[0, 0.5, 1].map((r, i) => {
const y = PAD.top + innerH * r;
const v = Math.round(max * (1 - r));
return (
<g key={`grid-${i}`}>
<line
x1={PAD.left}
y1={y}
x2={W - PAD.right}
y2={y}
stroke="var(--color-rule)"
strokeWidth={1}
strokeDasharray="3 3"
/>
<text
x={PAD.left - 8}
y={y + 4}
textAnchor="end"
fontSize="10"
fontFamily="var(--font-family-mono)"
fill="var(--color-ink-muted)"
>
{v}
</text>
</g>
);
})}
{/* 柱子 */}
{data.map((d, i) => {
const x = PAD.left + i * (barW + 8) + 4;
const h = (d.errorCount / max) * innerH;
const y = PAD.top + innerH - h;
return (
<g key={d.classId}>
<rect
x={x}
y={y}
width={barW}
height={h}
fill="var(--color-accent)"
fillOpacity={0.85}
/>
<text
x={x + barW / 2}
y={y - 4}
textAnchor="middle"
fontSize="11"
fontFamily="var(--font-family-mono)"
fontWeight="600"
fill="var(--color-ink)"
>
{d.errorCount}
</text>
<text
x={x + barW / 2}
y={H - PAD.bottom + 14}
textAnchor="middle"
fontSize="10"
fontFamily="var(--font-family-sans)"
fill="var(--color-ink-muted)"
>
{d.className}
</text>
</g>
);
})}
</svg>
);
}
/** 章节薄弱度横向柱图 */
function ChapterWeaknessChart({
data,
}: {
data: ErrorBookChapterWeakness[];
}): React.ReactNode {
const W = 540;
const rowH = 24;
const labelW = 120;
const barAreaW = W - labelW - 80;
const H = data.length * rowH + 20;
const max = Math.max(...data.map((d) => d.errorCount), 1);
return (
<svg
viewBox={`0 0 ${W} ${H}`}
className="w-full max-w-2xl border border-rule rounded-card bg-surface"
role="img"
aria-label="章节薄弱度横向柱图"
>
{data.map((d, i) => {
const y = i * rowH + 10;
const w = (d.errorCount / max) * barAreaW;
return (
<g key={d.chapter}>
<text
x={0}
y={y + 14}
fontSize="11"
fontFamily="var(--font-family-sans)"
fill="var(--color-ink)"
>
{d.chapter.length > 8 ? d.chapter.slice(0, 8) + "…" : d.chapter}
</text>
<rect
x={labelW}
y={y}
width={w}
height={16}
fill="var(--color-warning)"
fillOpacity={0.85}
/>
<text
x={labelW + w + 6}
y={y + 13}
fontSize="11"
fontFamily="var(--font-family-mono)"
fontWeight="600"
fill="var(--color-ink)"
>
{d.errorCount}
</text>
</g>
);
})}
</svg>
);
}
/** 知识点薄弱度雷达图10 轴) */
function KpWeaknessRadar({
data,
}: {
data: ErrorBookKpWeakness[];
}): React.ReactNode {
const W = 360;
const H = 320;
const cx = W / 2;
const cy = H / 2;
const r = 110;
const total = data.length;
function angleAt(i: number): number {
return (2 * Math.PI * i) / total - Math.PI / 2;
}
function point(i: number, value: number): { x: number; y: number } {
const a = angleAt(i);
const radius = (value / 100) * r;
return { x: cx + radius * Math.cos(a), y: cy + radius * Math.sin(a) };
}
// 雷达形状路径
const path = data
.map((d, i) => {
const p = point(i, d.errorRate * 100);
return `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`;
})
.join(" ") + " Z";
return (
<svg
viewBox={`0 0 ${W} ${H}`}
className="w-full max-w-md border border-rule rounded-card bg-surface"
role="img"
aria-label="知识点薄弱度雷达图"
>
{/* 同心多边形 */}
{[0.25, 0.5, 0.75, 1].map((ratio, ri) => {
const pts = data
.map((_, i) => {
const a = angleAt(i);
const radius = r * ratio;
return `${cx + radius * Math.cos(a)},${cy + radius * Math.sin(a)}`;
})
.join(" ");
return (
<polygon
key={`ring-${ri}`}
points={pts}
fill="none"
stroke="var(--color-rule)"
strokeWidth={1}
/>
);
})}
{/* 轴线 */}
{data.map((d, i) => {
const a = angleAt(i);
const x = cx + r * Math.cos(a);
const y = cy + r * Math.sin(a);
return (
<line
key={`axis-${i}`}
x1={cx}
y1={cy}
x2={x}
y2={y}
stroke="var(--color-rule)"
strokeWidth={1}
/>
);
})}
{/* 数据形状 */}
<path
d={path}
fill="var(--color-danger)"
fillOpacity={0.18}
stroke="var(--color-danger)"
strokeWidth={2}
/>
{/* 轴标签 */}
{data.map((d, i) => {
const a = angleAt(i);
const lx = cx + (r + 14) * Math.cos(a);
const ly = cy + (r + 14) * Math.sin(a);
return (
<text
key={`label-${i}`}
x={lx}
y={ly}
textAnchor="middle"
fontSize="9"
fontFamily="var(--font-family-sans)"
fill="var(--color-ink-muted)"
>
{d.knowledgePoint.length > 5
? d.knowledgePoint.slice(0, 5) + "…"
: d.knowledgePoint}
</text>
);
})}
</svg>
);
}
export default function ErrorBookPage(): JSX.Element {
const [subject, setSubject] = useState<string>("数学");
const [classId, setClassId] = useState<string>("");
const [result] = useQuery({
query: ErrorBookQuery,
variables: { subject, classId: classId || null },
});
const book: ErrorBookType | undefined = result.data?.errorBook;
return (
<div className="px-10 py-10">
<header className="mb-8">
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL ErrorBookQuery · × P7-insightsMSW mock
</p>
</header>
<div className="rule-thin mb-8" />
{/* 学科 Tab + 班级筛选 */}
<div className="mb-6 flex items-baseline justify-between flex-wrap gap-4">
<div className="flex gap-2">
{SUBJECTS.map((s) => {
const active = s === subject;
return (
<button
key={s}
type="button"
onClick={() => setSubject(s)}
className={`px-3 py-1.5 text-sm rounded-button transition-colors ${
active
? "text-ink-on-accent bg-accent"
: "text-ink border border-rule hover:bg-subtle"
}`}
>
{s}
</button>
);
})}
</div>
<div className="flex items-center gap-3">
<label className="text-tiny uppercase tracking-wide text-ink-muted">
</label>
<select
value={classId}
onChange={(e) => setClassId(e.target.value)}
className="px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
>
{CLASSES.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
</div>
{result.fetching ? (
<Loading lines={6} />
) : result.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{result.error.message}
</p>
</div>
) : !book ? (
<Empty title="暂无数据" description="该学科/班级暂无错题分析" />
) : (
<>
{/* 5 项统计卡片 */}
<section className="mb-8 grid grid-cols-2 md:grid-cols-5 gap-4">
<div className="p-5 border border-rule rounded-card">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
</p>
<p className="mt-2 text-3xl font-serif text-ink">
{book.summary.totalErrors}
</p>
</div>
<div className="p-5 border border-rule rounded-card">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
</p>
<p className="mt-2 text-3xl font-serif text-danger">
{book.summary.highFreqErrors}
</p>
</div>
<div className="p-5 border border-rule rounded-card">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
</p>
<p className="mt-2 text-3xl font-serif text-warning">
{book.summary.weakKpCount}
</p>
</div>
<div className="p-5 border border-rule rounded-card">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
</p>
<p className="mt-2 text-3xl font-serif text-ink">
{Math.round(book.summary.avgErrorRate * 100)}%
</p>
</div>
<div className="p-5 border border-rule rounded-card">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
</p>
<p
className={`mt-2 text-3xl font-serif ${
book.summary.trendUp ? "text-danger" : "text-success"
}`}
>
{book.summary.trendUp ? "↑" : "↓"}
</p>
</div>
</section>
{/* 班级对比柱图 */}
<section className="mb-8">
<h2 className="text-xl font-serif text-ink mb-4"></h2>
<div className="rule-thin mb-6" />
<ClassComparisonChart data={book.classComparison} />
</section>
{/* 章节薄弱度 + 知识点薄弱度雷达图 */}
<section className="mb-8 grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<h2 className="text-xl font-serif text-ink mb-4">
</h2>
<div className="rule-thin mb-6" />
<ChapterWeaknessChart data={book.chapterWeakness} />
</div>
<div>
<h2 className="text-xl font-serif text-ink mb-4">
</h2>
<div className="rule-thin mb-6" />
<KpWeaknessRadar data={book.kpWeakness} />
</div>
</section>
{/* 学生错题分组 */}
<section className="mb-8">
<h2 className="text-xl font-serif text-ink mb-4"></h2>
<div className="rule-thin mb-6" />
<table className="w-full text-sm">
<thead>
<tr className="text-left text-tiny uppercase tracking-wide text-ink-muted border-b border-rule">
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal"></th>
</tr>
</thead>
<tbody>
{book.studentGroups.map((g) => (
<tr key={g.studentId} className="border-b border-rule align-top">
<td className="py-3 pr-4 text-ink">{g.studentName}</td>
<td className="py-3 pr-4 font-mono text-accent">
{g.errorCount}
</td>
<td className="py-3 pr-4">
<ul className="space-y-1">
{g.topErrors.map((e) => (
<li key={e.questionId} className="text-tiny text-ink-muted">
<span className="font-mono text-danger">
{Math.round(e.errorRate * 100)}%
</span>
{" "}
{e.content.slice(0, 60)}
{e.content.length > 60 ? "…" : ""}
</li>
))}
</ul>
</td>
</tr>
))}
</tbody>
</table>
</section>
{/* Top10 高频错题 */}
<section>
<h2 className="text-xl font-serif text-ink mb-4">
Top 10
</h2>
<div className="rule-thin mb-6" />
<ol className="space-y-2">
{book.topQuestions.map((q, i) => (
<li
key={q.questionId}
className="py-3 px-4 border border-rule rounded-card bg-surface"
>
<div className="flex items-baseline gap-3">
<span className="text-lg font-serif text-ink-muted">
{i + 1}
</span>
<div className="flex-1">
<p className="text-sm text-ink">
{q.content.slice(0, 100)}
{q.content.length > 100 ? "…" : ""}
</p>
<p className="mt-1 text-tiny text-ink-muted font-mono">
{Math.round(q.errorRate * 100)}% · {q.subject}
</p>
</div>
</div>
</li>
))}
</ol>
</section>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,486 @@
"use client";
/**
* 考试分析仪表盘
*
* 数据来源P7 扩展MSW mock
* - GraphQL ExamAnalyticsQuery分数分布/均分/最高/最低/及格率/科目对比/每题正确率/学生排名
*
* 5 个 SVG 图表:
* 1. 分数分布柱图0-59/60-69/70-79/80-89/90-100
* 2. 每题正确率柱图20 题横向柱图)
* 3. 知识点掌握度雷达图
* 4. 班级对比柱图(含显著性)
* 5. 历次考试趋势折线图
*
* 学生排名表 + "导出分析报告" 按钮CSV
*
* 维护者ai13teacher-portal
*/
import { useQuery } from "urql";
import { useParams } from "next/navigation";
import Link from "next/link";
import { Loading, Empty } from "@edu/ui-components";
import { ExamAnalyticsQuery } from "@/lib/graphql-p7-exams";
import type {
ExamAnalytics,
ExamQuestionAccuracy,
ExamHistoryTrend,
} from "@/lib/graphql-p7-exams";
import {
DistributionBarChart,
ClassComparisonChart,
KnowledgeRadarChart,
} from "@/app/(app)/grades/analytics/charts";
// ============ 自定义图表(题正确率 + 历次趋势) ============
/** 每题正确率横向柱图 */
function QuestionAccuracyChart({
data,
}: {
data: ExamQuestionAccuracy[];
}): React.ReactNode {
if (data.length === 0) {
return <p className="text-sm text-ink-muted"></p>;
}
const W = 520;
const H = 360;
const PAD = { top: 16, right: 32, bottom: 24, left: 48 };
const innerW = W - PAD.left - PAD.right;
const innerH = H - PAD.top - PAD.bottom;
const barH = Math.max(6, innerH / data.length - 2);
// 正确率 0-100
return (
<svg
viewBox={`0 0 ${W} ${H}`}
className="w-full border border-rule rounded-card bg-surface"
role="img"
aria-label="每题正确率横向柱图"
>
{/* 网格线 */}
{[0, 25, 50, 75, 100].map((v) => {
const x = PAD.left + (v / 100) * innerW;
return (
<g key={`grid-${v}`}>
<line
x1={x}
y1={PAD.top}
x2={x}
y2={PAD.top + innerH}
stroke="var(--color-rule)"
strokeWidth={1}
strokeDasharray="3 3"
/>
<text
x={x}
y={H - PAD.bottom + 14}
textAnchor="middle"
fontSize="11"
fontFamily="var(--font-family-mono)"
fill="var(--color-ink-muted)"
>
{v}%
</text>
</g>
);
})}
{/* 柱子 */}
{data.map((item, i) => {
const y = PAD.top + i * (barH + 2);
const w = (item.correctRate / 100) * innerW;
return (
<g key={item.questionId}>
<text
x={PAD.left - 4}
y={y + barH / 2 + 4}
textAnchor="end"
fontSize="10"
fontFamily="var(--font-family-mono)"
fill="var(--color-ink-muted)"
>
{item.order}
</text>
<rect
x={PAD.left}
y={y}
width={Math.max(0, w)}
height={barH}
fill={
item.correctRate < 50
? "var(--color-danger)"
: item.correctRate < 70
? "var(--color-warning)"
: "var(--color-accent)"
}
rx={2}
/>
<text
x={PAD.left + w + 4}
y={y + barH / 2 + 4}
textAnchor="start"
fontSize="10"
fontFamily="var(--font-family-mono)"
fontWeight="600"
fill="var(--color-ink)"
>
{item.correctRate}%
</text>
</g>
);
})}
</svg>
);
}
/** 历次考试趋势折线图 */
function HistoryTrendChart({
data,
}: {
data: ExamHistoryTrend[];
}): React.ReactNode {
if (data.length === 0) {
return <p className="text-sm text-ink-muted"></p>;
}
const W = 520;
const H = 200;
const PAD = { top: 20, right: 24, bottom: 36, left: 36 };
const innerW = W - PAD.left - PAD.right;
const innerH = H - PAD.top - PAD.bottom;
const yMax = 100;
const yMin = 0;
const yRange = yMax - yMin;
const xStep = data.length > 1 ? innerW / (data.length - 1) : 0;
const points = data.map((d, i) => ({
x: PAD.left + i * xStep,
y: PAD.top + innerH - ((d.avg - yMin) / yRange) * innerH,
value: d.avg,
label: d.examTitle,
}));
const linePath = points
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`)
.join(" ");
const firstPoint = points[0];
const lastPoint = points[points.length - 1];
if (!firstPoint || !lastPoint) {
return <p className="text-sm text-ink-muted"></p>;
}
const areaPath = `${linePath} L ${lastPoint.x} ${PAD.top + innerH} L ${firstPoint.x} ${PAD.top + innerH} Z`;
const gridLines = [0, 50, 100].map((v) => {
const ratio = 1 - (v - yMin) / yRange;
return { y: PAD.top + innerH * ratio, value: v };
});
return (
<svg
viewBox={`0 0 ${W} ${H}`}
className="w-full border border-rule rounded-card bg-surface"
role="img"
aria-label="历次考试趋势折线图"
>
{gridLines.map((g, i) => (
<g key={`grid-${i}`}>
<line
x1={PAD.left}
y1={g.y}
x2={W - PAD.right}
y2={g.y}
stroke="var(--color-rule)"
strokeWidth={1}
strokeDasharray="3 3"
/>
<text
x={PAD.left - 8}
y={g.y + 4}
textAnchor="end"
fontSize="11"
fontFamily="var(--font-family-mono)"
fill="var(--color-ink-muted)"
>
{g.value}
</text>
</g>
))}
<path d={areaPath} fill="var(--color-accent)" fillOpacity={0.08} />
<path
d={linePath}
fill="none"
stroke="var(--color-accent)"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
{points.map((p, i) => (
<g key={`pt-${i}`}>
<circle
cx={p.x}
cy={p.y}
r={4}
fill="var(--color-accent)"
stroke="var(--bg-paper)"
strokeWidth={2}
/>
<text
x={p.x}
y={p.y - 10}
textAnchor="middle"
fontSize="11"
fontFamily="var(--font-family-mono)"
fontWeight="600"
fill="var(--color-ink)"
>
{p.value}
</text>
<text
x={p.x}
y={H - PAD.bottom + 18}
textAnchor="middle"
fontSize="10"
fontFamily="var(--font-family-sans)"
fill="var(--color-ink-muted)"
>
{i + 1}
</text>
</g>
))}
</svg>
);
}
// ============ CSV 导出 ============
/** 触发 CSV 下载 */
function exportAnalyticsCsv(analytics: ExamAnalytics): void {
const lines: string[] = [];
lines.push("# 考试分析报告");
lines.push(`# 考试: ${analytics.examTitle}`);
lines.push(
`# 应考 ${analytics.summary.expectedCount}, 实考 ${analytics.summary.attendedCount}, 均分 ${analytics.summary.avgScore}, 最高 ${analytics.summary.maxScore}, 最低 ${analytics.summary.minScore}, 及格率 ${analytics.summary.passRate}%`,
);
lines.push("");
lines.push("排名,学号,姓名,总分,等级");
analytics.rankings.forEach((r) => {
lines.push(
[r.rank, r.studentNo, r.studentName, r.totalScore, r.level].join(","),
);
});
lines.push("");
lines.push("题号,题目,正确率,平均得分,满分");
analytics.questionAccuracy.forEach((q) => {
const title = q.questionTitle.replace(/[\r\n,]/g, " ");
lines.push(
[q.order, title, `${q.correctRate}%`, q.avgScore, q.maxScore].join(","),
);
});
const blob = new Blob(["\ufeff" + lines.join("\n")], {
type: "text/csv;charset=utf-8",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `exam-analytics-${analytics.examId}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// ============ 页面 ============
export default function ExamAnalyticsPage(): React.ReactNode {
const params = useParams<{ id: string }>();
const examId = params?.id ?? "";
const [result] = useQuery({
query: ExamAnalyticsQuery,
variables: { examId },
pause: !examId,
});
const analytics: ExamAnalytics | undefined = result.data?.examAnalytics;
return (
<div className="px-10 py-10">
<header className="mb-8">
<p className="mb-2">
<Link
href={examId ? `/exams/${examId}` : "/exams"}
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
</p>
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL ExamAnalyticsQuery · core-edu P7 MSW mock
</p>
</header>
<div className="rule-thin mb-8" />
{result.fetching ? (
<Loading lines={8} />
) : result.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{result.error.message}
</p>
</div>
) : !analytics ? (
<Empty
title="未找到分析数据"
description="该考试暂未生成考后分析,或考试 ID 不存在"
/>
) : (
<section className="space-y-6">
{/* 顶部统计卡片 */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
{[
{ label: "应考人数", value: `${analytics.summary.expectedCount}` },
{ label: "实考人数", value: `${analytics.summary.attendedCount}` },
{ label: "平均分", value: `${analytics.summary.avgScore}` },
{ label: "最高分", value: `${analytics.summary.maxScore}` },
{ label: "最低分", value: `${analytics.summary.minScore}` },
{ label: "及格率", value: `${analytics.summary.passRate}%` },
].map((c) => (
<div
key={c.label}
className="border border-rule rounded-card p-3 bg-surface"
>
<p className="text-tiny uppercase tracking-wide text-ink-muted">
{c.label}
</p>
<p className="mt-1 text-2xl font-mono text-ink">{c.value}</p>
</div>
))}
</div>
{/* 导出按钮 */}
<div className="flex items-center justify-end">
<button
type="button"
onClick={() => exportAnalyticsCsv(analytics)}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
>
CSV
</button>
</div>
{/* 5 个图表 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="p-4 border border-rule rounded-card">
<h2 className="text-base font-serif text-ink mb-3"></h2>
<p className="text-tiny text-ink-muted mb-3">
</p>
<DistributionBarChart data={analytics.distribution} />
</div>
<div className="p-4 border border-rule rounded-card">
<h2 className="text-base font-serif text-ink mb-3"></h2>
<p className="text-tiny text-ink-muted mb-3">
&lt;50% · 50-70% · &gt;70%
</p>
<QuestionAccuracyChart data={analytics.questionAccuracy} />
</div>
<div className="p-4 border border-rule rounded-card">
<h2 className="text-base font-serif text-ink mb-3">
</h2>
<p className="text-tiny text-ink-muted mb-3">
*
</p>
<ClassComparisonChart data={analytics.classComparison} />
</div>
<div className="p-4 border border-rule rounded-card">
<h2 className="text-base font-serif text-ink mb-3">
</h2>
<p className="text-tiny text-ink-muted mb-3">
{analytics.historyTrend.length}
</p>
<HistoryTrendChart data={analytics.historyTrend} />
</div>
<div className="p-4 border border-rule rounded-card lg:col-span-2">
<h2 className="text-base font-serif text-ink mb-3">
</h2>
<p className="text-tiny text-ink-muted mb-3">
</p>
<KnowledgeRadarChart data={analytics.knowledgeMastery} />
</div>
</div>
{/* 学生排名表 */}
<div className="border border-rule rounded-card p-4 bg-surface">
<h2 className="text-base font-serif text-ink mb-3">
<span className="ml-2 text-sm font-sans text-ink-muted">
{analytics.rankings.length}
</span>
</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-rule">
<th className="text-left py-2 pr-4 text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="text-left py-2 pr-4 text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="text-left py-2 pr-4 text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="text-right py-2 pr-4 text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="text-right py-2 text-tiny uppercase tracking-wide text-ink-muted">
</th>
</tr>
</thead>
<tbody>
{analytics.rankings.map((r) => (
<tr
key={r.studentId}
className="border-b border-rule hover:bg-subtle"
>
<td className="py-2 pr-4 font-mono text-ink-muted">
{r.rank}
</td>
<td className="py-2 pr-4 font-mono text-ink">
{r.studentNo}
</td>
<td className="py-2 pr-4 text-ink">{r.studentName}</td>
<td className="py-2 pr-4 text-right font-mono text-ink">
{r.totalScore}
</td>
<td className="py-2 text-right font-mono">
<span
className={
r.level === "A" || r.level === "B"
? "text-success"
: r.level === "E"
? "text-danger"
: "text-ink"
}
>
{r.level}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</section>
)}
</div>
);
}

View File

@@ -0,0 +1,328 @@
"use client";
/**
* 富文本试卷编辑页
*
* 数据来源P7 扩展MSW mock
* - GraphQL ExamRichEditorQuery按 examId 拉取富文本试卷内容Tiptap examNodes 结构)
* - GraphQL SaveExamRichContentMutation保存富文本内容
*
* 布局:
* - 顶部:试卷标题 + 总分 + 题数
* - 中间富文本编辑器contenteditable div + 工具栏:加粗/斜体/标题/列表/插入题目/分页符)
* - "保存" 调用 SaveExamRichContentMutation
* - "预览" 在新 tab 打开 /exams/[id]
* - "导出 PDF" 调用 window.print()
*
* 维护者ai13teacher-portal
*/
import { useState, useRef, useEffect } from "react";
import { useQuery, useMutation } from "urql";
import { useParams } from "next/navigation";
import Link from "next/link";
import { Loading, Empty } from "@edu/ui-components";
import {
ExamRichEditorQuery,
SaveExamRichContentMutation,
} from "@/lib/graphql-p7-advanced";
/** 工具栏按钮定义 */
interface ToolButton {
command: string;
label: string;
value?: string;
}
const TOOLBAR_GROUPS: ToolButton[][] = [
[
{ command: "bold", label: "加粗" },
{ command: "italic", label: "斜体" },
],
[
{ command: "formatBlock", label: "H1", value: "h1" },
{ command: "formatBlock", label: "H2", value: "h2" },
{ command: "formatBlock", label: "H3", value: "h3" },
{ command: "formatBlock", label: "正文", value: "p" },
],
[
{ command: "insertUnorderedList", label: "无序列表" },
{ command: "insertOrderedList", label: "有序列表" },
],
[
{ command: "insertQuestion", label: "插入题目" },
{ command: "insertPageBreak", label: "插入分页符" },
],
];
/** 执行工具栏命令contenteditable + document.execCommandmock 实现) */
function execToolbar(
cmd: string,
value: string | undefined,
editorRef: React.RefObject<HTMLDivElement>,
): void {
if (cmd === "insertQuestion") {
// 插入题目占位块
document.execCommand("insertHTML", false, questionBlockHtml());
return;
}
if (cmd === "insertPageBreak") {
document.execCommand(
"insertHTML",
false,
'<div class="page-break" style="page-break-after:always;border-top:2px dashed var(--color-rule);margin:1rem 0;padding-top:0.5rem;color:var(--color-ink-muted);font-size:0.75rem;text-align:center;">— 分页 —</div>',
);
return;
}
// 标准命令
document.execCommand(cmd, false, value);
// 确保编辑器保持焦点
editorRef.current?.focus();
}
/** 生成题目占位 HTML */
function questionBlockHtml(): string {
return '<div class="question-block" style="border:1px solid var(--color-rule);border-radius:0.5rem;padding:1rem;margin:1rem 0;background:var(--color-surface);"><p style="font-weight:600;margin-bottom:0.5rem;">题目(请在下方编写题干)</p><p>选项 / 作答区...</p></div>';
}
export default function ExamRichEditorPage(): React.ReactNode {
const params = useParams<{ id: string }>();
const examId = params?.id ?? "";
const [result] = useQuery({
query: ExamRichEditorQuery,
variables: { examId },
pause: !examId,
});
const [, saveContent] = useMutation(SaveExamRichContentMutation);
const editorRef = useRef<HTMLDivElement>(null);
const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const [initialized, setInitialized] = useState(false);
const editor = result.data?.examRichEditor;
// 初始化编辑器内容mock将 JSON content 渲染为可编辑 HTML
useEffect(() => {
if (!editor || initialized || !editorRef.current) return;
// mock将 content 的文本内容直接渲染
const content = editor.content as {
type?: string;
content?: Array<{ type?: string; text?: string; attrs?: { level?: number } }>;
} | null;
if (content && Array.isArray(content.content)) {
const html = content.content
.map((node) => {
if (node.type === "heading") {
const level = node.attrs?.level ?? 1;
return `<h${level}>${node.text ?? ""}</h${level}>`;
}
if (node.type === "paragraph") {
return `<p>${node.text ?? ""}</p>`;
}
return "";
})
.join("");
editorRef.current.innerHTML = html || "<p></p>";
} else {
editorRef.current.innerHTML = "<p></p>";
}
setInitialized(true);
}, [editor, initialized]);
/** 保存富文本内容 */
const handleSave = async () => {
if (!editorRef.current) return;
setSaving(true);
setSaveError(null);
setSaveMsg(null);
// 将编辑器 HTML 序列化为 JSONmock保存为简化结构
const content = {
type: "doc",
content: serializeEditorToNodes(editorRef.current),
};
const res = await saveContent({
input: { examId, content },
});
setSaving(false);
if (res.error) {
setSaveError(res.error.message);
return;
}
setSaveMsg("已保存");
};
/** 预览:在新 tab 打开考试详情 */
const handlePreview = () => {
window.open(`/exams/${examId}`, "_blank");
};
/** 导出 PDF调用浏览器打印 */
const handleExportPdf = () => {
window.print();
};
return (
<div className="px-10 py-10">
<header className="mb-8">
<p className="mb-2">
<Link
href={`/exams/${examId}`}
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
</p>
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL ExamRichEditorQuery + SaveExamRichContentMutation · core-edu P7 MSW mock
</p>
</header>
<div className="rule-thin mb-8" />
{result.fetching ? (
<Loading lines={6} />
) : result.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{result.error.message}
</p>
</div>
) : !editor ? (
<Empty title="未找到试卷" description="该考试不存在或无富文本内容" />
) : (
<section className="max-w-4xl">
{/* 顶部信息卡片 */}
<div className="mb-6 flex items-baseline justify-between">
<div>
<h2 className="text-xl font-serif text-ink">{editor.title}</h2>
<p className="mt-1 text-sm text-ink-muted">
{new Date(editor.updatedAt).toLocaleString("zh-CN")}
</p>
</div>
<div className="flex items-baseline gap-6 text-sm">
<div>
<span className="text-tiny uppercase tracking-wide text-ink-muted">
</span>
<span className="ml-2 font-serif text-ink">
{editor.totalScore}
</span>
</div>
<div>
<span className="text-tiny uppercase tracking-wide text-ink-muted">
</span>
<span className="ml-2 font-serif text-ink">
{editor.questionCount}
</span>
</div>
</div>
</div>
{/* 工具栏 */}
<div className="mb-4 p-2 border border-rule rounded-card bg-surface flex items-center gap-2 flex-wrap">
{TOOLBAR_GROUPS.map((group, gi) => (
<div key={gi} className="flex items-center gap-1">
{gi > 0 && (
<span
className="mx-1 inline-block w-px h-5 bg-rule"
aria-hidden="true"
/>
)}
{group.map((btn) => (
<button
key={`${btn.command}-${btn.label}`}
type="button"
onClick={() => execToolbar(btn.command, btn.value, editorRef)}
className="px-2 py-1 text-tiny text-ink bg-transparent border border-transparent rounded-button hover:bg-subtle hover:border-rule"
title={btn.label}
>
{btn.label}
</button>
))}
</div>
))}
</div>
{/* 编辑器 */}
<div
ref={editorRef}
contentEditable
suppressContentEditableWarning
className="min-h-[400px] p-6 border border-rule rounded-card bg-paper text-ink focus:outline-none focus:border-accent font-serif"
style={{ lineHeight: 1.8 }}
aria-label="富文本试卷编辑器"
/>
{/* 错误 / 消息 */}
{saveError && (
<div className="mark-left mt-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">{saveError}</p>
</div>
)}
{saveMsg && (
<p className="mt-4 text-tiny text-success">{saveMsg}</p>
)}
{/* 操作按钮 */}
<div className="mt-6 flex items-center gap-4">
<button
type="button"
onClick={handleSave}
disabled={saving}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
>
{saving ? "保存中..." : "保存"}
</button>
<button
type="button"
onClick={handlePreview}
className="px-4 py-2 text-sm text-ink bg-transparent border border-rule rounded-button hover:bg-subtle"
>
</button>
<button
type="button"
onClick={handleExportPdf}
className="px-4 py-2 text-sm text-ink bg-transparent border border-rule rounded-button hover:bg-subtle"
>
PDF
</button>
</div>
</section>
)}
</div>
);
}
/** 将编辑器 DOM 序列化为简化节点结构mock Tiptap examNodes */
function serializeEditorToNodes(
el: HTMLDivElement,
): Array<{ type: string; text?: string; attrs?: { level?: number } }> {
const nodes: Array<{ type: string; text?: string; attrs?: { level?: number } }> = [];
const children = Array.from(el.children);
for (const child of children) {
const tag = child.tagName.toLowerCase();
const text = child.textContent ?? "";
if (tag === "h1") {
nodes.push({ type: "heading", text, attrs: { level: 1 } });
} else if (tag === "h2") {
nodes.push({ type: "heading", text, attrs: { level: 2 } });
} else if (tag === "h3") {
nodes.push({ type: "heading", text, attrs: { level: 3 } });
} else if (tag === "ul" || tag === "ol") {
nodes.push({ type: tag === "ul" ? "bulletList" : "orderedList", text });
} else if (tag === "div") {
nodes.push({ type: "questionBlock", text });
} else {
nodes.push({ type: "paragraph", text });
}
}
return nodes;
}

View File

@@ -0,0 +1,228 @@
"use client";
/**
* 考试详情页 - 显示考试基本信息 + 题目列表
*
* 数据来源contract.md §2.4GraphQL ExamDetailQueryP3 扩展MSW mock
* - 动态路由 /exams/[id]id 为考试 UUID
* - DRAFT 状态展示"发布考试"按钮(调用 CreateExamMutation 复用为发布动作占位)
*
* 维护者ai13teacher-portal
*/
import { useQuery, useMutation } from "urql";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { useState } from "react";
import { Loading, Empty } from "@edu/ui-components";
import { ExamDetailQuery, CreateExamMutation } from "@/lib/graphql";
import type { ExamQuestion } from "@/lib/graphql";
/** 题目类型中文映射 */
const QUESTION_TYPE_LABEL: Record<ExamQuestion["type"], string> = {
SINGLE_CHOICE: "单选题",
MULTIPLE_CHOICE: "多选题",
SHORT_ANSWER: "简答题",
ESSAY: "论述题",
};
/** 考试状态中文映射 */
const EXAM_STATUS_LABEL: Record<string, string> = {
DRAFT: "草稿",
PUBLISHED: "已发布",
IN_PROGRESS: "进行中",
GRADING: "批改中",
SCORED: "已完成",
ARCHIVED: "已归档",
};
export default function ExamDetailPage() {
const params = useParams<{ id: string }>();
const router = useRouter();
const examId = params?.id ?? "";
const [result] = useQuery({
query: ExamDetailQuery,
variables: { id: examId },
pause: !examId,
});
const [publishResult, publishExam] = useMutation(CreateExamMutation);
const [publishing, setPublishing] = useState(false);
const [publishError, setPublishError] = useState<string | null>(null);
const handlePublish = async () => {
if (!result.data?.examDetail) return;
setPublishing(true);
setPublishError(null);
// 复用 CreateExamMutation 作为发布动作占位MSW mock 接受相同 input
const res = await publishExam({
input: {
classId: result.data.examDetail.classId,
title: result.data.examDetail.title,
description: result.data.examDetail.description,
examDate: result.data.examDetail.examDate,
duration: result.data.examDetail.duration,
totalScore: result.data.examDetail.totalScore,
},
});
setPublishing(false);
if (res.error) {
setPublishError(res.error.message);
return;
}
// 发布成功后回到考试列表
router.push(`/exams?classId=${result.data.examDetail.classId}`);
};
return (
<div className="px-10 py-10">
<header className="mb-8">
<p className="mb-2">
<Link
href={`/exams?classId=${result.data?.examDetail?.classId ?? ""}`}
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
</p>
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL ExamDetailQuery · core-edu P3 MSW mock
</p>
</header>
<div className="rule-thin mb-8" />
{result.fetching ? (
<Loading lines={6} />
) : result.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{result.error.message}
</p>
</div>
) : !result.data?.examDetail ? (
<Empty title="未找到考试" description="该考试不存在或已被删除" />
) : (
<section className="max-w-4xl">
{/* 基本信息 */}
<div className="mb-10">
<h2 className="text-xl font-serif text-ink mb-2">
{result.data.examDetail.title}
</h2>
{result.data.examDetail.description && (
<p className="mt-1 text-sm text-ink-muted">
{result.data.examDetail.description}
</p>
)}
<div className="rule-thin mb-4 mt-3" />
<dl className="grid grid-cols-2 gap-4 text-sm">
<div>
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
</dt>
<dd className="mt-1 text-ink">
{new Date(result.data.examDetail.examDate).toLocaleString("zh-CN")}
</dd>
</div>
<div>
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
</dt>
<dd className="mt-1 text-ink">
{result.data.examDetail.duration}
</dd>
</div>
<div>
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
</dt>
<dd className="mt-1 text-ink">
{result.data.examDetail.totalScore}
</dd>
</div>
<div>
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
</dt>
<dd className="mt-1 text-ink">
{EXAM_STATUS_LABEL[result.data.examDetail.status] ?? result.data.examDetail.status}
{result.data.examDetail.status === "DRAFT" && (
<span className="ml-2 text-tiny text-warning"></span>
)}
</dd>
</div>
</dl>
{/* 发布按钮(仅 DRAFT 状态可见) */}
{result.data.examDetail.status === "DRAFT" && (
<div className="mt-6">
<button
type="button"
onClick={handlePublish}
disabled={publishing}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
>
{publishing ? "发布中..." : "发布考试"}
</button>
{publishError && (
<p className="mt-2 text-tiny text-danger">
{publishError}
</p>
)}
{publishResult.data && !publishError && (
<p className="mt-2 text-tiny text-success"></p>
)}
</div>
)}
</div>
{/* 题目列表 */}
<div>
<div className="flex items-baseline justify-between mb-4">
<h3 className="text-xl font-serif text-ink">
<span className="ml-2 text-sm font-sans text-ink-muted">
{result.data.examDetail.questions.length}
</span>
</h3>
</div>
<div className="rule-thin mb-6" />
{result.data.examDetail.questions.length === 0 ? (
<Empty title="暂无题目" description="该考试尚未添加题目" />
) : (
<ol className="space-y-0">
{result.data.examDetail.questions.map((q: ExamQuestion) => (
<li
key={q.id}
className="py-4 grid grid-cols-12 gap-4 items-baseline border-b border-rule"
>
<div className="col-span-1 text-tiny font-mono text-ink-muted">
{q.order}.
</div>
<div className="col-span-7">
<h4 className="text-base font-serif text-ink">
{q.title}
</h4>
<p className="mt-1 text-tiny text-ink-muted">
{QUESTION_TYPE_LABEL[q.type]}
</p>
</div>
<div className="col-span-2 text-sm text-ink">
{q.score}
</div>
<div className="col-span-2 text-right text-tiny font-mono text-ink-muted">
{q.id.slice(0, 8)}...
</div>
</li>
))}
</ol>
)}
</div>
</section>
)}
</div>
);
}

View File

@@ -0,0 +1,388 @@
"use client";
/**
* 监考面板页
*
* 数据来源P7 扩展MSW mock
* - GraphQL ProctoringStatusQuery按 examId 拉取监考状态(摘要 + 学生列表 + 近期事件)
* - GraphQL ProctoringEventMutation提交监考事件WARNING/FLAG/NOTE
*
* 布局:
* - 顶部:标题 + "开始监考"/"结束监考" 按钮 + 手动刷新按钮
* - 摘要卡片:应考数/在线数/已交卷数/异常数
* - 左侧:学生状态列表(按状态筛选)
* - 右侧:近期事件流(含操作按钮"标记"/"备注"
*
* 维护者ai13teacher-portal
*/
import { useState } from "react";
import { useQuery, useMutation } from "urql";
import { useParams } from "next/navigation";
import Link from "next/link";
import { Loading, Empty } from "@edu/ui-components";
import {
ProctoringStatusQuery,
ProctoringEventMutation,
} from "@/lib/graphql-p7-advanced";
import type {
ProctoringStudent,
ProctoringEvent,
ProctoringStudentStatus,
} from "@/lib/graphql-p7-advanced";
/** 学生状态中文映射 */
const STUDENT_STATUS_LABEL: Record<ProctoringStudentStatus, string> = {
online: "在线",
offline: "离线",
submitted: "已交卷",
flagged: "异常",
};
/** 学生状态对应颜色 */
function studentStatusColor(status: ProctoringStudentStatus): string {
switch (status) {
case "online":
return "var(--color-success)";
case "submitted":
return "var(--color-ink-muted)";
case "flagged":
return "var(--color-danger)";
default:
return "var(--color-warning)";
}
}
/** 事件类型中文映射 */
const EVENT_TYPE_LABEL: Record<string, string> = {
WARNING: "警告",
FLAG: "标记",
NOTE: "备注",
};
/** 事件类型颜色 */
function eventTypeColor(type: string): string {
switch (type) {
case "WARNING":
return "var(--color-warning)";
case "FLAG":
return "var(--color-danger)";
case "NOTE":
return "var(--color-ink-muted)";
default:
return "var(--color-ink-muted)";
}
}
/** 状态筛选选项 */
const STATUS_FILTER_OPTIONS: Array<{ value: string; label: string }> = [
{ value: "", label: "全部状态" },
{ value: "online", label: "在线" },
{ value: "offline", label: "离线" },
{ value: "submitted", label: "已交卷" },
{ value: "flagged", label: "异常" },
];
export default function ProctoringPage(): React.ReactNode {
const params = useParams<{ id: string }>();
const examId = params?.id ?? "";
const [result, reexecuteQuery] = useQuery({
query: ProctoringStatusQuery,
variables: { examId },
pause: !examId,
requestPolicy: "network-only",
});
const [, submitEvent] = useMutation(ProctoringEventMutation);
const [statusFilter, setStatusFilter] = useState("");
const [actionMsg, setActionMsg] = useState<string | null>(null);
const [proctoringActive, setProctoringActive] = useState(false);
const status = result.data?.proctoringStatus;
const summary = status?.summary;
const allStudents: ProctoringStudent[] = status?.students ?? [];
const recentEvents: ProctoringEvent[] = status?.recentEvents ?? [];
// 按状态筛选学生
const filteredStudents = statusFilter
? allStudents.filter((s) => s.status === statusFilter)
: allStudents;
/** 提交监考事件 */
const handleEvent = async (
studentId: string,
eventType: "WARNING" | "FLAG" | "NOTE",
note?: string,
) => {
setActionMsg(null);
const res = await submitEvent({
input: {
examId,
studentId,
eventType,
note: note ?? null,
},
});
if (res.error) {
setActionMsg(`操作失败:${res.error.message}`);
return;
}
setActionMsg("已提交事件");
// 刷新数据
reexecuteQuery({ requestPolicy: "network-only" });
};
/** 手动刷新 */
const handleRefresh = () => {
reexecuteQuery({ requestPolicy: "network-only" });
};
/** 开始/结束监考mock本地状态切换 */
const toggleProctoring = () => {
setProctoringActive((prev) => !prev);
handleRefresh();
};
return (
<div className="px-10 py-10">
<header className="mb-8">
<p className="mb-2">
<Link
href={`/exams/${examId}`}
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
</p>
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL ProctoringStatusQuery + ProctoringEventMutation · core-edu P7 MSW mock
</p>
</header>
<div className="rule-thin mb-8" />
{result.fetching ? (
<Loading lines={8} />
) : result.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{result.error.message}
</p>
</div>
) : !status || !summary ? (
<Empty title="未找到监考数据" description="该考试无监考信息" />
) : (
<section className="space-y-8">
{/* 顶部操作栏 */}
<div className="flex items-center justify-between">
<div>
{summary.startedAt && (
<p className="text-tiny text-ink-muted">
{new Date(summary.startedAt).toLocaleString("zh-CN")}
</p>
)}
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleRefresh}
className="px-3 py-1.5 text-tiny text-ink bg-transparent border border-rule rounded-button hover:bg-subtle"
>
</button>
<button
type="button"
onClick={toggleProctoring}
className={
proctoringActive
? "px-4 py-2 text-sm text-ink-on-accent bg-danger rounded-button hover:opacity-90"
: "px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
}
>
{proctoringActive ? "结束监考" : "开始监考"}
</button>
</div>
</div>
{/* 摘要卡片 */}
<div className="grid grid-cols-4 gap-4">
<SummaryCard
label="应考数"
value={summary.expectedCount}
color="var(--color-ink)"
/>
<SummaryCard
label="在线数"
value={summary.onlineCount}
color="var(--color-success)"
/>
<SummaryCard
label="已交卷"
value={summary.submittedCount}
color="var(--color-ink-muted)"
/>
<SummaryCard
label="异常数"
value={summary.flaggedCount}
color="var(--color-danger)"
/>
</div>
{actionMsg && (
<p className="text-tiny text-success">{actionMsg}</p>
)}
{/* 左右分栏:学生列表 + 事件流 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* 左侧:学生状态列表 */}
<section>
<div className="flex items-baseline justify-between mb-4">
<h3 className="text-lg font-serif text-ink"></h3>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-2 py-1 bg-transparent border border-rule rounded-button text-tiny text-ink focus:outline-none"
>
{STATUS_FILTER_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="rule-thin mb-4" />
{filteredStudents.length === 0 ? (
<Empty title="暂无学生" description="当前筛选无学生记录" />
) : (
<ul className="space-y-2 max-h-[600px] overflow-y-auto">
{filteredStudents.map((stu) => (
<li
key={stu.studentId}
className="flex items-center justify-between p-3 border border-rule rounded-card hover:bg-subtle"
>
<div>
<p className="text-sm font-serif text-ink">
{stu.studentName}
<span className="ml-2 text-tiny font-mono text-ink-muted">
{stu.studentNo}
</span>
</p>
{stu.lastEventAt && (
<p className="mt-1 text-tiny text-ink-muted">
{new Date(stu.lastEventAt).toLocaleTimeString("zh-CN")}
</p>
)}
</div>
<div className="flex items-center gap-3">
<span
className="text-tiny"
style={{ color: studentStatusColor(stu.status) }}
>
{STUDENT_STATUS_LABEL[stu.status]}
</span>
<div className="flex gap-1">
<button
type="button"
onClick={() =>
handleEvent(stu.studentId, "FLAG", "教师标记异常")
}
className="px-2 py-0.5 text-tiny text-danger bg-transparent border border-danger rounded-button hover:bg-subtle"
>
</button>
<button
type="button"
onClick={() => {
const note = window.prompt("输入备注:");
if (note) handleEvent(stu.studentId, "NOTE", note);
}}
className="px-2 py-0.5 text-tiny text-ink bg-transparent border border-rule rounded-button hover:bg-subtle"
>
</button>
</div>
</div>
</li>
))}
</ul>
)}
</section>
{/* 右侧:事件流 */}
<section>
<h3 className="text-lg font-serif text-ink mb-4"></h3>
<div className="rule-thin mb-4" />
{recentEvents.length === 0 ? (
<Empty title="暂无事件" description="监考期间无事件记录" />
) : (
<ul className="space-y-3 max-h-[600px] overflow-y-auto">
{recentEvents.map((evt) => (
<li
key={evt.eventId}
className="p-3 border border-rule rounded-card"
>
<div className="flex items-baseline justify-between mb-1">
<span
className="text-tiny font-serif"
style={{ color: eventTypeColor(evt.eventType) }}
>
{EVENT_TYPE_LABEL[evt.eventType] ?? evt.eventType}
</span>
<span className="text-tiny text-ink-muted">
{new Date(evt.createdAt).toLocaleString("zh-CN")}
</span>
</div>
<p className="text-sm text-ink">
<span className="font-serif">{evt.studentName}</span>
<span className="ml-2 text-tiny font-mono text-ink-muted">
{evt.studentNo}
</span>
</p>
{evt.note && (
<p className="mt-1 text-sm text-ink-muted">
{evt.note}
</p>
)}
</li>
))}
</ul>
)}
</section>
</div>
</section>
)}
</div>
);
}
/** 摘要卡片子组件 */
function SummaryCard({
label,
value,
color,
}: {
label: string;
value: number;
color: string;
}): React.ReactNode {
return (
<div className="p-4 border border-rule rounded-card bg-surface">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
{label}
</p>
<p
className="mt-2 text-3xl font-serif"
style={{ color }}
>
{value}
</p>
</div>
);
}

View File

@@ -0,0 +1,249 @@
"use client";
/**
* 新建考试表单页
*
* 数据来源contract.md §2.4GraphQL CreateExamMutationP3 扩展MSW mock
* - 通过 URL ?classId=xxx 预填班级 ID
* - 表单字段:标题、描述、考试日期、时长、满分
* - 乐观更新:本地先插入临时项,失败后回滚(基于 urql mutation cache 更新)
*
* 维护者ai13teacher-portal
*/
import { useState, Suspense } from "react";
import { useMutation, useQuery } from "urql";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Loading } from "@edu/ui-components";
import { CreateExamMutation, ClassesQuery } from "@/lib/graphql";
import type { Class } from "@/lib/graphql";
function NewExamContent() {
const router = useRouter();
const searchParams = useSearchParams();
const presetClassId = searchParams.get("classId") ?? "";
// 拉取班级列表(用于班级选择)
const [classesResult] = useQuery({ query: ClassesQuery });
const [createExamResult, createExam] = useMutation(CreateExamMutation);
const [classId, setClassId] = useState(presetClassId);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [examDate, setExamDate] = useState("");
const [duration, setDuration] = useState("120");
const [totalScore, setTotalScore] = useState("100");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!classId) {
setError("请选择班级");
return;
}
if (!title.trim()) {
setError("请填写考试标题");
return;
}
if (!examDate) {
setError("请选择考试日期");
return;
}
setSubmitting(true);
// 乐观更新urql mutation 完成后由组件状态驱动跳转;
// 失败回滚体现在错误信息展示 + 不跳转。
const res = await createExam({
input: {
classId,
title: title.trim(),
description: description.trim() || null,
examDate: new Date(examDate).toISOString(),
duration: Number(duration) || 0,
totalScore: Number(totalScore) || 0,
},
});
setSubmitting(false);
if (res.error) {
setError(res.error.message);
return;
}
// 成功后跳回考试列表
router.push(`/exams?classId=${classId}`);
};
if (classesResult.fetching) {
return (
<div className="px-10 py-10">
<Loading lines={4} />
</div>
);
}
const classes = (classesResult.data?.classes ?? []) as Class[];
return (
<div className="px-10 py-10">
<header className="mb-8">
<p className="mb-2">
<Link
href={`/exams?classId=${classId}`}
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
</p>
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL CreateExamMutation · core-edu P3 MSW mock
</p>
</header>
<div className="rule-thin mb-8" />
<section className="max-w-2xl">
<form onSubmit={handleSubmit} className="space-y-6">
{/* 班级选择 */}
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
<span className="text-danger">*</span>
</label>
<select
value={classId}
onChange={(e) => setClassId(e.target.value)}
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
required
>
<option value=""></option>
{classes.map((cls) => (
<option key={cls.id} value={cls.id}>
{cls.name}
</option>
))}
</select>
</div>
{/* 标题 */}
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
<span className="text-danger">*</span>
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm text-ink focus:outline-none focus:border-b-2"
placeholder="例如2026 春季期中考试"
required
/>
</div>
{/* 描述 */}
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
placeholder="考试范围、注意事项等"
/>
</div>
{/* 考试日期 */}
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
<span className="text-danger">*</span>
</label>
<input
type="datetime-local"
value={examDate}
onChange={(e) => setExamDate(e.target.value)}
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
required
/>
</div>
{/* 时长 + 满分 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
<span className="text-danger">*</span>
</label>
<input
type="number"
min={1}
value={duration}
onChange={(e) => setDuration(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm text-ink focus:outline-none focus:border-b-2"
required
/>
</div>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
<span className="text-danger">*</span>
</label>
<input
type="number"
min={1}
value={totalScore}
onChange={(e) => setTotalScore(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm text-ink focus:outline-none focus:border-b-2"
required
/>
</div>
</div>
{/* 错误提示 */}
{error && (
<div className="mark-left py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">{error}</p>
</div>
)}
{/* 操作按钮 */}
<div className="flex items-center gap-4 pt-4">
<button
type="submit"
disabled={submitting}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
>
{submitting ? "提交中..." : "创建考试"}
</button>
<Link
href={`/exams?classId=${classId}`}
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
{createExamResult.data && (
<span className="text-tiny text-success"></span>
)}
</div>
</form>
</section>
</div>
);
}
export default function NewExamPage() {
return (
<Suspense
fallback={
<div className="px-10 py-10">
<Loading lines={3} />
</div>
}
>
<NewExamContent />
</Suspense>
);
}

View File

@@ -6,12 +6,14 @@
* 数据来源contract.md §2.4GraphQL ClassExamsQueryP3 扩展MSW mock
* - 通过 URL ?classId=xxx 指定班级
* - P2 阶段用 MSW mock 数据,待 teacher-bff P3 启用 core-edu 聚合
* - P3列表项可点击进入详情页顶部提供"新建考试"入口
*
* 维护者ai13teacher-portal
*/
import { useQuery } from "urql";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { Suspense } from "react";
import { Loading, Empty } from "@edu/ui-components";
import { ClassExamsQuery, ClassQuery } from "@/lib/graphql";
@@ -46,13 +48,21 @@ function ExamsContent() {
return (
<div className="px-10 py-10">
<header className="mb-8">
<h1 className="text-3xl font-serif text-ink">
{cls ? `${cls.name} - 考试管理` : "考试管理"}
</h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL ClassExamsQuery · core-edu P3 MSW mock
</p>
<header className="mb-8 flex items-baseline justify-between">
<div>
<h1 className="text-3xl font-serif text-ink">
{cls ? `${cls.name} - 考试管理` : "考试管理"}
</h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL ClassExamsQuery · core-edu P3 MSW mock
</p>
</div>
<Link
href={`/exams/new?classId=${classId}`}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
>
</Link>
</header>
<div className="rule-thin mb-8" />
@@ -66,16 +76,31 @@ function ExamsContent() {
</p>
</div>
) : exams.length === 0 ? (
<Empty title="暂无考试" description="该班级尚未创建考试" />
<Empty
title="暂无考试"
description="该班级尚未创建考试"
action={
<Link
href={`/exams/new?classId=${classId}`}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
>
</Link>
}
/>
) : (
<ul className="space-y-0">
{(exams as ExamItem[]).map((exam) => (
<li
key={exam.id}
className="py-4 grid grid-cols-12 gap-4 items-baseline border-b border-rule"
className="py-4 grid grid-cols-12 gap-4 items-baseline border-b border-rule hover:bg-subtle"
>
<div className="col-span-7">
<h3 className="text-lg font-serif text-ink">{exam.title}</h3>
<Link href={`/exams/${exam.id}`}>
<h3 className="text-lg font-serif text-ink hover:text-accent">
{exam.title}
</h3>
</Link>
{exam.description && (
<p className="mt-1 text-sm text-ink-muted">
{exam.description}
@@ -90,8 +115,13 @@ function ExamsContent() {
<div className="col-span-3 text-tiny text-ink-muted">
: {exam.status}
</div>
<div className="col-span-2 text-right text-tiny font-mono text-ink-muted">
{exam.id.slice(0, 8)}...
<div className="col-span-2 text-right text-tiny">
<Link
href={`/exams/${exam.id}`}
className="font-mono text-ink-muted hover:text-accent"
>
</Link>
</div>
</li>
))}

View File

@@ -0,0 +1,598 @@
"use client";
/**
* 成绩分析图表组件集(纯 SVG无外部依赖
*
* 5 个图表:
* 1. TrendLineChart - 趋势折线图avg/max/min 三线)
* 2. DistributionBarChart - 分数分布柱图5 段)
* 3. SubjectComparisonChart - 学科对比柱图6 学科)
* 4. ClassComparisonChart - 班级对比柱图(含显著性 * 标记)
* 5. KnowledgeRadarChart - 知识点掌握度雷达图5 轴)
*
* 颜色使用 var(--color-*) 语义令牌project_rules §3.10
*
* 维护者ai13teacher-portal
*/
import type {
GradeTrendPoint,
GradeDistributionBand,
SubjectComparisonItem,
ClassComparisonItem,
KnowledgeMasteryItem,
} from "@/lib/graphql-p7-grades";
// ============ 1. 趋势折线图avg/max/min 三线) ============
const TREND_PADDING = { top: 24, right: 32, bottom: 36, left: 36 };
const TREND_W = 520;
const TREND_H = 220;
export function TrendLineChart({ data }: { data: GradeTrendPoint[] }): React.ReactNode {
if (data.length === 0) {
return <p className="text-sm text-ink-muted"></p>;
}
const innerW = TREND_W - TREND_PADDING.left - TREND_PADDING.right;
const innerH = TREND_H - TREND_PADDING.top - TREND_PADDING.bottom;
const allValues = data.flatMap((d) => [d.avg, d.max, d.min]);
const yMax = Math.max(100, ...allValues);
const yMin = Math.min(0, ...allValues);
const yRange = yMax - yMin || 1;
const xStep = data.length > 1 ? innerW / (data.length - 1) : 0;
const toPoint = (value: number, i: number) => ({
x: TREND_PADDING.left + i * xStep,
y: TREND_PADDING.top + innerH - ((value - yMin) / yRange) * innerH,
});
const buildPath = (key: "avg" | "max" | "min") =>
data
.map((d, i) => {
const p = toPoint(d[key], i);
return `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`;
})
.join(" ");
// Y 轴网格0 / 50 / 100
const gridLines = [0, 50, 100].map((v) => {
const ratio = 1 - (v - yMin) / yRange;
return { y: TREND_PADDING.top + innerH * ratio, value: v };
});
const lineColor = (key: "avg" | "max" | "min") =>
key === "avg"
? "var(--color-accent)"
: key === "max"
? "var(--color-success)"
: "var(--color-danger)";
return (
<svg
viewBox={`0 0 ${TREND_W} ${TREND_H}`}
className="w-full border border-rule rounded-card bg-surface"
role="img"
aria-label="成绩趋势折线图"
>
{/* 网格线 + Y 刻度 */}
{gridLines.map((g, i) => (
<g key={`grid-${i}`}>
<line
x1={TREND_PADDING.left}
y1={g.y}
x2={TREND_W - TREND_PADDING.right}
y2={g.y}
stroke="var(--color-rule)"
strokeWidth={1}
strokeDasharray="3 3"
/>
<text
x={TREND_PADDING.left - 8}
y={g.y + 4}
textAnchor="end"
fontSize="11"
fontFamily="var(--font-family-mono)"
fill="var(--color-ink-muted)"
>
{g.value}
</text>
</g>
))}
{/* 三条线 */}
{(["max", "avg", "min"] as const).map((key) => (
<g key={key}>
<path
d={buildPath(key)}
fill="none"
stroke={lineColor(key)}
strokeWidth={key === "avg" ? 2.5 : 1.5}
strokeDasharray={key === "avg" ? undefined : "4 3"}
strokeLinecap="round"
strokeLinejoin="round"
/>
{data.map((d, i) => {
const p = toPoint(d[key], i);
return (
<circle
key={`${key}-${i}`}
cx={p.x}
cy={p.y}
r={key === "avg" ? 4 : 3}
fill={lineColor(key)}
stroke="var(--bg-paper)"
strokeWidth={1.5}
/>
);
})}
</g>
))}
{/* X 轴月份标签 */}
{data.map((d, i) => (
<text
key={`x-${i}`}
x={TREND_PADDING.left + i * xStep}
y={TREND_H - TREND_PADDING.bottom + 18}
textAnchor="middle"
fontSize="11"
fontFamily="var(--font-family-sans)"
fill="var(--color-ink-muted)"
>
{d.month}
</text>
))}
{/* avg 数值标签 */}
{data.map((d, i) => {
const p = toPoint(d.avg, i);
return (
<text
key={`avg-label-${i}`}
x={p.x}
y={p.y - 10}
textAnchor="middle"
fontSize="11"
fontFamily="var(--font-family-mono)"
fontWeight="600"
fill="var(--color-ink)"
>
{d.avg}
</text>
);
})}
</svg>
);
}
// ============ 2. 分数分布柱图 ============
export function DistributionBarChart({
data,
}: {
data: GradeDistributionBand[];
}): React.ReactNode {
if (data.length === 0) {
return <p className="text-sm text-ink-muted"></p>;
}
const maxCount = Math.max(1, ...data.map((d) => d.count));
const W = 480;
const H = 180;
const PAD = { top: 20, right: 16, bottom: 36, left: 36 };
const innerW = W - PAD.left - PAD.right;
const innerH = H - PAD.top - PAD.bottom;
const barW = innerW / data.length;
return (
<svg
viewBox={`0 0 ${W} ${H}`}
className="w-full border border-rule rounded-card bg-surface"
role="img"
aria-label="分数分布柱图"
>
{/* Y 轴网格 */}
{[0, 0.5, 1].map((ratio, i) => {
const y = PAD.top + innerH * ratio;
const value = Math.round(maxCount * (1 - ratio));
return (
<g key={`grid-${i}`}>
<line
x1={PAD.left}
y1={y}
x2={W - PAD.right}
y2={y}
stroke="var(--color-rule)"
strokeWidth={1}
strokeDasharray="3 3"
/>
<text
x={PAD.left - 8}
y={y + 4}
textAnchor="end"
fontSize="11"
fontFamily="var(--font-family-mono)"
fill="var(--color-ink-muted)"
>
{value}
</text>
</g>
);
})}
{/* 柱子 */}
{data.map((band, i) => {
const barH = (band.count / maxCount) * innerH;
const x = PAD.left + i * barW + barW * 0.2;
const y = PAD.top + innerH - barH;
const w = barW * 0.6;
return (
<g key={band.label}>
<rect
x={x}
y={y}
width={w}
height={Math.max(0, barH)}
fill="var(--color-accent)"
rx={3}
/>
<text
x={x + w / 2}
y={y - 6}
textAnchor="middle"
fontSize="11"
fontFamily="var(--font-family-mono)"
fontWeight="600"
fill="var(--color-ink)"
>
{band.count}
</text>
<text
x={x + w / 2}
y={H - PAD.bottom + 18}
textAnchor="middle"
fontSize="11"
fontFamily="var(--font-family-sans)"
fill="var(--color-ink-muted)"
>
{band.label}
</text>
</g>
);
})}
</svg>
);
}
// ============ 3. 学科对比柱图 ============
export function SubjectComparisonChart({
data,
}: {
data: SubjectComparisonItem[];
}): React.ReactNode {
if (data.length === 0) {
return <p className="text-sm text-ink-muted"></p>;
}
const maxAvg = Math.max(1, ...data.map((d) => d.avg));
const W = 480;
const H = 180;
const PAD = { top: 20, right: 16, bottom: 36, left: 36 };
const innerW = W - PAD.left - PAD.right;
const innerH = H - PAD.top - PAD.bottom;
const barW = innerW / data.length;
return (
<svg
viewBox={`0 0 ${W} ${H}`}
className="w-full border border-rule rounded-card bg-surface"
role="img"
aria-label="学科对比柱图"
>
{[0, 0.5, 1].map((ratio, i) => {
const y = PAD.top + innerH * ratio;
const value = Math.round(maxAvg * (1 - ratio));
return (
<g key={`grid-${i}`}>
<line
x1={PAD.left}
y1={y}
x2={W - PAD.right}
y2={y}
stroke="var(--color-rule)"
strokeWidth={1}
strokeDasharray="3 3"
/>
<text
x={PAD.left - 8}
y={y + 4}
textAnchor="end"
fontSize="11"
fontFamily="var(--font-family-mono)"
fill="var(--color-ink-muted)"
>
{value}
</text>
</g>
);
})}
{data.map((item, i) => {
const barH = (item.avg / maxAvg) * innerH;
const x = PAD.left + i * barW + barW * 0.2;
const y = PAD.top + innerH - barH;
const w = barW * 0.6;
return (
<g key={item.subject}>
<rect
x={x}
y={y}
width={w}
height={Math.max(0, barH)}
fill="var(--color-accent)"
rx={3}
/>
<text
x={x + w / 2}
y={y - 6}
textAnchor="middle"
fontSize="11"
fontFamily="var(--font-family-mono)"
fontWeight="600"
fill="var(--color-ink)"
>
{item.avg}
</text>
<text
x={x + w / 2}
y={H - PAD.bottom + 18}
textAnchor="middle"
fontSize="11"
fontFamily="var(--font-family-sans)"
fill="var(--color-ink-muted)"
>
{item.subject}
</text>
</g>
);
})}
</svg>
);
}
// ============ 4. 班级对比柱图(含显著性 * 标记) ============
export function ClassComparisonChart({
data,
}: {
data: ClassComparisonItem[];
}): React.ReactNode {
if (data.length === 0) {
return <p className="text-sm text-ink-muted"></p>;
}
const maxAvg = Math.max(1, ...data.map((d) => d.avg));
const W = 480;
const H = 200;
const PAD = { top: 24, right: 16, bottom: 48, left: 36 };
const innerW = W - PAD.left - PAD.right;
const innerH = H - PAD.top - PAD.bottom;
const barW = innerW / data.length;
return (
<svg
viewBox={`0 0 ${W} ${H}`}
className="w-full border border-rule rounded-card bg-surface"
role="img"
aria-label="班级对比柱图"
>
{[0, 0.5, 1].map((ratio, i) => {
const y = PAD.top + innerH * ratio;
const value = Math.round(maxAvg * (1 - ratio));
return (
<g key={`grid-${i}`}>
<line
x1={PAD.left}
y1={y}
x2={W - PAD.right}
y2={y}
stroke="var(--color-rule)"
strokeWidth={1}
strokeDasharray="3 3"
/>
<text
x={PAD.left - 8}
y={y + 4}
textAnchor="end"
fontSize="11"
fontFamily="var(--font-family-mono)"
fill="var(--color-ink-muted)"
>
{value}
</text>
</g>
);
})}
{data.map((item, i) => {
const barH = (item.avg / maxAvg) * innerH;
const x = PAD.left + i * barW + barW * 0.2;
const y = PAD.top + innerH - barH;
const w = barW * 0.6;
return (
<g key={item.classId}>
<rect
x={x}
y={y}
width={w}
height={Math.max(0, barH)}
fill={
item.significant
? "var(--color-warning)"
: "var(--color-accent)"
}
rx={3}
/>
<text
x={x + w / 2}
y={y - 6}
textAnchor="middle"
fontSize="11"
fontFamily="var(--font-family-mono)"
fontWeight="600"
fill="var(--color-ink)"
>
{item.avg}
{item.significant ? "*" : ""}
</text>
<text
x={x + w / 2}
y={H - PAD.bottom + 14}
textAnchor="middle"
fontSize="10"
fontFamily="var(--font-family-sans)"
fill="var(--color-ink-muted)"
>
{item.className}
</text>
{item.significant && (
<text
x={x + w / 2}
y={H - PAD.bottom + 28}
textAnchor="middle"
fontSize="9"
fontFamily="var(--font-family-sans)"
fill="var(--color-warning)"
>
</text>
)}
</g>
);
})}
</svg>
);
}
// ============ 5. 知识点掌握度雷达图 ============
export function KnowledgeRadarChart({
data,
}: {
data: KnowledgeMasteryItem[];
}): React.ReactNode {
if (data.length < 3) {
return <p className="text-sm text-ink-muted"></p>;
}
const W = 360;
const H = 360;
const cx = W / 2;
const cy = H / 2;
const radius = 120;
const n = data.length;
// 5 个层级20/40/60/80/100
const rings = [0.2, 0.4, 0.6, 0.8, 1];
// 计算顶点坐标
const angle = (i: number) => (Math.PI * 2 * i) / n - Math.PI / 2;
const pointAt = (i: number, r: number) => ({
x: cx + r * Math.cos(angle(i)),
y: cy + r * Math.sin(angle(i)),
});
// 数据多边形顶点
const dataPoints = data.map((d, i) =>
pointAt(i, (d.mastery / 100) * radius),
);
const dataPath = dataPoints
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`)
.join(" ") + " Z";
return (
<svg
viewBox={`0 0 ${W} ${H}`}
className="w-full max-w-sm border border-rule rounded-card bg-surface mx-auto"
role="img"
aria-label="知识点掌握度雷达图"
>
{/* 同心多边形网格 */}
{rings.map((ratio, ri) => {
const r = radius * ratio;
const pts = data
.map((_, i) => {
const p = pointAt(i, r);
return `${p.x},${p.y}`;
})
.join(" ");
return (
<polygon
key={`ring-${ri}`}
points={pts}
fill="none"
stroke="var(--color-rule)"
strokeWidth={1}
strokeDasharray={ri === rings.length - 1 ? undefined : "3 3"}
/>
);
})}
{/* 轴线 */}
{data.map((_, i) => {
const p = pointAt(i, radius);
return (
<line
key={`axis-${i}`}
x1={cx}
y1={cy}
x2={p.x}
y2={p.y}
stroke="var(--color-rule)"
strokeWidth={1}
/>
);
})}
{/* 数据多边形 */}
<path
d={dataPath}
fill="var(--color-accent)"
fillOpacity={0.15}
stroke="var(--color-accent)"
strokeWidth={2}
strokeLinejoin="round"
/>
{/* 数据点 + 标签 */}
{dataPoints.map((p, i) => {
const d = data[i];
if (!d) return null;
const labelP = pointAt(i, radius + 24);
return (
<g key={`pt-${i}`}>
<circle
cx={p.x}
cy={p.y}
r={4}
fill="var(--color-accent)"
stroke="var(--bg-paper)"
strokeWidth={2}
/>
<text
x={labelP.x}
y={labelP.y}
textAnchor="middle"
fontSize="11"
fontFamily="var(--font-family-sans)"
fill="var(--color-ink-muted)"
>
{d.knowledgePoint}
</text>
<text
x={labelP.x}
y={labelP.y + 13}
textAnchor="middle"
fontSize="10"
fontFamily="var(--font-family-mono)"
fontWeight="600"
fill="var(--color-accent)"
>
{d.mastery}%
</text>
</g>
);
})}
</svg>
);
}

View File

@@ -0,0 +1,217 @@
"use client";
/**
* 成绩分析仪表盘
*
* 数据来源P7 扩展MSW mock
* - GraphQL GradeAnalyticsQuery趋势/分布/学科对比/班级对比/知识点掌握度
* - GraphQL ClassesQuery班级下拉选项
*
* 5 个 SVG 图表(参考 analytics/TrendChart.tsx 模式):
* 1. 趋势折线图avg/max/min 三线)
* 2. 分数分布柱图5 段)
* 3. 学科对比柱图6 学科)
* 4. 班级对比柱图(含显著性 * 标记)
* 5. 知识点掌握度雷达图5 轴)
*
* 维护者ai13teacher-portal
*/
import { useState } from "react";
import { useQuery } from "urql";
import Link from "next/link";
import { Loading, Empty } from "@edu/ui-components";
import { ClassesQuery } from "@/lib/graphql";
import type { Class } from "@/lib/graphql";
import { GradeAnalyticsQuery } from "@/lib/graphql-p7-grades";
import type { GradeAnalytics } from "@/lib/graphql-p7-grades";
import {
TrendLineChart,
DistributionBarChart,
SubjectComparisonChart,
ClassComparisonChart,
KnowledgeRadarChart,
} from "./charts";
/** 学期选项 */
const SEMESTERS = [
{ value: "", label: "全部学期" },
{ value: "1", label: "第一学期" },
{ value: "2", label: "第二学期" },
];
/** 考试类型选项 */
const EXAM_TYPES = [
{ value: "", label: "全部考试" },
{ value: "midterm", label: "期中考试" },
{ value: "final", label: "期末考试" },
{ value: "unit", label: "单元测验" },
];
export default function GradeAnalyticsPage(): React.ReactNode {
const [classId, setClassId] = useState("");
const [semester, setSemester] = useState("");
const [examType, setExamType] = useState("");
// 班级列表
const [classesResult] = useQuery({ query: ClassesQuery });
const classes: Class[] = classesResult.data?.classes ?? [];
const firstClassId = classes[0]?.id ?? "";
// classId 为空时回退到第一个班级
const targetClassId = classId || firstClassId;
const [analyticsResult] = useQuery({
query: GradeAnalyticsQuery,
variables: {
classId: targetClassId,
semester: semester || null,
examType: examType || null,
},
pause: !targetClassId,
});
const analytics: GradeAnalytics | undefined =
analyticsResult.data?.gradeAnalytics;
return (
<div className="px-10 py-10">
<header className="mb-8">
<p className="mb-2">
<Link
href="/grades"
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
</p>
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL GradeAnalyticsQuery · core-edu P7 MSW mock
</p>
</header>
<div className="rule-thin mb-8" />
{/* 筛选区 */}
<section className="mb-8">
<div className="grid grid-cols-3 gap-6">
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<select
value={targetClassId}
onChange={(e) => setClassId(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
>
<option value=""></option>
{classes.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<select
value={semester}
onChange={(e) => setSemester(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
>
{SEMESTERS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<select
value={examType}
onChange={(e) => setExamType(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
>
{EXAM_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
</div>
</section>
{classesResult.fetching ? (
<Loading lines={4} />
) : analyticsResult.fetching ? (
<Loading lines={8} />
) : analyticsResult.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{analyticsResult.error.message}
</p>
</div>
) : !analytics ? (
<Empty title="暂无分析数据" description="请选择班级后查看成绩分析" />
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 1. 趋势折线图 */}
<div className="p-4 border border-rule rounded-card">
<h2 className="text-base font-serif text-ink mb-3"></h2>
<p className="text-tiny text-ink-muted mb-3">
线线/
</p>
<TrendLineChart data={analytics.trend} />
</div>
{/* 2. 分数分布柱图 */}
<div className="p-4 border border-rule rounded-card">
<h2 className="text-base font-serif text-ink mb-3"></h2>
<p className="text-tiny text-ink-muted mb-3">
</p>
<DistributionBarChart data={analytics.distribution} />
</div>
{/* 3. 学科对比柱图 */}
<div className="p-4 border border-rule rounded-card">
<h2 className="text-base font-serif text-ink mb-3"></h2>
<p className="text-tiny text-ink-muted mb-3">
</p>
<SubjectComparisonChart data={analytics.subjectComparison} />
</div>
{/* 4. 班级对比柱图 */}
<div className="p-4 border border-rule rounded-card">
<h2 className="text-base font-serif text-ink mb-3">
</h2>
<p className="text-tiny text-ink-muted mb-3">
*
</p>
<ClassComparisonChart data={analytics.classComparison} />
</div>
{/* 5. 知识点掌握度雷达图 */}
<div className="p-4 border border-rule rounded-card lg:col-span-2">
<h2 className="text-base font-serif text-ink mb-3">
</h2>
<p className="text-tiny text-ink-muted mb-3">
</p>
<KnowledgeRadarChart data={analytics.knowledgeMastery} />
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,325 @@
"use client";
/**
* 成绩批量录入页
*
* 数据来源P7 扩展MSW mock
* - GraphQL GradeEntryQuery按 examId + classId 拉取学生成绩录入列表
* - GraphQL SaveGradeEntriesMutation批量保存成绩录入
* - GraphQL ClassesQuery + ClassExamsQuery班级 + 试卷下拉选项
*
* 试卷-班级年级匹配:仅展示同年级的试卷/班级(按 gradeId 过滤)
*
* 维护者ai13teacher-portal
*/
import { useState, useMemo } from "react";
import { useQuery, useMutation } from "urql";
import Link from "next/link";
import { Loading, Empty } from "@edu/ui-components";
import { ClassesQuery, ClassExamsQuery } from "@/lib/graphql";
import type { Class, ExamItem } from "@/lib/graphql";
import { GradeEntryQuery, SaveGradeEntriesMutation } from "@/lib/graphql-p7-grades";
import type { GradeEntryItem, SaveGradeEntryInput } from "@/lib/graphql-p7-grades";
/** 单行编辑状态 */
interface EntryRow {
studentId: string;
score: string;
feedback: string;
}
export default function GradeEntryPage(): React.ReactNode {
const [classId, setClassId] = useState("");
const [examId, setExamId] = useState("");
// 班级列表
const [classesResult] = useQuery({ query: ClassesQuery });
const classes: Class[] = classesResult.data?.classes ?? [];
// 选中班级后拉取试卷列表同年级过滤ClassExamsQuery 已按 classId 返回该班试卷)
const [examsResult] = useQuery({
query: ClassExamsQuery,
variables: { classId },
pause: !classId,
});
const exams: ExamItem[] = examsResult.data?.classExams ?? [];
// 成绩录入列表
const [entryResult, reexecuteEntry] = useQuery({
query: GradeEntryQuery,
variables: { examId, classId },
pause: !examId || !classId,
});
const entries: GradeEntryItem[] = entryResult.data?.gradeEntry ?? [];
// 批量保存 mutation
const [saveResult, saveEntries] = useMutation(SaveGradeEntriesMutation);
// 编辑行状态(按 studentId 索引)
const [rows, setRows] = useState<Record<string, EntryRow>>({});
const [submitting, setSubmitting] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [savedMsg, setSavedMsg] = useState<string | null>(null);
// 当 entries 变化时,初始化 rows
useMemo(() => {
const next: Record<string, EntryRow> = {};
entries.forEach((e) => {
next[e.studentId] = {
studentId: e.studentId,
score: e.score?.toString() ?? "",
feedback: e.feedback ?? "",
};
});
setRows(next);
setSavedMsg(null);
setSaveError(null);
}, [entries]);
// 年级匹配校验:选中班级后,同年级班级列表(用于校验提示)
const selectedClass = classes.find((c) => c.id === classId);
const sameGradeClassNames = useMemo(() => {
if (!selectedClass) return [];
return classes
.filter((c) => c.gradeId === selectedClass.gradeId)
.map((c) => c.name);
}, [classes, selectedClass]);
const handleSaveAll = async () => {
if (!examId || !classId) {
setSaveError("请先选择班级和试卷");
return;
}
const payload: SaveGradeEntryInput[] = [];
for (const e of entries) {
const row = rows[e.studentId];
if (!row) continue;
const scoreNum = Number(row.score);
if (row.score === "" || Number.isNaN(scoreNum)) continue;
payload.push({
studentId: e.studentId,
score: scoreNum,
feedback: row.feedback.trim() || null,
});
}
if (payload.length === 0) {
setSaveError("请至少填写一条有效成绩");
return;
}
setSubmitting(true);
setSaveError(null);
setSavedMsg(null);
// 乐观更新:本地展示保存中状态
const res = await saveEntries({
input: { examId, classId, entries: payload },
});
setSubmitting(false);
if (res.error) {
setSaveError(res.error.message);
return;
}
setSavedMsg(`已保存 ${res.data?.saveGradeEntries?.savedCount ?? payload.length} 条成绩`);
// 刷新录入列表
reexecuteEntry({ requestPolicy: "network-only" });
};
return (
<div className="px-10 py-10">
<header className="mb-8">
<p className="mb-2">
<Link
href="/grades"
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
</p>
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL GradeEntryQuery + SaveGradeEntriesMutation · core-edu P7 MSW mock
</p>
</header>
<div className="rule-thin mb-8" />
{/* 班级 + 试卷选择 */}
<section className="mb-8">
<div className="grid grid-cols-2 gap-6 mb-4">
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<select
value={classId}
onChange={(e) => {
setClassId(e.target.value);
setExamId("");
}}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
>
<option value=""></option>
{classes.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<select
value={examId}
onChange={(e) => setExamId(e.target.value)}
disabled={!classId}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent disabled:opacity-50"
>
<option value=""></option>
{exams.map((ex) => (
<option key={ex.id} value={ex.id}>
{ex.title}{ex.totalScore}
</option>
))}
</select>
</div>
</div>
{selectedClass && sameGradeClassNames.length > 0 && (
<p className="text-tiny text-ink-muted">
{sameGradeClassNames.join("、")} {selectedClass.gradeId}
</p>
)}
</section>
{!classId || !examId ? (
<Empty
title="请选择班级和试卷"
description="选择班级后选择试卷,再拉取学生成绩录入列表"
/>
) : entryResult.fetching ? (
<Loading lines={8} />
) : entryResult.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{entryResult.error.message}
</p>
</div>
) : entries.length === 0 ? (
<Empty title="暂无学生" description="该班级尚未导入学生名单" />
) : (
<section>
<div className="flex items-baseline justify-between mb-4">
<h2 className="text-xl font-serif text-ink">
<span className="ml-2 text-sm font-sans text-ink-muted">
{entries.length}
</span>
</h2>
<div className="flex items-center gap-3">
{saveError && (
<span className="text-tiny text-danger">{saveError}</span>
)}
{savedMsg && !submitting && (
<span className="text-tiny text-success">{savedMsg}</span>
)}
<button
type="button"
onClick={handleSaveAll}
disabled={submitting}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
>
{submitting ? "保存中..." : "保存全部"}
</button>
</div>
</div>
<div className="rule-thin mb-4" />
{/* 录入表格 */}
<div className="border border-rule rounded-card overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-subtle">
<tr>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted w-32">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
</tr>
</thead>
<tbody>
{entries.map((e) => {
const row = rows[e.studentId];
return (
<tr
key={e.studentId}
className="border-t border-rule"
>
<td className="px-4 py-3 font-serif text-ink">
{e.studentName}
</td>
<td className="px-4 py-3 font-mono text-tiny text-ink-muted">
{e.studentNo}
</td>
<td className="px-4 py-3">
<input
type="number"
min={0}
max={100}
value={row?.score ?? ""}
onChange={(ev) =>
setRows((prev) => ({
...prev,
[e.studentId]: {
studentId: e.studentId,
score: ev.target.value,
feedback: row?.feedback ?? "",
},
}))
}
className="w-24 px-2 py-1 bg-paper border border-rule rounded-button text-sm text-ink focus:outline-none focus:border-accent"
placeholder="0-100"
/>
</td>
<td className="px-4 py-3">
<input
type="text"
value={row?.feedback ?? ""}
onChange={(ev) =>
setRows((prev) => ({
...prev,
[e.studentId]: {
studentId: e.studentId,
score: row?.score ?? "",
feedback: ev.target.value,
},
}))
}
className="w-full px-2 py-1 bg-paper border border-rule rounded-button text-sm text-ink focus:outline-none focus:border-accent"
placeholder="可选:批改意见"
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{saveResult.data && !saveError && !submitting && (
<p className="mt-4 text-tiny text-success">
{saveResult.data.saveGradeEntries?.savedCount ?? 0}
</p>
)}
</section>
)}
</div>
);
}

View File

@@ -1,44 +1,286 @@
"use client";
/**
* Grades 页面 - 成绩查询
* Grades 页面 - 成绩查询 + 录入 + 分析图表
*
* 数据来源contract.md §2.4GraphQL ExamGradesQueryP3 扩展MSW mock
* 数据来源contract.md §2.4
* - GraphQL ExamGradesQueryP3 扩展MSW mock拉取成绩列表
* - GraphQL RecordGradeMutationP3 扩展MSW mock录入成绩
* - 通过 URL ?examId=xxx 指定考试
* - P2 阶段用 MSW mock 数据,待 teacher-bff P3 启用 core-edu 聚合
* - 成绩分析:平均分/最高分/最低分/及格率 + 纯 SVG 柱状图(不引入 rechartsP4 再迁移)
*
* 维护者ai13teacher-portal
*/
import { useState, Suspense } from "react";
import { useQuery } from "urql";
import { useState, useMemo, useRef, Suspense } from "react";
import { useQuery, useMutation } from "urql";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { Loading, Empty } from "@edu/ui-components";
import { ExamGradesQuery } from "@/lib/graphql";
import { ExamGradesQuery, RecordGradeMutation } from "@/lib/graphql";
import type { GradeItem } from "@/lib/graphql";
import { SaveGradeEntriesMutation } from "@/lib/graphql-p7-grades";
import type { SaveGradeEntryInput } from "@/lib/graphql-p7-grades";
/** 柱状图分段(成绩分布) */
interface ScoreBand {
label: string;
min: number;
max: number;
count: number;
}
/** 计算成绩统计 */
function computeStats(grades: GradeItem[]) {
if (grades.length === 0) {
return {
average: 0,
max: 0,
min: 0,
passRate: 0,
bands: [] as ScoreBand[],
};
}
const scores = grades.map((g) => g.score);
const sum = scores.reduce((acc, s) => acc + s, 0);
const average = sum / scores.length;
const max = Math.max(...scores);
const min = Math.min(...scores);
const passCount = scores.filter((s) => s >= 60).length;
const passRate = (passCount / scores.length) * 100;
// 分段0-59, 60-69, 70-79, 80-89, 90-100
const bandDefs = [
{ label: "0-59", min: 0, max: 59 },
{ label: "60-69", min: 60, max: 69 },
{ label: "70-79", min: 70, max: 79 },
{ label: "80-89", min: 80, max: 89 },
{ label: "90-100", min: 90, max: 100 },
];
const bands: ScoreBand[] = bandDefs.map((b) => ({
...b,
count: scores.filter((s) => s >= b.min && s <= b.max).length,
}));
return { average, max, min, passRate, bands };
}
function GradesContent() {
const searchParams = useSearchParams();
const initialExamId = searchParams.get("examId") ?? "";
const [examId, setExamId] = useState(initialExamId);
const [gradesResult] = useQuery({
const [gradesResult, reexecuteQuery] = useQuery({
query: ExamGradesQuery,
variables: { examId },
pause: !examId,
});
const grades = gradesResult.data?.examGrades ?? [];
const [recordResult, recordGrade] = useMutation(RecordGradeMutation);
// P7批量导入成绩 mutation
const [, saveEntries] = useMutation(SaveGradeEntriesMutation);
// 录入表单状态
const [showForm, setShowForm] = useState(false);
const [studentId, setStudentId] = useState("");
const [score, setScore] = useState("");
const [feedback, setFeedback] = useState("");
const [submitting, setSubmitting] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
// P7Excel 导入状态
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [importMsg, setImportMsg] = useState<string | null>(null);
const grades = (gradesResult.data?.examGrades ?? []) as GradeItem[];
// 成绩统计useMemo 避免重复计算)
const stats = useMemo(() => computeStats(grades), [grades]);
const maxBandCount = Math.max(1, ...stats.bands.map((b) => b.count));
const handleRecordGrade = async (e: React.FormEvent) => {
e.preventDefault();
setFormError(null);
if (!studentId.trim()) {
setFormError("请填写学生 ID");
return;
}
if (!examId) {
setFormError("请先指定考试 ID");
return;
}
const scoreNum = Number(score);
if (Number.isNaN(scoreNum)) {
setFormError("分数必须为数字");
return;
}
setSubmitting(true);
// 乐观更新:本地表单先清空,失败后保留输入由错误提示
const res = await recordGrade({
input: {
studentId: studentId.trim(),
examId,
score: scoreNum,
feedback: feedback.trim() || null,
},
});
setSubmitting(false);
if (res.error) {
setFormError(res.error.message);
return;
}
// 成功后刷新成绩列表
reexecuteQuery({ requestPolicy: "network-only" });
setStudentId("");
setScore("");
setFeedback("");
setShowForm(false);
};
// P7Excel 导入处理(解析 CSV/文本为 entries调用 SaveGradeEntriesMutation
const handleExcelImport = async (
e: React.ChangeEvent<HTMLInputElement>,
) => {
const file = e.target.files?.[0];
if (!file || !examId) {
setImportMsg(examId ? "请选择文件" : "请先指定考试 ID");
return;
}
setImportMsg(null);
try {
const text = await file.text();
const lines = text.split(/\r?\n/).filter((l) => l.trim());
const entries: SaveGradeEntryInput[] = [];
for (const line of lines) {
const parts = line.split(/[,\t]/).map((p) => p.trim());
const sid = parts[0];
const sc = parts[1];
if (!sid || !sc) continue;
const scoreNum = Number(sc);
if (Number.isNaN(scoreNum)) continue;
entries.push({ studentId: sid, score: scoreNum });
}
if (entries.length === 0) {
setImportMsg("未解析到有效数据(格式:学号,分数)");
return;
}
const res = await saveEntries({
input: { examId, classId: examId, entries },
});
if (res.error) {
setImportMsg(`导入失败:${res.error.message}`);
return;
}
setImportMsg(`已导入 ${entries.length} 条成绩`);
reexecuteQuery({ requestPolicy: "network-only" });
} catch {
setImportMsg("文件读取失败");
}
// 清空 file input 以便重复选择同一文件
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
// P7导出当前成绩列表为 CSV
const handleExportCsv = () => {
if (grades.length === 0) return;
const header = "学生ID,学生姓名,分数,反馈\n";
const rows = grades
.map(
(g) =>
`${g.studentId},${g.studentName},${g.score},${g.feedback ?? ""}`,
)
.join("\n");
const csv = "\uFEFF" + header + rows;
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `成绩_${examId}.csv`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="px-10 py-10">
<header className="mb-8">
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL ExamGradesQuery · core-edu P3 MSW mock
</p>
<header className="mb-8 flex items-baseline justify-between">
<div>
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL ExamGradesQuery + RecordGradeMutation · core-edu P3 MSW mock
</p>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleExportCsv}
disabled={grades.length === 0}
className="px-3 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle disabled:opacity-50"
>
CSV
</button>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={!examId}
className="px-3 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle disabled:opacity-50"
>
Excel
</button>
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.csv"
onChange={handleExcelImport}
className="hidden"
/>
{examId && (
<button
type="button"
onClick={() => setShowForm((v) => !v)}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
>
{showForm ? "收起录入" : "录入成绩"}
</button>
)}
</div>
</header>
{/* P7子模块导航 */}
<nav className="mb-6 flex items-center gap-4">
<Link
href="/grades/entry"
className="text-sm text-accent hover:opacity-70"
>
</Link>
<Link
href="/grades/stats"
className="text-sm text-accent hover:opacity-70"
>
</Link>
<Link
href="/grades/analytics"
className="text-sm text-accent hover:opacity-70"
>
</Link>
<Link
href="/grades/report-card"
className="text-sm text-accent hover:opacity-70"
>
</Link>
</nav>
{importMsg && (
<p className="mb-4 text-tiny text-ink-muted">{importMsg}</p>
)}
<div className="rule-thin mb-8" />
<div className="mb-6 flex items-baseline gap-3">
@@ -54,6 +296,77 @@ function GradesContent() {
/>
</div>
{/* 录入表单 */}
{showForm && examId && (
<section className="mb-8 p-4 border border-rule rounded-card bg-subtle">
<h2 className="text-base font-serif text-ink mb-3"></h2>
<form onSubmit={handleRecordGrade} className="grid grid-cols-3 gap-4">
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
ID <span className="text-danger">*</span>
</label>
<input
type="text"
value={studentId}
onChange={(e) => setStudentId(e.target.value)}
className="w-full px-3 py-2 bg-paper border border-rule rounded-button text-sm text-ink focus:outline-none"
placeholder="学生 UUID"
required
/>
</div>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
<span className="text-danger">*</span>
</label>
<input
type="number"
min={0}
max={100}
value={score}
onChange={(e) => setScore(e.target.value)}
className="w-full px-3 py-2 bg-paper border border-rule rounded-button text-sm text-ink focus:outline-none"
placeholder="0-100"
required
/>
</div>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<input
type="text"
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
className="w-full px-3 py-2 bg-paper border border-rule rounded-button text-sm text-ink focus:outline-none"
placeholder="可选:批改意见"
/>
</div>
<div className="col-span-3 flex items-center gap-3">
<button
type="submit"
disabled={submitting}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
>
{submitting ? "录入中..." : "保存成绩"}
</button>
<button
type="button"
onClick={() => setShowForm(false)}
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</button>
{formError && (
<span className="text-tiny text-danger">{formError}</span>
)}
{recordResult.data && !formError && !submitting && (
<span className="text-tiny text-success"></span>
)}
</div>
</form>
</section>
)}
{gradesResult.fetching ? (
<Loading lines={4} />
) : gradesResult.error ? (
@@ -67,35 +380,123 @@ function GradesContent() {
) : grades.length === 0 ? (
<Empty title="暂无成绩" description="该考试尚未录入成绩" />
) : (
<ul className="space-y-0">
{(grades as GradeItem[]).map((g) => (
<li
key={g.id}
className="py-4 grid grid-cols-12 gap-4 items-baseline border-b border-rule"
>
<div className="col-span-6">
<h3 className="text-lg font-serif text-ink">{g.studentName}</h3>
<p className="mt-1 text-tiny text-ink-muted">
ID: {g.studentId}
</p>
{g.feedback && (
<p className="mt-1 text-sm text-ink-muted">
: {g.feedback}
</p>
)}
<>
{/* 成绩分析 */}
<section className="mb-8">
<h2 className="text-xl font-serif text-ink mb-2"></h2>
<div className="rule-thin mb-4" />
<dl className="grid grid-cols-4 gap-4 mb-6">
<div className="p-3 border border-rule rounded-card">
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
</dt>
<dd className="mt-1 text-2xl font-serif text-accent">
{stats.average.toFixed(1)}
</dd>
</div>
<div className="col-span-2 text-2xl font-serif text-accent">
{g.score}
<div className="p-3 border border-rule rounded-card">
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
</dt>
<dd className="mt-1 text-2xl font-serif text-success">
{stats.max}
</dd>
</div>
<div className="col-span-2 text-tiny text-ink-muted">
{g.examId ? `考试: ${g.examId.slice(0, 8)}...` : "作业成绩"}
<div className="p-3 border border-rule rounded-card">
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
</dt>
<dd className="mt-1 text-2xl font-serif text-danger">
{stats.min}
</dd>
</div>
<div className="col-span-2 text-right text-tiny font-mono text-ink-muted">
{g.id.slice(0, 8)}...
<div className="p-3 border border-rule rounded-card">
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
</dt>
<dd className="mt-1 text-2xl font-serif text-ink">
{stats.passRate.toFixed(1)}%
</dd>
</div>
</li>
))}
</ul>
</dl>
{/* 柱状图(纯 SVG无外部依赖 */}
<div className="p-4 border border-rule rounded-card bg-surface">
<h3 className="text-sm font-serif text-ink mb-4"></h3>
<div className="flex items-end gap-4 h-40">
{stats.bands.map((band) => {
const heightPct = (band.count / maxBandCount) * 100;
return (
<div
key={band.label}
className="flex-1 flex flex-col items-center"
>
<div className="w-full flex flex-col items-center justify-end h-32">
<span className="text-tiny text-ink-muted mb-1">
{band.count}
</span>
<div
className="w-full bg-accent rounded-t-button"
style={{ height: `${heightPct}%`, minHeight: band.count > 0 ? "4px" : "0" }}
aria-label={`${band.label}${band.count}`}
/>
</div>
<span className="mt-2 text-tiny font-mono text-ink-muted">
{band.label}
</span>
</div>
);
})}
</div>
</div>
</section>
{/* 成绩列表 */}
<section>
<h2 className="text-xl font-serif text-ink mb-2"></h2>
<div className="rule-thin mb-4" />
<ul className="space-y-0">
{grades.map((g) => (
<li
key={g.id}
className="py-4 grid grid-cols-12 gap-4 items-baseline border-b border-rule"
>
<div className="col-span-6">
<h3 className="text-lg font-serif text-ink">
{g.studentName}
</h3>
<p className="mt-1 text-tiny text-ink-muted">
ID: {g.studentId}
</p>
{g.feedback && (
<p className="mt-1 text-sm text-ink-muted">
: {g.feedback}
</p>
)}
</div>
<div className="col-span-2 text-2xl font-serif text-accent">
{g.score}
</div>
<div className="col-span-2 text-tiny text-ink-muted">
{g.examId ? `考试: ${g.examId.slice(0, 8)}...` : "作业成绩"}
</div>
<div className="col-span-2 text-right">
<button
type="button"
onClick={handleExportCsv}
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
>
CSV
</button>
<p className="mt-1 text-tiny font-mono text-ink-muted">
{g.id.slice(0, 8)}...
</p>
</div>
</li>
))}
</ul>
</section>
</>
)}
</div>
);

View File

@@ -0,0 +1,280 @@
"use client";
/**
* 学生成绩报告卡
*
* 数据来源P7 扩展MSW mock
* - GraphQL ReportCardQuery学生成绩报告卡学年/学期/科目成绩 + 排名)
*
* 打印window.print()
* A4 纸质风格bg-paper/text-ink 字体
*
* 维护者ai13teacher-portal
*/
import { useState } from "react";
import { useQuery } from "urql";
import Link from "next/link";
import { Loading, Empty } from "@edu/ui-components";
import { ReportCardQuery } from "@/lib/graphql-p7-grades";
import type { ReportCard } from "@/lib/graphql-p7-grades";
import {
REPORT_CARD_STUDENTS,
REPORT_CARD_SEMESTERS,
} from "@/mocks/fixtures/grades-extended";
export default function ReportCardPage(): React.ReactNode {
const [studentId, setStudentId] = useState<string>(
REPORT_CARD_STUDENTS[0]?.id ?? "",
);
const [academicYear, setAcademicYear] = useState<string>("");
const [semester, setSemester] = useState<string>("");
// 学年选项(从 SEMESTERS 去重)
const yearOptions = Array.from(
new Set(REPORT_CARD_SEMESTERS.map((s) => s.year)),
);
const [result] = useQuery({
query: ReportCardQuery,
variables: {
studentId,
academicYear: academicYear || null,
semester: semester || null,
},
pause: !studentId,
});
const card: ReportCard | undefined = result.data?.reportCard;
/** 触发浏览器打印 */
function handlePrint() {
window.print();
}
return (
<div className="px-10 py-10">
<header className="mb-8 flex items-baseline justify-between print:hidden">
<div>
<p className="mb-2">
<Link
href="/grades"
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
</p>
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL ReportCardQuery · core-edu P7 MSW mock
</p>
</div>
{card && (
<button
type="button"
onClick={handlePrint}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
>
</button>
)}
</header>
<div className="rule-thin mb-8 print:hidden" />
{/* 筛选区 */}
<section className="mb-8 print:hidden">
<div className="grid grid-cols-3 gap-6">
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<select
value={studentId}
onChange={(e) => setStudentId(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
>
{REPORT_CARD_STUDENTS.map((s) => (
<option key={s.id} value={s.id}>
{s.name}{s.no}
</option>
))}
</select>
</div>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<select
value={academicYear}
onChange={(e) => setAcademicYear(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
>
<option value=""></option>
{yearOptions.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
</div>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<select
value={semester}
onChange={(e) => setSemester(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
>
<option value=""></option>
<option value="1"></option>
<option value="2"></option>
</select>
</div>
</div>
</section>
{result.fetching ? (
<Loading lines={6} />
) : result.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{result.error.message}
</p>
</div>
) : !card ? (
<Empty title="暂无报告卡" description="该学生未找到报告卡数据" />
) : (
// A4 纸质风格报告卡
<article className="max-w-3xl mx-auto bg-paper border border-rule rounded-card p-10 shadow-sm">
{/* 报告头 */}
<header className="text-center mb-8 pb-6 border-b-2 border-accent">
<h1 className="text-2xl font-serif text-ink">
{card.className} ·
</h1>
<p className="mt-2 text-tiny font-mono text-ink-muted">
{card.academicYear} {card.semester}
</p>
</header>
{/* 学生信息 */}
<section className="mb-8">
<dl className="grid grid-cols-3 gap-4 text-sm">
<div>
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
</dt>
<dd className="mt-1 font-serif text-ink text-lg">
{card.studentName}
</dd>
</div>
<div>
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
</dt>
<dd className="mt-1 font-mono text-ink">{card.studentNo}</dd>
</div>
<div>
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
</dt>
<dd className="mt-1 font-serif text-ink">{card.className}</dd>
</div>
</dl>
</section>
{/* 学科成绩表格 */}
<section className="mb-8">
<table className="w-full text-sm border border-rule">
<thead className="bg-subtle">
<tr>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted border-b border-rule">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted border-b border-rule w-24">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted border-b border-rule w-24">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted border-b border-rule">
</th>
</tr>
</thead>
<tbody>
{card.subjects.map((s) => (
<tr key={s.subject} className="border-b border-rule">
<td className="px-4 py-3 font-serif text-ink">
{s.subject}
</td>
<td className="px-4 py-3 font-serif text-lg text-accent">
{s.score}
</td>
<td className="px-4 py-3 font-mono text-tiny text-ink-muted">
{s.rank}
</td>
<td className="px-4 py-3 font-sans text-sm text-ink">
{s.teacherName}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="bg-subtle">
<td className="px-4 py-3 font-serif text-ink"></td>
<td className="px-4 py-3 font-serif text-xl text-accent">
{card.totalScore}
</td>
<td className="px-4 py-3" colSpan={2} />
</tr>
</tfoot>
</table>
</section>
{/* 排名信息 */}
<section className="mb-8 grid grid-cols-2 gap-6">
<div className="p-4 border border-rule rounded-card">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
</p>
<p className="mt-2 text-2xl font-serif text-accent">
{card.classRank}
<span className="ml-1 text-sm font-sans text-ink-muted">
/ {card.classSize}
</span>
</p>
</div>
<div className="p-4 border border-rule rounded-card">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
</p>
<p className="mt-2 text-2xl font-serif text-ink">
{card.gradeRank}
<span className="ml-1 text-sm font-sans text-ink-muted">
/ {card.gradeSize}
</span>
</p>
</div>
</section>
{/* 班主任评语 */}
<section className="mb-6">
<h2 className="text-base font-serif text-ink mb-3"></h2>
<div className="rule-thin mb-4" />
<p className="text-sm leading-relaxed text-ink p-4 bg-subtle rounded-card font-sans">
{card.teacherComment}
</p>
</section>
{/* 签名区 */}
<footer className="mt-10 pt-6 border-t border-rule flex justify-between text-sm text-ink-muted font-sans">
<span>________________</span>
<span>{new Date().toLocaleDateString("zh-CN")}</span>
</footer>
</article>
)}
</div>
);
}

View File

@@ -0,0 +1,285 @@
"use client";
/**
* 班级成绩统计页
*
* 数据来源P7 扩展MSW mock
* - GraphQL GradeStatsQuery班级成绩统计avg/median/max/min/passRate/stdDev/rankings
* - GraphQL ClassesQuery班级下拉选项
*
* 导出 CSVBlob + URL.createObjectURL
*
* 维护者ai13teacher-portal
*/
import { useState } from "react";
import { useQuery } from "urql";
import Link from "next/link";
import { Loading, Empty } from "@edu/ui-components";
import { ClassesQuery } from "@/lib/graphql";
import type { Class } from "@/lib/graphql";
import { GradeStatsQuery } from "@/lib/graphql-p7-grades";
import type { GradeStats } from "@/lib/graphql-p7-grades";
import { SUBJECTS } from "@/mocks/fixtures/grades-extended";
/** 等级颜色映射(语义令牌) */
function levelColor(level: string): string {
switch (level) {
case "A":
return "var(--color-success)";
case "B":
return "var(--color-accent)";
case "C":
return "var(--color-ink)";
case "D":
return "var(--color-warning)";
case "E":
return "var(--color-danger)";
default:
return "var(--color-ink-muted)";
}
}
export default function GradeStatsPage(): React.ReactNode {
const [classId, setClassId] = useState("");
const [subject, setSubject] = useState<string>(SUBJECTS[1] ?? "数学");
// 班级列表
const [classesResult] = useQuery({ query: ClassesQuery });
const classes: Class[] = classesResult.data?.classes ?? [];
const firstClassId = classes[0]?.id ?? "";
// classId 为空时回退到第一个班级(用户未选择时)
const targetClassId = classId || firstClassId;
const [statsResult] = useQuery({
query: GradeStatsQuery,
variables: { classId: targetClassId, subject },
pause: !targetClassId,
});
const stats: GradeStats | undefined = statsResult.data?.gradeStats;
/** 导出 CSV排名表 */
function exportCsv() {
if (!stats) return;
const header = "排名,学号,姓名,总分,等级\n";
const rows = stats.rankings
.map(
(r) =>
`${r.rank},${r.studentNo},${r.studentName},${r.totalScore},${r.level}`,
)
.join("\n");
// BOM 头确保 Excel 正确识别 UTF-8
const csv = "\uFEFF" + header + rows;
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `成绩统计_${stats.className}_${stats.subject}.csv`;
a.click();
URL.revokeObjectURL(url);
}
return (
<div className="px-10 py-10">
<header className="mb-8 flex items-baseline justify-between">
<div>
<p className="mb-2">
<Link
href="/grades"
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
</p>
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL GradeStatsQuery · core-edu P7 MSW mock
</p>
</div>
{stats && (
<button
type="button"
onClick={exportCsv}
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle"
>
CSV
</button>
)}
</header>
<div className="rule-thin mb-8" />
{/* 班级 + 学科筛选 */}
<section className="mb-8">
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<select
value={targetClassId}
onChange={(e) => setClassId(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
>
<option value=""></option>
{classes.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<select
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
>
{SUBJECTS.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
</div>
</section>
{classesResult.fetching ? (
<Loading lines={4} />
) : statsResult.fetching ? (
<Loading lines={6} />
) : statsResult.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{statsResult.error.message}
</p>
</div>
) : !stats ? (
<Empty title="暂无统计数据" description="请选择班级和学科" />
) : (
<>
{/* 5 个统计卡片 */}
<section className="mb-10">
<h2 className="text-xl font-serif text-ink mb-4">
{stats.className} · {stats.subject}
</h2>
<div className="rule-thin mb-6" />
<dl className="grid grid-cols-5 gap-4">
<div className="p-4 border border-rule rounded-card">
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
</dt>
<dd className="mt-2 text-3xl font-serif text-accent">
{stats.avg.toFixed(1)}
</dd>
</div>
<div className="p-4 border border-rule rounded-card">
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
</dt>
<dd className="mt-2 text-3xl font-serif text-ink">
{stats.median.toFixed(1)}
</dd>
</div>
<div className="p-4 border border-rule rounded-card">
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
</dt>
<dd className="mt-2 text-3xl font-serif text-success">
{stats.max}
</dd>
</div>
<div className="p-4 border border-rule rounded-card">
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
</dt>
<dd className="mt-2 text-3xl font-serif text-danger">
{stats.min}
</dd>
</div>
<div className="p-4 border border-rule rounded-card">
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
</dt>
<dd className="mt-2 text-3xl font-serif text-ink">
{stats.passRate.toFixed(1)}%
</dd>
</div>
</dl>
<p className="mt-4 text-tiny text-ink-muted">
σ = {stats.stdDev.toFixed(1)} · {stats.totalCount}
</p>
</section>
{/* 排名表格 */}
<section>
<h2 className="text-xl font-serif text-ink mb-4"></h2>
<div className="rule-thin mb-4" />
<div className="border border-rule rounded-card overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-subtle">
<tr>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted w-20">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted w-32">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted w-24">
</th>
</tr>
</thead>
<tbody>
{stats.rankings.map((r) => (
<tr key={r.studentId} className="border-t border-rule">
<td className="px-4 py-3 font-serif text-ink">
{r.rank <= 3 ? (
<span className="text-accent">{r.rank}</span>
) : (
r.rank
)}
</td>
<td className="px-4 py-3 font-mono text-tiny text-ink-muted">
{r.studentNo}
</td>
<td className="px-4 py-3 font-serif text-ink">
{r.studentName}
</td>
<td className="px-4 py-3 font-serif text-lg text-ink">
{r.totalScore}
</td>
<td className="px-4 py-3">
<span
className="inline-block px-2 py-0.5 rounded-button text-tiny font-mono"
style={{
color: levelColor(r.level),
border: `1px solid ${levelColor(r.level)}`,
}}
>
{r.level}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,319 @@
"use client";
/**
* 作业详情页 + 批改界面
*
* 数据来源contract.md §2.4
* - GraphQL HomeworkDetailQueryP3 扩展MSW mock获取作业详情 + 提交列表
* - GraphQL RecordGradeMutationP3 扩展MSW mock录入批改分数
* - 动态路由 /homework/[id]
*
* 维护者ai13teacher-portal
*/
import { useState } from "react";
import { useQuery, useMutation } from "urql";
import { useParams } from "next/navigation";
import Link from "next/link";
import { Loading, Empty } from "@edu/ui-components";
import { HomeworkDetailQuery, RecordGradeMutation } from "@/lib/graphql";
import type { HomeworkSubmission } from "@/lib/graphql";
/** 提交状态中文映射 */
const SUBMISSION_STATUS_LABEL: Record<string, string> = {
NOT_SUBMITTED: "未提交",
SUBMITTED: "已提交",
GRADED: "已批改",
};
interface GradingFormState {
[submissionId: string]: {
score: string;
feedback: string;
};
}
export default function HomeworkDetailPage() {
const params = useParams<{ id: string }>();
const homeworkId = params?.id ?? "";
const [result, reexecuteQuery] = useQuery({
query: HomeworkDetailQuery,
variables: { id: homeworkId },
pause: !homeworkId,
});
const [recordGradeResult, recordGrade] = useMutation(RecordGradeMutation);
// 批改表单状态:按 submissionId 索引
const [gradingForms, setGradingForms] = useState<GradingFormState>({});
const [activeSubmissionId, setActiveSubmissionId] = useState<string | null>(
null,
);
const [submittingId, setSubmittingId] = useState<string | null>(null);
const [errorId, setErrorId] = useState<string | null>(null);
const openGradingForm = (sub: HomeworkSubmission) => {
setActiveSubmissionId(sub.id);
setGradingForms((prev) => ({
...prev,
[sub.id]: {
score: sub.score?.toString() ?? "",
feedback: sub.feedback ?? "",
},
}));
};
const closeGradingForm = () => {
setActiveSubmissionId(null);
};
const handleRecordGrade = async (sub: HomeworkSubmission) => {
const form = gradingForms[sub.id];
if (!form) return;
const score = Number(form.score);
if (Number.isNaN(score)) {
setErrorId(sub.id);
return;
}
setSubmittingId(sub.id);
setErrorId(null);
// 乐观更新:本地表单先展示新值;失败回滚由错误信息提示
const res = await recordGrade({
input: {
studentId: sub.studentId,
homeworkId: homeworkId,
score,
feedback: form.feedback.trim() || null,
},
});
setSubmittingId(null);
if (res.error) {
setErrorId(sub.id);
return;
}
// 成功后重新拉取详情刷新提交列表
reexecuteQuery({ requestPolicy: "network-only" });
closeGradingForm();
};
return (
<div className="px-10 py-10">
<header className="mb-8">
<p className="mb-2">
<Link
href={`/homework?classId=${result.data?.homeworkDetail?.classId ?? ""}`}
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
</p>
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL HomeworkDetailQuery · core-edu P3 MSW mock
</p>
</header>
<div className="rule-thin mb-8" />
{result.fetching ? (
<Loading lines={6} />
) : result.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{result.error.message}
</p>
</div>
) : !result.data?.homeworkDetail ? (
<Empty title="未找到作业" description="该作业不存在或已被删除" />
) : (
<section className="max-w-4xl">
{/* 基本信息 */}
<div className="mb-10">
<h2 className="text-xl font-serif text-ink mb-2">
{result.data.homeworkDetail.title}
</h2>
{result.data.homeworkDetail.description && (
<p className="mt-1 text-sm text-ink-muted">
{result.data.homeworkDetail.description}
</p>
)}
<div className="rule-thin mb-4 mt-3" />
<dl className="grid grid-cols-2 gap-4 text-sm">
<div>
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
</dt>
<dd className="mt-1 text-ink">
{new Date(result.data.homeworkDetail.dueDate).toLocaleString("zh-CN")}
</dd>
</div>
<div>
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
</dt>
<dd className="mt-1 text-ink">
{SUBMISSION_STATUS_LABEL[result.data.homeworkDetail.status] ?? result.data.homeworkDetail.status}
</dd>
</div>
</dl>
</div>
{/* 学生提交列表 */}
<div>
<div className="flex items-baseline justify-between mb-4">
<h3 className="text-xl font-serif text-ink">
<span className="ml-2 text-sm font-sans text-ink-muted">
{result.data.homeworkDetail.submissions.length}
</span>
</h3>
</div>
<div className="rule-thin mb-6" />
{result.data.homeworkDetail.submissions.length === 0 ? (
<Empty title="暂无提交" description="学生尚未提交作业" />
) : (
<ul className="space-y-0">
{result.data.homeworkDetail.submissions.map(
(sub: HomeworkSubmission) => {
const isActive = activeSubmissionId === sub.id;
const form = gradingForms[sub.id];
const hasError = errorId === sub.id;
return (
<li
key={sub.id}
className="py-4 border-b border-rule"
>
<div className="grid grid-cols-12 gap-4 items-baseline">
<div className="col-span-5">
<h4 className="text-base font-serif text-ink">
{sub.studentName}
</h4>
<p className="mt-1 text-tiny text-ink-muted">
ID: {sub.studentId}
</p>
{sub.submittedAt && (
<p className="mt-1 text-tiny text-ink-muted">
{new Date(sub.submittedAt).toLocaleString("zh-CN")}
</p>
)}
</div>
<div className="col-span-2 text-sm text-ink">
{sub.score !== null ? `${sub.score}` : "—"}
</div>
<div className="col-span-2 text-tiny text-ink-muted">
{SUBMISSION_STATUS_LABEL[sub.status] ?? sub.status}
</div>
<div className="col-span-3 text-right">
{sub.status !== "NOT_SUBMITTED" && (
<button
type="button"
onClick={() =>
isActive
? closeGradingForm()
: openGradingForm(sub)
}
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
>
{isActive ? "收起" : "批改"}
</button>
)}
</div>
</div>
{/* 已有反馈展示 */}
{sub.feedback && !isActive && (
<p className="mt-2 text-sm text-ink-muted pl-1">
{sub.feedback}
</p>
)}
{/* 批改表单 */}
{isActive && form && (
<div className="mt-4 p-4 border border-rule rounded-card bg-subtle">
<div className="grid grid-cols-3 gap-4 mb-3">
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
<span className="text-danger">*</span>
</label>
<input
type="number"
min={0}
value={form.score}
onChange={(e) =>
setGradingForms((prev) => ({
...prev,
[sub.id]: {
...form,
score: e.target.value,
},
}))
}
className="w-full px-3 py-2 bg-paper border border-rule rounded-button text-sm text-ink focus:outline-none"
placeholder="0-100"
/>
</div>
<div className="col-span-2">
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<input
type="text"
value={form.feedback}
onChange={(e) =>
setGradingForms((prev) => ({
...prev,
[sub.id]: {
...form,
feedback: e.target.value,
},
}))
}
className="w-full px-3 py-2 bg-paper border border-rule rounded-button text-sm text-ink focus:outline-none"
placeholder="可选:批改意见"
/>
</div>
</div>
{hasError && (
<p className="text-tiny text-danger mb-2">
{recordGradeResult.error?.message ?? "录入失败"}
</p>
)}
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => handleRecordGrade(sub)}
disabled={submittingId === sub.id}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
>
{submittingId === sub.id
? "录入中..."
: "保存批改"}
</button>
<button
type="button"
onClick={closeGradingForm}
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</button>
{recordGradeResult.data && !hasError && submittingId === null && (
<span className="text-tiny text-success"></span>
)}
</div>
</div>
)}
</li>
);
},
)}
</ul>
)}
</div>
</section>
)}
</div>
);
}

View File

@@ -0,0 +1,267 @@
"use client";
/**
* 作业批量批改视图
*
* 数据来源P7 扩展MSW mock
* - GraphQL HomeworkAssignmentSubmissionsQuery按作业 ID 拉取所有提交列表
* - GraphQL AiBatchGradingQuery批量自动评分建议
*
* 顶部统计:已批/已提交/未提交
* 表格:学生 | 提交时间 | 分数 | 状态 | "批改" 链接
* "批量自动评分" 按钮触发 AI 评分建议
*
* 维护者ai13teacher-portal
*/
import { useState } from "react";
import { useQuery, useMutation } from "urql";
import { useParams } from "next/navigation";
import Link from "next/link";
import { Loading, Empty } from "@edu/ui-components";
import {
HomeworkAssignmentSubmissionsQuery,
AiBatchGradingQuery,
GradeSubmissionMutation,
} from "@/lib/graphql-p7-grades";
import type {
HomeworkSubmissionDetail,
AiBatchGradingItem,
} from "@/lib/graphql-p7-grades";
/** 状态中文映射 */
const STATUS_LABEL: Record<string, string> = {
SUBMITTED: "已提交",
GRADED: "已批改",
};
export default function AssignmentSubmissionsPage(): React.ReactNode {
const params = useParams<{ id: string }>();
const homeworkId = params?.id ?? "";
const [result, reexecuteQuery] = useQuery({
query: HomeworkAssignmentSubmissionsQuery,
variables: { homeworkId },
pause: !homeworkId,
});
// AI 批量评分建议(点击按钮后触发)
const [aiResult, executeAi] = useQuery({
query: AiBatchGradingQuery,
variables: { homeworkId },
pause: true, // 手动触发
});
const [, gradeSubmission] = useMutation(GradeSubmissionMutation);
const [batching, setBatching] = useState(false);
const [batchMsg, setBatchMsg] = useState<string | null>(null);
const submissions: HomeworkSubmissionDetail[] =
result.data?.homeworkAssignmentSubmissions ?? [];
const aiSuggestions: AiBatchGradingItem[] =
aiResult.data?.aiBatchGrading ?? [];
// 统计
const totalCount = submissions.length;
const gradedCount = submissions.filter((s) => s.status === "GRADED").length;
const submittedCount = submissions.filter(
(s) => s.status === "SUBMITTED",
).length;
/** 批量自动评分:触发 AI 建议查询urql 响应式更新,建议加载后显示"应用 AI"按钮) */
const handleBatchAutoGrade = () => {
if (submissions.length === 0) return;
setBatching(true);
setBatchMsg(null);
executeAi();
setBatchMsg("AI 评分建议已生成,请逐份复核后点击「应用 AI」");
setBatching(false);
};
/** 应用 AI 建议到单份提交 */
const handleApplyAiSuggestion = async (
sub: HomeworkSubmissionDetail,
) => {
const suggestion = aiSuggestions.find(
(a) => a.submissionId === sub.submissionId,
);
if (!suggestion) {
setBatchMsg("未找到该提交的 AI 建议");
return;
}
const res = await gradeSubmission({
input: {
submissionId: sub.submissionId,
score: suggestion.suggestedScore,
feedback: suggestion.suggestion,
aiAssisted: true,
},
});
if (res.error) {
setBatchMsg(`保存失败:${res.error.message}`);
return;
}
setBatchMsg(`已应用 AI 建议到 ${sub.studentName}`);
reexecuteQuery({ requestPolicy: "network-only" });
};
return (
<div className="px-10 py-10">
<header className="mb-8">
<p className="mb-2">
<Link
href="/homework/submissions"
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
</p>
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL HomeworkAssignmentSubmissionsQuery · core-edu P7 MSW mock
</p>
</header>
<div className="rule-thin mb-8" />
{/* 顶部统计 */}
<section className="mb-8">
<div className="grid grid-cols-3 gap-4 mb-4">
<div className="p-4 border border-rule rounded-card">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
</p>
<p className="mt-2 text-2xl font-serif text-success">
{gradedCount}
</p>
</div>
<div className="p-4 border border-rule rounded-card">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
</p>
<p className="mt-2 text-2xl font-serif text-warning">
{submittedCount}
</p>
</div>
<div className="p-4 border border-rule rounded-card">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
</p>
<p className="mt-2 text-2xl font-serif text-ink">{totalCount}</p>
</div>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleBatchAutoGrade}
disabled={batching || submissions.length === 0}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
>
{batching ? "生成中..." : "批量自动评分"}
</button>
{batchMsg && (
<span className="text-tiny text-ink-muted">{batchMsg}</span>
)}
</div>
</section>
{result.fetching ? (
<Loading lines={8} />
) : result.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{result.error.message}
</p>
</div>
) : submissions.length === 0 ? (
<Empty title="暂无提交" description="该作业尚未有学生提交" />
) : (
<section>
<div className="border border-rule rounded-card overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-subtle">
<tr>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted w-24">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted w-24">
</th>
<th className="px-4 py-3 text-right text-tiny uppercase tracking-wide text-ink-muted">
</th>
</tr>
</thead>
<tbody>
{submissions.map((sub) => {
const aiSuggestion = aiSuggestions.find(
(a) => a.submissionId === sub.submissionId,
);
return (
<tr key={sub.submissionId} className="border-t border-rule">
<td className="px-4 py-3 font-serif text-ink">
{sub.studentName}
</td>
<td className="px-4 py-3 font-mono text-tiny text-ink-muted">
{sub.studentNo}
</td>
<td className="px-4 py-3 font-mono text-tiny text-ink-muted">
{sub.submittedAt
? new Date(sub.submittedAt).toLocaleString("zh-CN")
: "—"}
</td>
<td className="px-4 py-3 font-serif text-accent">
{sub.totalScore !== null ? sub.totalScore : "—"}
</td>
<td className="px-4 py-3">
<span
className="text-tiny"
style={{
color:
sub.status === "GRADED"
? "var(--color-success)"
: "var(--color-warning)",
}}
>
{STATUS_LABEL[sub.status] ?? sub.status}
</span>
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-3">
{aiSuggestion && sub.status === "SUBMITTED" && (
<button
type="button"
onClick={() => handleApplyAiSuggestion(sub)}
className="text-tiny uppercase tracking-wide text-warning hover:opacity-70"
>
AI
</button>
)}
<Link
href={`/homework/submissions/${sub.submissionId}`}
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
>
</Link>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
)}
</div>
);
}

View File

@@ -0,0 +1,210 @@
"use client";
/**
* 布置作业表单页
*
* 数据来源contract.md §2.4GraphQL AssignHomeworkMutationP3 扩展MSW mock
* - 通过 URL ?classId=xxx 预填班级 ID
* - 表单字段:班级选择、标题、描述、截止日期
* - 乐观更新:成功后跳回列表;失败展示错误并保留输入
*
* 维护者ai13teacher-portal
*/
import { useState, Suspense } from "react";
import { useMutation, useQuery } from "urql";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Loading } from "@edu/ui-components";
import { AssignHomeworkMutation, ClassesQuery } from "@/lib/graphql";
import type { Class } from "@/lib/graphql";
function NewHomeworkContent() {
const router = useRouter();
const searchParams = useSearchParams();
const presetClassId = searchParams.get("classId") ?? "";
const [classesResult] = useQuery({ query: ClassesQuery });
const [assignResult, assignHomework] = useMutation(AssignHomeworkMutation);
const [classId, setClassId] = useState(presetClassId);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [dueDate, setDueDate] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!classId) {
setError("请选择班级");
return;
}
if (!title.trim()) {
setError("请填写作业标题");
return;
}
if (!dueDate) {
setError("请选择截止日期");
return;
}
setSubmitting(true);
const res = await assignHomework({
input: {
classId,
title: title.trim(),
description: description.trim() || null,
dueDate: new Date(dueDate).toISOString(),
},
});
setSubmitting(false);
if (res.error) {
setError(res.error.message);
return;
}
router.push(`/homework?classId=${classId}`);
};
if (classesResult.fetching) {
return (
<div className="px-10 py-10">
<Loading lines={4} />
</div>
);
}
const classes = (classesResult.data?.classes ?? []) as Class[];
return (
<div className="px-10 py-10">
<header className="mb-8">
<p className="mb-2">
<Link
href={`/homework?classId=${classId}`}
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
</p>
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL AssignHomeworkMutation · core-edu P3 MSW mock
</p>
</header>
<div className="rule-thin mb-8" />
<section className="max-w-2xl">
<form onSubmit={handleSubmit} className="space-y-6">
{/* 班级选择 */}
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
<span className="text-danger">*</span>
</label>
<select
value={classId}
onChange={(e) => setClassId(e.target.value)}
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
required
>
<option value=""></option>
{classes.map((cls) => (
<option key={cls.id} value={cls.id}>
{cls.name}
</option>
))}
</select>
</div>
{/* 标题 */}
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
<span className="text-danger">*</span>
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm text-ink focus:outline-none focus:border-b-2"
placeholder="例如:函数练习题"
required
/>
</div>
{/* 描述 */}
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
placeholder="作业要求、范围、注意事项等"
/>
</div>
{/* 截止日期 */}
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
<span className="text-danger">*</span>
</label>
<input
type="datetime-local"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
required
/>
</div>
{/* 错误提示 */}
{error && (
<div className="mark-left py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">{error}</p>
</div>
)}
{/* 操作按钮 */}
<div className="flex items-center gap-4 pt-4">
<button
type="submit"
disabled={submitting}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
>
{submitting ? "提交中..." : "布置作业"}
</button>
<Link
href={`/homework?classId=${classId}`}
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
{assignResult.data && (
<span className="text-tiny text-success"></span>
)}
</div>
</form>
</section>
</div>
);
}
export default function NewHomeworkPage() {
return (
<Suspense
fallback={
<div className="px-10 py-10">
<Loading lines={3} />
</div>
}
>
<NewHomeworkContent />
</Suspense>
);
}

View File

@@ -6,12 +6,14 @@
* 数据来源contract.md §2.4GraphQL ClassHomeworkQueryP3 扩展MSW mock
* - 通过 URL ?classId=xxx 指定班级
* - P2 阶段用 MSW mock 数据,待 teacher-bff P3 启用 core-edu 聚合
* - P3列表项可点击进入详情页批改界面顶部提供"布置作业"入口
*
* 维护者ai13teacher-portal
*/
import { useQuery } from "urql";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { Suspense } from "react";
import { Loading, Empty } from "@edu/ui-components";
import { ClassHomeworkQuery, ClassQuery } from "@/lib/graphql";
@@ -46,13 +48,21 @@ function HomeworkContent() {
return (
<div className="px-10 py-10">
<header className="mb-8">
<h1 className="text-3xl font-serif text-ink">
{cls ? `${cls.name} - 作业管理` : "作业管理"}
</h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL ClassHomeworkQuery · core-edu P3 MSW mock
</p>
<header className="mb-8 flex items-baseline justify-between">
<div>
<h1 className="text-3xl font-serif text-ink">
{cls ? `${cls.name} - 作业管理` : "作业管理"}
</h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL ClassHomeworkQuery · core-edu P3 MSW mock
</p>
</div>
<Link
href={`/homework/new?classId=${classId}`}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
>
</Link>
</header>
<div className="rule-thin mb-8" />
@@ -66,16 +76,31 @@ function HomeworkContent() {
</p>
</div>
) : items.length === 0 ? (
<Empty title="暂无作业" description="该班级尚未布置作业" />
<Empty
title="暂无作业"
description="该班级尚未布置作业"
action={
<Link
href={`/homework/new?classId=${classId}`}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
>
</Link>
}
/>
) : (
<ul className="space-y-0">
{(items as HomeworkItem[]).map((hw) => (
<li
key={hw.id}
className="py-4 grid grid-cols-12 gap-4 items-baseline border-b border-rule"
className="py-4 grid grid-cols-12 gap-4 items-baseline border-b border-rule hover:bg-subtle"
>
<div className="col-span-7">
<h3 className="text-lg font-serif text-ink">{hw.title}</h3>
<Link href={`/homework/${hw.id}`}>
<h3 className="text-lg font-serif text-ink hover:text-accent">
{hw.title}
</h3>
</Link>
{hw.description && (
<p className="mt-1 text-sm text-ink-muted">
{hw.description}
@@ -88,8 +113,13 @@ function HomeworkContent() {
<div className="col-span-3 text-tiny text-ink-muted">
: {hw.status}
</div>
<div className="col-span-2 text-right text-tiny font-mono text-ink-muted">
{hw.id.slice(0, 8)}...
<div className="col-span-2 text-right text-tiny">
<Link
href={`/homework/${hw.id}`}
className="font-mono text-ink-muted hover:text-accent"
>
</Link>
</div>
</li>
))}

View File

@@ -0,0 +1,486 @@
"use client";
/**
* 单份提交批改页(核心)
*
* 数据来源P7 扩展MSW mock
* - GraphQL HomeworkAssignmentSubmissionsQuery按作业 ID 拉取所有提交(用于定位当前 + 上一份/下一份)
* - GraphQL GradeSubmissionMutation保存批改
*
* 布局:
* - 顶部:学生信息 + 提交时间 + 状态
* - 左侧:题目作答展示(题干 + 学生作答 + 正确答案)
* - 右侧:评分表单(每题分数 + 反馈 + AI 评分建议按钮)
* - 底部:上一份/下一份导航
*
* AI 辅助按钮mock 流式返回评分建议逐字显示setInterval 模拟 SSE
*
* 维护者ai13teacher-portal
*/
import { useState, useEffect, useMemo } from "react";
import { useQuery, useMutation } from "urql";
import { useParams } from "next/navigation";
import Link from "next/link";
import { Loading, Empty } from "@edu/ui-components";
import { HomeworkAssignmentSubmissionsQuery } from "@/lib/graphql-p7-grades";
import { GradeSubmissionMutation } from "@/lib/graphql-p7-grades";
import type { HomeworkSubmissionDetail, SubmissionAnswer } from "@/lib/graphql-p7-grades";
/** 题型中文映射 */
const QUESTION_TYPE_LABEL: Record<string, string> = {
SINGLE_CHOICE: "单选题",
MULTIPLE_CHOICE: "多选题",
SHORT_ANSWER: "简答题",
ESSAY: "论述题",
};
/** 状态中文映射 */
const STATUS_LABEL: Record<string, string> = {
SUBMITTED: "已提交",
GRADED: "已批改",
};
/** 每题评分表单状态 */
interface QuestionGrade {
score: string;
feedback: string;
}
/** 从 submissionId 解析 homeworkId规约${homeworkId}-sub-XXX */
function parseHomeworkId(submissionId: string): string {
const match = submissionId.match(/^(.+)-sub-\d+$/);
return match?.[1] ?? "";
}
export default function SubmissionGradingPage(): React.ReactNode {
const params = useParams<{ submissionId: string }>();
const submissionId = params?.submissionId ?? "";
const homeworkId = parseHomeworkId(submissionId);
// 拉取该作业所有提交(用于定位当前 + 导航)
const [result, reexecuteQuery] = useQuery({
query: HomeworkAssignmentSubmissionsQuery,
variables: { homeworkId },
pause: !homeworkId,
});
const [, gradeSubmission] = useMutation(GradeSubmissionMutation);
const allSubmissions: HomeworkSubmissionDetail[] =
result.data?.homeworkAssignmentSubmissions ?? [];
// 定位当前提交 + 前后导航
const currentIndex = allSubmissions.findIndex(
(s) => s.submissionId === submissionId,
);
const submission: HomeworkSubmissionDetail | undefined =
currentIndex >= 0 ? allSubmissions[currentIndex] : undefined;
const prevSubmission =
currentIndex > 0 ? allSubmissions[currentIndex - 1] : null;
const nextSubmission =
currentIndex >= 0 && currentIndex < allSubmissions.length - 1
? allSubmissions[currentIndex + 1] ?? null
: null;
// 每题评分状态
const [questionGrades, setQuestionGrades] = useState<
Record<string, QuestionGrade>
>({});
const [totalFeedback, setTotalFeedback] = useState("");
const [submitting, setSubmitting] = useState(false);
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
// AI 流式建议状态:按 questionId 索引
const [aiStreaming, setAiStreaming] = useState<Record<string, string>>({});
const [aiLoading, setAiLoading] = useState<string | null>(null);
// 当 submission 变化时,初始化评分表单
useEffect(() => {
if (!submission) return;
const next: Record<string, QuestionGrade> = {};
submission.answers.forEach((a) => {
next[a.questionId] = {
score: a.score?.toString() ?? "",
feedback: a.feedback ?? "",
};
});
setQuestionGrades(next);
setTotalFeedback(submission.answers[0]?.feedback ?? "");
setSaveMsg(null);
setSaveError(null);
setAiStreaming({});
}, [submission]);
// 计算当前总分(实时)
const computedTotal = useMemo(() => {
let total = 0;
submission?.answers.forEach((a) => {
const g = questionGrades[a.questionId];
if (g && g.score !== "") {
const n = Number(g.score);
if (!Number.isNaN(n)) total += n;
}
});
return total;
}, [submission, questionGrades]);
/** 保存批改 */
const handleSave = async () => {
if (!submission) return;
setSubmitting(true);
setSaveError(null);
setSaveMsg(null);
const questionScores = submission.answers.map((a) => {
const g = questionGrades[a.questionId];
const score = Number(g?.score ?? "0");
return {
questionId: a.questionId,
score: Number.isNaN(score) ? 0 : score,
feedback: g?.feedback || null,
};
});
const res = await gradeSubmission({
input: {
submissionId: submission.submissionId,
score: computedTotal,
feedback: totalFeedback.trim() || null,
aiAssisted: Object.keys(aiStreaming).length > 0,
questionScores,
},
});
setSubmitting(false);
if (res.error) {
setSaveError(res.error.message);
return;
}
setSaveMsg("已保存批改");
reexecuteQuery({ requestPolicy: "network-only" });
};
/** AI 评分建议mock SSE 流式:逐字显示) */
const handleAiSuggest = (answer: SubmissionAnswer) => {
if (!answer.aiSuggestion) return;
setAiLoading(answer.questionId);
const full = answer.aiSuggestion;
let i = 0;
const timer = setInterval(() => {
i += 2;
setAiStreaming((prev) => ({
...prev,
[answer.questionId]: full.slice(0, i),
}));
if (i >= full.length) {
clearInterval(timer);
setAiLoading(null);
// 自动填充建议分数maxScore 的 80%
setQuestionGrades((prev) => ({
...prev,
[answer.questionId]: {
score: Math.round(answer.maxScore * 0.8).toString(),
feedback: full,
},
}));
}
}, 30);
};
return (
<div className="px-10 py-10">
<header className="mb-8">
<p className="mb-2">
<Link
href={
homeworkId
? `/homework/assignments/${homeworkId}/submissions`
: "/homework/submissions"
}
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
</p>
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL HomeworkAssignmentSubmissionsQuery + GradeSubmissionMutation · core-edu P7 MSW mock
</p>
</header>
<div className="rule-thin mb-8" />
{result.fetching ? (
<Loading lines={8} />
) : result.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{result.error.message}
</p>
</div>
) : !submission ? (
<Empty title="未找到提交" description="该提交不存在或已被删除" />
) : (
<>
{/* 顶部:学生信息 + 提交时间 + 状态 */}
<section className="mb-8">
<div className="flex items-baseline justify-between">
<div>
<h2 className="text-xl font-serif text-ink">
{submission.homeworkTitle}
</h2>
<p className="mt-2 text-sm text-ink-muted">
<span className="font-serif text-ink">
{submission.studentName}
</span>
<span className="mx-2" aria-hidden="true">
·
</span>
<span className="font-mono text-tiny">
{submission.studentNo}
</span>
</p>
<p className="mt-1 text-tiny text-ink-muted">
{submission.submittedAt
? new Date(submission.submittedAt).toLocaleString("zh-CN")
: "—"}
</p>
</div>
<div className="text-right">
<span
className="text-tiny"
style={{
color:
submission.status === "GRADED"
? "var(--color-success)"
: "var(--color-warning)",
}}
>
{STATUS_LABEL[submission.status] ?? submission.status}
</span>
</div>
</div>
</section>
{/* 左右分栏:作答展示 + 评分表单 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
{/* 左侧:题目作答展示 */}
<section>
<h3 className="text-lg font-serif text-ink mb-4"></h3>
<div className="rule-thin mb-4" />
<ul className="space-y-6">
{submission.answers.map((answer, i) => {
const aiText = aiStreaming[answer.questionId];
const isAiLoading = aiLoading === answer.questionId;
return (
<li
key={answer.questionId}
className="border border-rule rounded-card p-4"
>
<div className="flex items-baseline justify-between mb-3">
<h4 className="text-base font-serif text-ink">
{i + 1} ·{" "}
{QUESTION_TYPE_LABEL[answer.questionType] ??
answer.questionType}
</h4>
<span className="text-tiny text-ink-muted">
{answer.maxScore}
</span>
</div>
<p className="text-sm text-ink mb-3">
{answer.questionTitle}
</p>
<div className="space-y-2 text-sm">
<div>
<span className="text-tiny uppercase tracking-wide text-ink-muted">
</span>
<p className="mt-1 text-ink p-2 bg-subtle rounded-button">
{answer.studentAnswer}
</p>
</div>
<div>
<span className="text-tiny uppercase tracking-wide text-ink-muted">
</span>
<p className="mt-1 text-success p-2 bg-surface rounded-button border border-rule">
{answer.correctAnswer}
</p>
</div>
</div>
{/* AI 流式建议展示 */}
{aiText !== undefined && (
<div className="mt-3 p-3 border border-accent rounded-card bg-surface">
<p className="text-tiny uppercase tracking-wide text-accent mb-1">
AI
{isAiLoading && (
<span className="ml-2 animate-pulse"></span>
)}
</p>
<p className="text-sm text-ink">{aiText}</p>
</div>
)}
</li>
);
})}
</ul>
</section>
{/* 右侧:评分表单 */}
<section>
<h3 className="text-lg font-serif text-ink mb-4"></h3>
<div className="rule-thin mb-4" />
<ul className="space-y-6">
{submission.answers.map((answer, i) => {
const g = questionGrades[answer.questionId];
const aiText = aiStreaming[answer.questionId];
return (
<li
key={answer.questionId}
className="border border-rule rounded-card p-4"
>
<div className="flex items-baseline justify-between mb-3">
<h4 className="text-sm font-serif text-ink">
{i + 1}
</h4>
<span className="text-tiny text-ink-muted">
/ {answer.maxScore}
</span>
</div>
<div className="grid grid-cols-3 gap-3 mb-3">
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<input
type="number"
min={0}
max={answer.maxScore}
value={g?.score ?? ""}
onChange={(e) =>
setQuestionGrades((prev) => ({
...prev,
[answer.questionId]: {
score: e.target.value,
feedback: g?.feedback ?? "",
},
}))
}
className="w-full px-2 py-1 bg-paper border border-rule rounded-button text-sm text-ink focus:outline-none focus:border-accent"
placeholder="0"
/>
</div>
<div className="col-span-2">
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<input
type="text"
value={g?.feedback ?? ""}
onChange={(e) =>
setQuestionGrades((prev) => ({
...prev,
[answer.questionId]: {
score: g?.score ?? "",
feedback: e.target.value,
},
}))
}
className="w-full px-2 py-1 bg-paper border border-rule rounded-button text-sm text-ink focus:outline-none focus:border-accent"
placeholder="可选:本题反馈"
/>
</div>
</div>
{answer.aiSuggestion && aiText === undefined && (
<button
type="button"
onClick={() => handleAiSuggest(answer)}
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
>
AI
</button>
)}
</li>
);
})}
</ul>
{/* 总分 + 总反馈 */}
<div className="mt-6 p-4 border border-rule rounded-card bg-subtle">
<div className="flex items-baseline justify-between mb-3">
<span className="text-tiny uppercase tracking-wide text-ink-muted">
</span>
<span className="text-2xl font-serif text-accent">
{computedTotal}
</span>
</div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<textarea
value={totalFeedback}
onChange={(e) => setTotalFeedback(e.target.value)}
rows={3}
className="w-full px-3 py-2 bg-paper border border-rule rounded-button text-sm text-ink focus:outline-none focus:border-accent"
placeholder="可选:总体评语"
/>
</div>
{/* 保存按钮 */}
<div className="mt-4 flex items-center gap-3">
<button
type="button"
onClick={handleSave}
disabled={submitting}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
>
{submitting ? "保存中..." : "保存批改"}
</button>
{saveMsg && (
<span className="text-tiny text-success">{saveMsg}</span>
)}
{saveError && (
<span className="text-tiny text-danger">{saveError}</span>
)}
</div>
</section>
</div>
{/* 底部:上一份/下一份导航 */}
<section className="border-t border-rule pt-6 flex items-baseline justify-between">
<div>
{prevSubmission ? (
<Link
href={`/homework/submissions/${prevSubmission.submissionId}`}
className="text-sm text-accent hover:opacity-70"
>
{prevSubmission.studentName}
</Link>
) : (
<span className="text-sm text-ink-muted"></span>
)}
</div>
<span className="text-tiny font-mono text-ink-muted">
{currentIndex + 1} / {allSubmissions.length}
</span>
<div>
{nextSubmission ? (
<Link
href={`/homework/submissions/${nextSubmission.submissionId}`}
className="text-sm text-accent hover:opacity-70"
>
{nextSubmission.studentName}
</Link>
) : (
<span className="text-sm text-ink-muted"></span>
)}
</div>
</section>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,448 @@
"use client";
/**
* 扫描批改视图页
*
* 数据来源P7 扩展MSW mock
* - GraphQL SubmissionScanGradingQuery按 submissionId 拉取扫描批改视图
* - GraphQL SaveScanGradingMutation保存扫描批改
*
* 布局:
* - 顶部:学生信息 + 提交时间
* - 左侧:扫描图片预览(可缩放 zoom in/out
* - 右侧:识别作答 + 评分表单(每题:题号 + 题干 + 已识别作答 + AI 评分建议 + 分数 input + 反馈 textarea
* - 底部:"保存批改" 调用 SaveScanGradingMutation + "上一份"/"下一份" 导航
*
* 维护者ai13teacher-portal
*/
import { useState, useEffect, useMemo } from "react";
import { useQuery, useMutation } from "urql";
import { useParams } from "next/navigation";
import Link from "next/link";
import { Loading, Empty } from "@edu/ui-components";
import {
SubmissionScanGradingQuery,
SaveScanGradingMutation,
} from "@/lib/graphql-p7-advanced";
import type {
SubmissionScanGrading,
ScanGradingItem,
} from "@/lib/graphql-p7-advanced";
/** 每题评分表单状态 */
interface QuestionGrade {
score: string;
feedback: string;
recognizedAnswer: string;
}
export default function ScanGradingPage(): React.ReactNode {
const params = useParams<{ submissionId: string }>();
const submissionId = params?.submissionId ?? "";
const [result] = useQuery({
query: SubmissionScanGradingQuery,
variables: { submissionId },
pause: !submissionId,
});
const [, saveGrading] = useMutation(SaveScanGradingMutation);
const scan: SubmissionScanGrading | null =
result.data?.submissionScanGrading ?? null;
// 评分表单状态
const [questionGrades, setQuestionGrades] = useState<
Record<string, QuestionGrade>
>({});
const [submitting, setSubmitting] = useState(false);
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
// 图片缩放状态
const [zoom, setZoom] = useState(1);
const [currentImageIdx, setCurrentImageIdx] = useState(0);
// 当 scan 变化时,初始化评分表单
useEffect(() => {
if (!scan) return;
const next: Record<string, QuestionGrade> = {};
scan.items.forEach((item) => {
next[item.questionId] = {
score: item.score?.toString() ?? "",
feedback: item.feedback ?? "",
recognizedAnswer: item.recognizedAnswer,
};
});
setQuestionGrades(next);
setSaveMsg(null);
setSaveError(null);
setCurrentImageIdx(0);
}, [scan]);
// 计算当前总分(实时)
const computedTotal = useMemo(() => {
let total = 0;
scan?.items.forEach((item) => {
const g = questionGrades[item.questionId];
if (g && g.score !== "") {
const n = Number(g.score);
if (!Number.isNaN(n)) total += n;
}
});
return total;
}, [scan, questionGrades]);
/** 保存批改 */
const handleSave = async () => {
if (!scan) return;
setSubmitting(true);
setSaveError(null);
setSaveMsg(null);
const items = scan.items.map((item) => {
const g = questionGrades[item.questionId];
const score = Number(g?.score ?? "0");
return {
questionId: item.questionId,
score: Number.isNaN(score) ? 0 : score,
feedback: g?.feedback || null,
};
});
const res = await saveGrading({
input: { submissionId: scan.submissionId, items },
});
setSubmitting(false);
if (res.error) {
setSaveError(res.error.message);
return;
}
setSaveMsg("已保存批改");
};
const images = scan?.images ?? [];
const currentImage = images[currentImageIdx];
return (
<div className="px-10 py-10">
<header className="mb-8">
<p className="mb-2">
<Link
href={`/homework/submissions/${submissionId}`}
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
</p>
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL SubmissionScanGradingQuery + SaveScanGradingMutation · core-edu P7 MSW mock
</p>
</header>
<div className="rule-thin mb-8" />
{result.fetching ? (
<Loading lines={8} />
) : result.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{result.error.message}
</p>
</div>
) : !scan ? (
<Empty title="未找到扫描批改数据" description="该提交无扫描批改记录" />
) : (
<>
{/* 顶部:学生信息 + 提交时间 */}
<section className="mb-8">
<div className="flex items-baseline justify-between">
<div>
<h2 className="text-xl font-serif text-ink">
{scan.homeworkTitle}
</h2>
<p className="mt-2 text-sm text-ink-muted">
<span className="font-serif text-ink">
{scan.studentName}
</span>
<span className="mx-2" aria-hidden="true">·</span>
<span className="font-mono text-tiny">
{scan.studentNo}
</span>
</p>
<p className="mt-1 text-tiny text-ink-muted">
{new Date(scan.submittedAt).toLocaleString("zh-CN")}
</p>
</div>
<div className="text-right">
<span className="text-tiny uppercase tracking-wide text-ink-muted">
</span>
<p className="text-2xl font-serif text-accent">
{computedTotal}
</p>
</div>
</div>
</section>
{/* 左右分栏:扫描图片 + 评分表单 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
{/* 左侧:扫描图片预览 */}
<section>
<h3 className="text-lg font-serif text-ink mb-4">
{images.length > 1 && (
<span className="ml-2 text-sm font-sans text-ink-muted">
{currentImageIdx + 1} / {images.length}
</span>
)}
</h3>
<div className="rule-thin mb-4" />
{images.length === 0 ? (
<Empty title="无扫描图片" description="该提交无扫描图片" />
) : (
<div>
{/* 图片预览区 */}
<div className="border border-rule rounded-card bg-subtle p-4 flex items-center justify-center overflow-hidden" style={{ minHeight: "400px" }}>
{currentImage && (
<img
src={currentImage.url}
alt={`扫描第 ${currentImage.page}`}
style={{ transform: `scale(${zoom})`, maxHeight: "380px", transition: "transform 0.2s" }}
className="max-w-full h-auto"
/>
)}
</div>
{/* 缩放控制 + 翻页 */}
<div className="mt-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setZoom((z) => Math.max(0.5, z - 0.1))}
className="px-2 py-1 text-tiny text-ink bg-transparent border border-rule rounded-button hover:bg-subtle"
>
</button>
<span className="text-tiny font-mono text-ink-muted">
{Math.round(zoom * 100)}%
</span>
<button
type="button"
onClick={() => setZoom((z) => Math.min(3, z + 0.1))}
className="px-2 py-1 text-tiny text-ink bg-transparent border border-rule rounded-button hover:bg-subtle"
>
</button>
</div>
{images.length > 1 && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setCurrentImageIdx((i) => Math.max(0, i - 1))}
disabled={currentImageIdx === 0}
className="px-2 py-1 text-tiny text-ink bg-transparent border border-rule rounded-button hover:bg-subtle disabled:opacity-50"
>
</button>
<button
type="button"
onClick={() => setCurrentImageIdx((i) => Math.min(images.length - 1, i + 1))}
disabled={currentImageIdx >= images.length - 1}
className="px-2 py-1 text-tiny text-ink bg-transparent border border-rule rounded-button hover:bg-subtle disabled:opacity-50"
>
</button>
</div>
)}
</div>
</div>
)}
</section>
{/* 右侧:识别作答 + 评分表单 */}
<section>
<h3 className="text-lg font-serif text-ink mb-4"> + </h3>
<div className="rule-thin mb-4" />
<ul className="space-y-4 max-h-[600px] overflow-y-auto pr-2">
{scan.items.map((item: ScanGradingItem, i) => {
const g = questionGrades[item.questionId];
return (
<li
key={item.questionId}
className="border border-rule rounded-card p-4"
>
<div className="flex items-baseline justify-between mb-2">
<h4 className="text-sm font-serif text-ink">
{i + 1}
</h4>
<span className="text-tiny text-ink-muted">
/ {item.maxScore}
</span>
</div>
<p className="text-sm text-ink mb-3">
{item.questionTitle}
</p>
{/* 已识别作答(可编辑) */}
<div className="mb-3">
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<textarea
value={g?.recognizedAnswer ?? ""}
onChange={(e) =>
setQuestionGrades((prev) => ({
...prev,
[item.questionId]: {
score: g?.score ?? "",
feedback: g?.feedback ?? "",
recognizedAnswer: e.target.value,
},
}))
}
rows={2}
className="w-full px-2 py-1 bg-subtle border border-rule rounded-button text-sm text-ink focus:outline-none focus:border-accent"
/>
</div>
{/* AI 评分建议 */}
{item.aiSuggestion && (
<div className="mb-3 p-2 border border-accent rounded-card bg-surface">
<p className="text-tiny uppercase tracking-wide text-accent mb-1">
AI
<span className="ml-2 text-ink-muted">
{item.aiSuggestedScore}
</span>
</p>
<p className="text-tiny text-ink">
{item.aiSuggestion}
</p>
<button
type="button"
onClick={() =>
setQuestionGrades((prev) => ({
...prev,
[item.questionId]: {
score: String(item.aiSuggestedScore ?? ""),
feedback: g?.feedback ?? item.aiSuggestion ?? "",
recognizedAnswer: g?.recognizedAnswer ?? "",
},
}))
}
className="mt-1 text-tiny text-accent hover:opacity-70"
>
</button>
</div>
)}
{/* 分数 + 反馈 */}
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<input
type="number"
min={0}
max={item.maxScore}
value={g?.score ?? ""}
onChange={(e) =>
setQuestionGrades((prev) => ({
...prev,
[item.questionId]: {
score: e.target.value,
feedback: g?.feedback ?? "",
recognizedAnswer: g?.recognizedAnswer ?? "",
},
}))
}
className="w-full px-2 py-1 bg-paper border border-rule rounded-button text-sm text-ink focus:outline-none focus:border-accent"
placeholder="0"
/>
</div>
<div className="col-span-2">
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<input
type="text"
value={g?.feedback ?? ""}
onChange={(e) =>
setQuestionGrades((prev) => ({
...prev,
[item.questionId]: {
score: g?.score ?? "",
feedback: e.target.value,
recognizedAnswer: g?.recognizedAnswer ?? "",
},
}))
}
className="w-full px-2 py-1 bg-paper border border-rule rounded-button text-sm text-ink focus:outline-none focus:border-accent"
placeholder="可选:本题反馈"
/>
</div>
</div>
</li>
);
})}
</ul>
{/* 保存按钮 */}
<div className="mt-4 flex items-center gap-3">
<button
type="button"
onClick={handleSave}
disabled={submitting}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
>
{submitting ? "保存中..." : "保存批改"}
</button>
{saveMsg && (
<span className="text-tiny text-success">{saveMsg}</span>
)}
{saveError && (
<span className="text-tiny text-danger">{saveError}</span>
)}
</div>
</section>
</div>
{/* 底部:上一份/下一份导航 */}
<section className="border-t border-rule pt-6 flex items-baseline justify-between">
<div>
{scan.prevSubmissionId ? (
<Link
href={`/homework/submissions/${scan.prevSubmissionId}/scan-grading`}
className="text-sm text-accent hover:opacity-70"
>
</Link>
) : (
<span className="text-sm text-ink-muted"></span>
)}
</div>
<div>
{scan.nextSubmissionId ? (
<Link
href={`/homework/submissions/${scan.nextSubmissionId}/scan-grading`}
className="text-sm text-accent hover:opacity-70"
>
</Link>
) : (
<span className="text-sm text-ink-muted"></span>
)}
</div>
</section>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,203 @@
"use client";
/**
* 作业提交评审列表
*
* 数据来源P7 扩展MSW mock
* - GraphQL HomeworkSubmissionsQuery按作业维度统计提交/已批数
* - GraphQL ClassesQuery班级下拉选项
*
* 列出所有作业的提交统计,"去批改" 链接跳转批量批改视图
*
* 维护者ai13teacher-portal
*/
import { useState } from "react";
import { useQuery } from "urql";
import Link from "next/link";
import { Loading, Empty } from "@edu/ui-components";
import { ClassesQuery } from "@/lib/graphql";
import type { Class } from "@/lib/graphql";
import { HomeworkSubmissionsQuery } from "@/lib/graphql-p7-grades";
import type { HomeworkSubmissionStat } from "@/lib/graphql-p7-grades";
/** 状态中文映射 */
const STATUS_LABEL: Record<string, string> = {
draft: "草稿",
published: "已发布",
graded: "已批改",
};
export default function HomeworkSubmissionsPage(): React.ReactNode {
const [classId, setClassId] = useState("");
const [status, setStatus] = useState("");
// 班级列表
const [classesResult] = useQuery({ query: ClassesQuery });
const classes: Class[] = classesResult.data?.classes ?? [];
// 提交统计
const [result] = useQuery({
query: HomeworkSubmissionsQuery,
variables: {
classId: classId || null,
status: status || null,
},
});
const stats: HomeworkSubmissionStat[] =
result.data?.homeworkSubmissions ?? [];
return (
<div className="px-10 py-10">
<header className="mb-8">
<p className="mb-2">
<Link
href="/homework"
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
</p>
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL HomeworkSubmissionsQuery · core-edu P7 MSW mock
</p>
</header>
<div className="rule-thin mb-8" />
{/* 筛选区 */}
<section className="mb-8">
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<select
value={classId}
onChange={(e) => setClassId(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
>
<option value=""></option>
{classes.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
>
<option value=""></option>
<option value="draft">稿</option>
<option value="published"></option>
<option value="graded"></option>
</select>
</div>
</div>
</section>
{result.fetching ? (
<Loading lines={6} />
) : result.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{result.error.message}
</p>
</div>
) : stats.length === 0 ? (
<Empty title="暂无提交" description="没有符合条件的作业提交记录" />
) : (
<section>
<div className="border border-rule rounded-card overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-subtle">
<tr>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-right text-tiny uppercase tracking-wide text-ink-muted">
/
</th>
<th className="px-4 py-3 text-right text-tiny uppercase tracking-wide text-ink-muted">
/
</th>
<th className="px-4 py-3 text-right text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-right text-tiny uppercase tracking-wide text-ink-muted">
</th>
</tr>
</thead>
<tbody>
{stats.map((s) => {
const submitRate =
s.totalCount > 0
? Math.round((s.submittedCount / s.totalCount) * 100)
: 0;
const gradeRate =
s.submittedCount > 0
? Math.round((s.gradedCount / s.submittedCount) * 100)
: 0;
return (
<tr key={s.homeworkId} className="border-t border-rule">
<td className="px-4 py-3 font-serif text-ink">
{s.title}
</td>
<td className="px-4 py-3 text-sm text-ink-muted">
{s.className}
</td>
<td className="px-4 py-3">
<span className="text-tiny text-ink-muted">
{STATUS_LABEL[s.status] ?? s.status}
</span>
</td>
<td className="px-4 py-3 text-right font-mono text-tiny text-ink">
{s.submittedCount}/{s.totalCount}
<span className="ml-1 text-ink-muted">
({submitRate}%)
</span>
</td>
<td className="px-4 py-3 text-right font-mono text-tiny text-ink">
{s.gradedCount}/{s.submittedCount}
<span className="ml-1 text-ink-muted">
({gradeRate}%)
</span>
</td>
<td className="px-4 py-3 text-right font-serif text-accent">
{s.avgScore !== null ? s.avgScore : "—"}
</td>
<td className="px-4 py-3 text-right">
<Link
href={`/homework/assignments/${s.homeworkId}/submissions`}
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
>
</Link>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
)}
</div>
);
}

View File

@@ -0,0 +1,399 @@
"use client";
/**
* KnowledgeGraphView - 纯 SVG 知识图谱可视化组件
*
* 职责:
* - 圆形布局放置知识点节点
* - 节点大小 = 掌握度,颜色 = 掌握度梯度(红→黄→绿,使用语义令牌)
* - 边为箭头线PREREQUISITE 实线 / RELATED 虚线)
* - 点击节点展示详情面板
*
* 不依赖 d3/recharts全部 SVG 手绘project_rules不安装新 npm 包)。
* 颜色禁止 hex 字面量,使用 var(--color-success/warning/danger)project_rules §3.10)。
*
* 维护者ai13teacher-portal
*/
import { useMemo, useState } from "react";
import type {
KnowledgeNode,
KnowledgeEdge,
} from "@/lib/graphql-p4";
interface KnowledgeGraphViewProps {
nodes: KnowledgeNode[];
edges: KnowledgeEdge[];
}
/** 画布尺寸 */
const WIDTH = 880;
const HEIGHT = 620;
const CENTER_X = WIDTH / 2;
const CENTER_Y = HEIGHT / 2;
/** 节点分布圆半径 */
const LAYOUT_RADIUS = 230;
/** 节点最小/最大半径 */
const NODE_R_MIN = 18;
const NODE_R_MAX = 34;
/** 按掌握度映射填充色(语义令牌变量) */
function masteryColor(level: number): string {
if (level < 40) return "var(--color-danger)";
if (level < 70) return "var(--color-warning)";
return "var(--color-success)";
}
/** 按掌握度映射节点半径 */
function nodeRadius(level: number): number {
const ratio = Math.max(0, Math.min(100, level)) / 100;
return NODE_R_MIN + (NODE_R_MAX - NODE_R_MIN) * ratio;
}
interface NodePosition {
node: KnowledgeNode;
x: number;
y: number;
r: number;
}
export default function KnowledgeGraphView({
nodes,
edges,
}: KnowledgeGraphViewProps): React.ReactNode {
const [selectedId, setSelectedId] = useState<string | null>(
nodes[0]?.id ?? null,
);
// 圆形布局:按 index 均匀分布
const positions = useMemo<NodePosition[]>(() => {
const total = nodes.length;
return nodes.map((node, i) => {
const angle = (2 * Math.PI * i) / total - Math.PI / 2;
return {
node,
x: CENTER_X + LAYOUT_RADIUS * Math.cos(angle),
y: CENTER_Y + LAYOUT_RADIUS * Math.sin(angle),
r: nodeRadius(node.masteryLevel),
};
});
}, [nodes]);
const posMap = useMemo(() => {
const m = new Map<string, NodePosition>();
positions.forEach((p) => m.set(p.node.id, p));
return m;
}, [positions]);
const selected = selectedId
? positions.find((p) => p.node.id === selectedId) ?? null
: null;
return (
<div className="flex gap-8 items-start">
{/* SVG 图谱 */}
<svg
viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
className="flex-1 max-w-3xl border border-rule rounded-card bg-surface"
role="img"
aria-label="知识图谱节点关系图"
>
<defs>
{/* 箭头标记 - PREREQUISITE */}
<marker
id="arrow-prereq"
viewBox="0 0 10 10"
refX="9"
refY="5"
markerWidth="7"
markerHeight="7"
orient="auto-start-reverse"
>
<path
d="M 0 0 L 10 5 L 0 10 z"
fill="var(--color-ink-muted)"
/>
</marker>
{/* 箭头标记 - RELATED */}
<marker
id="arrow-related"
viewBox="0 0 10 10"
refX="9"
refY="5"
markerWidth="7"
markerHeight="7"
orient="auto-start-reverse"
>
<path
d="M 0 0 L 10 5 L 0 10 z"
fill="var(--color-ink-subtle)"
/>
</marker>
</defs>
{/* 边 */}
<g>
{edges.map((edge, i) => {
const from = posMap.get(edge.from);
const to = posMap.get(edge.to);
if (!from || !to) return null;
// 计算边起止点(缩进到节点边缘,避免穿过节点)
const dx = to.x - from.x;
const dy = to.y - from.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const ux = dx / dist;
const uy = dy / dist;
const x1 = from.x + ux * from.r;
const y1 = from.y + uy * from.r;
const x2 = to.x - ux * (to.r + 4);
const y2 = to.y - uy * (to.r + 4);
const isPrereq = edge.type === "PREREQUISITE";
return (
<line
key={`edge-${i}`}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke={isPrereq ? "var(--color-ink-muted)" : "var(--color-ink-subtle)"}
strokeWidth={isPrereq ? 1.5 : 1}
strokeDasharray={isPrereq ? undefined : "5 4"}
markerEnd={isPrereq ? "url(#arrow-prereq)" : "url(#arrow-related)"}
/>
);
})}
</g>
{/* 节点 */}
<g>
{positions.map((p) => {
const isSelected = p.node.id === selectedId;
return (
<g
key={p.node.id}
onClick={() => setSelectedId(p.node.id)}
className="cursor-pointer"
role="button"
aria-label={`${p.node.name} 掌握度 ${p.node.masteryLevel}`}
>
<circle
cx={p.x}
cy={p.y}
r={p.r}
fill={masteryColor(p.node.masteryLevel)}
fillOpacity={0.85}
stroke={
isSelected
? "var(--color-ink)"
: "var(--color-rule)"
}
strokeWidth={isSelected ? 2.5 : 1}
/>
<text
x={p.x}
y={p.y + p.r + 16}
textAnchor="middle"
fontSize="13"
fontFamily="var(--font-family-sans)"
fill="var(--color-ink)"
>
{p.node.name}
</text>
<text
x={p.x}
y={p.y + 5}
textAnchor="middle"
fontSize="12"
fontFamily="var(--font-family-mono)"
fontWeight="600"
fill="var(--color-ink-on-accent)"
>
{p.node.masteryLevel}
</text>
</g>
);
})}
</g>
</svg>
{/* 详情面板 */}
<aside className="w-72 flex-shrink-0">
{selected ? (
<div className="p-6 border border-rule rounded-card bg-surface">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
{selected.node.subject}
</p>
<h3 className="mt-2 text-xl font-serif text-ink">
{selected.node.name}
</h3>
{selected.node.description ? (
<p className="mt-3 text-sm text-ink-muted">
{selected.node.description}
</p>
) : null}
<div className="rule-thin my-5" />
<div>
<div className="flex items-baseline justify-between mb-2">
<span className="text-tiny uppercase tracking-wide text-ink-muted">
</span>
<span
className="text-lg font-serif"
style={{ color: masteryColor(selected.node.masteryLevel) }}
>
{selected.node.masteryLevel}
<span className="text-tiny text-ink-muted">/100</span>
</span>
</div>
<div className="h-2 rounded-button bg-subtle overflow-hidden">
<div
className="h-full rounded-button"
style={{
width: `${selected.node.masteryLevel}%`,
background: masteryColor(selected.node.masteryLevel),
}}
/>
</div>
</div>
{/* 前置 / 后继 */}
<Predecessors
edges={edges}
nodeId={selected.node.id}
posMap={posMap}
/>
<Successors
edges={edges}
nodeId={selected.node.id}
posMap={posMap}
/>
</div>
) : (
<p className="text-sm text-ink-muted p-6">
</p>
)}
{/* 图例 */}
<div className="mt-6 p-4 border border-rule rounded-card">
<p className="text-tiny uppercase tracking-wide text-ink-muted mb-3">
</p>
<ul className="space-y-2 text-sm">
<li className="flex items-center gap-3">
<span
className="inline-block w-4 h-4 rounded-full"
style={{ background: "var(--color-success)" }}
/>
<span className="text-ink"> 70</span>
</li>
<li className="flex items-center gap-3">
<span
className="inline-block w-4 h-4 rounded-full"
style={{ background: "var(--color-warning)" }}
/>
<span className="text-ink">40 &lt; 70</span>
</li>
<li className="flex items-center gap-3">
<span
className="inline-block w-4 h-4 rounded-full"
style={{ background: "var(--color-danger)" }}
/>
<span className="text-ink"> &lt; 40</span>
</li>
<li className="flex items-center gap-3 pt-2 border-t border-rule">
<span className="inline-block w-6 h-0.5 bg-ink-muted" />
<span className="text-ink-muted">线</span>
</li>
<li className="flex items-center gap-3">
<span
className="inline-block w-6 h-0.5"
style={{
backgroundImage:
"repeating-linear-gradient(90deg, var(--color-ink-subtle) 0 5px, transparent 5px 9px)",
}}
/>
<span className="text-ink-muted">线</span>
</li>
</ul>
</div>
</aside>
</div>
);
}
/** 前置节点列表 */
function Predecessors({
edges,
nodeId,
posMap,
}: {
edges: KnowledgeEdge[];
nodeId: string;
posMap: Map<string, NodePosition>;
}): React.ReactNode {
const preds = edges
.filter((e) => e.to === nodeId)
.map((e) => posMap.get(e.from)?.node)
.filter((n): n is KnowledgeNode => n !== undefined);
if (preds.length === 0) return null;
return (
<div className="mt-5">
<p className="text-tiny uppercase tracking-wide text-ink-muted mb-2">
</p>
<ul className="space-y-1">
{preds.map((n) => (
<li key={n.id} className="text-sm text-ink">
{n.name}
<span className="ml-2 text-tiny text-ink-muted">
{n.subject}
</span>
</li>
))}
</ul>
</div>
);
}
/** 后继节点列表 */
function Successors({
edges,
nodeId,
posMap,
}: {
edges: KnowledgeEdge[];
nodeId: string;
posMap: Map<string, NodePosition>;
}): React.ReactNode {
const succs = edges
.filter((e) => e.from === nodeId)
.map((e) => posMap.get(e.to)?.node)
.filter((n): n is KnowledgeNode => n !== undefined);
if (succs.length === 0) return null;
return (
<div className="mt-5">
<p className="text-tiny uppercase tracking-wide text-ink-muted mb-2">
</p>
<ul className="space-y-1">
{succs.map((n) => (
<li key={n.id} className="text-sm text-ink">
{n.name}
<span className="ml-2 text-tiny text-ink-muted">
{n.subject}
</span>
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,90 @@
"use client";
/**
* KnowledgeGraph 页面 - 知识图谱可视化
*
* 数据来源P4 扩展MSW mockGraphQL KnowledgeGraphQuery
* - 支持按科目筛选(下拉选择)
* - 纯 SVG 绘制节点关系图(不安装 d3/recharts
* - CSR + @next/dynamic 懒加载ssr: false
*
* 维护者ai13teacher-portal
*/
import { useState } from "react";
import dynamic from "next/dynamic";
import { useQuery } from "urql";
import { Loading, Empty } from "@edu/ui-components";
import { KnowledgeGraphQuery } from "@/lib/graphql-p4";
import type { KnowledgeGraphData } from "@/lib/graphql-p4";
import { KNOWLEDGE_SUBJECTS } from "@/mocks/fixtures/knowledge-graph";
// 懒加载 SVG 可视化组件CSR only避免 SSR 报 window 未定义)
const KnowledgeGraphView = dynamic(
() => import("./KnowledgeGraphView"),
{
ssr: false,
loading: () => <Loading lines={6} />,
},
);
export default function KnowledgeGraphPage() {
const [subject, setSubject] = useState<string>("全部");
const [result] = useQuery({
query: KnowledgeGraphQuery,
variables: { subject: subject === "全部" ? null : subject },
});
const graph: KnowledgeGraphData | undefined = result.data?.knowledgeGraph;
return (
<div className="px-10 py-10">
<header className="mb-8">
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL KnowledgeGraphQuery · P4 MSW mock
</p>
</header>
<div className="rule-thin mb-8" />
{/* 科目筛选 */}
<div className="mb-6 flex items-baseline gap-3">
<label className="text-tiny uppercase tracking-wide text-ink-muted">
</label>
<select
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
>
{KNOWLEDGE_SUBJECTS.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
{graph ? (
<span className="ml-2 text-tiny text-ink-muted">
{graph.nodes.length} · {graph.edges.length}
</span>
) : null}
</div>
{result.fetching ? (
<Loading lines={6} />
) : result.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{result.error.message}
</p>
</div>
) : !graph || graph.nodes.length === 0 ? (
<Empty title="暂无知识图谱数据" description="该科目尚未录入知识点" />
) : (
<KnowledgeGraphView nodes={graph.nodes} edges={graph.edges} />
)}
</div>
);
}

View File

@@ -0,0 +1,607 @@
"use client";
/**
* 请假审批页 - 教师请假申请管理
*
* 数据来源P7 扩展MSW mock
* - GraphQL LeaveRequestsQuery按 dataScope/status 筛选 + 分页
* - GraphQL ApproveLeaveRequestMutation批准/拒绝请假
* - GraphQL SubmitLeaveRequestMutation教师提交请假申请
*
* 功能:
* - dataScope 筛选(我的/本班/本年级/全校)
* - 状态筛选(全部/待审批/已批准/已拒绝)
* - 审批对话框(批准/拒绝 + 评论)
* - 提交请假申请模态框(类型/起止日期/原因)
*
* 维护者ai13teacher-portal
*/
import { useState, useMemo } from "react";
import { useQuery, useMutation } from "urql";
import { Loading, Empty } from "@edu/ui-components";
import {
LeaveRequestsQuery,
ApproveLeaveRequestMutation,
SubmitLeaveRequestMutation,
} from "@/lib/graphql-p7-admin";
import type {
LeaveRequest,
LeaveRequestsFilter,
LeaveType,
LeaveStatus,
ApproveLeaveRequestInput,
SubmitLeaveRequestInput,
} from "@/lib/graphql-p7-admin";
import type { DataScope } from "@/lib/graphql";
import {
DATA_SCOPES,
LEAVE_TYPE_LABEL,
LEAVE_STATUS_LABEL,
} from "@/mocks/fixtures/leave-requests";
const LEAVE_TYPES: { value: LeaveType; label: string }[] = [
{ value: "SICK", label: "病假" },
{ value: "PERSONAL", label: "事假" },
{ value: "ANNUAL", label: "年假" },
{ value: "OTHER", label: "其他" },
];
const STATUS_FILTERS: { value: LeaveStatus | "ALL"; label: string }[] = [
{ value: "ALL", label: "全部" },
{ value: "PENDING", label: "待审批" },
{ value: "APPROVED", label: "已批准" },
{ value: "REJECTED", label: "已拒绝" },
];
function statusBadgeClass(status: LeaveStatus): string {
switch (status) {
case "PENDING":
return "text-warning border-warning";
case "APPROVED":
return "text-success border-success";
case "REJECTED":
return "text-danger border-danger";
default:
return "text-ink-muted border-rule";
}
}
function todayStr(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
export default function LeavePage(): React.ReactNode {
const [dataScope, setDataScope] = useState<DataScope>("SELF");
const [statusFilter, setStatusFilter] = useState<LeaveStatus | "ALL">("ALL");
const [page, setPage] = useState(1);
const pageSize = 20;
// 审批对话框状态
const [reviewTarget, setReviewTarget] = useState<LeaveRequest | null>(null);
const [reviewAction, setReviewAction] = useState<"APPROVE" | "REJECT">(
"APPROVE",
);
const [reviewComment, setReviewComment] = useState("");
const [submitting, setSubmitting] = useState(false);
const [reviewError, setReviewError] = useState<string | null>(null);
// 提交请假对话框状态
const [showSubmit, setShowSubmit] = useState(false);
const [formType, setFormType] = useState<LeaveType>("SICK");
const [formStart, setFormStart] = useState(todayStr());
const [formEnd, setFormEnd] = useState(todayStr());
const [formReason, setFormReason] = useState("");
const [formSubmitting, setFormSubmitting] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [formSuccess, setFormSuccess] = useState<string | null>(null);
const filter: LeaveRequestsFilter = useMemo(
() => ({
dataScope,
status: statusFilter === "ALL" ? null : statusFilter,
page,
pageSize,
}),
[dataScope, statusFilter, page],
);
const [result, reexecute] = useQuery({
query: LeaveRequestsQuery,
variables: { filter },
});
const [, approveLeave] = useMutation(ApproveLeaveRequestMutation);
const [, submitLeave] = useMutation(SubmitLeaveRequestMutation);
const data = result.data?.leaveRequests;
const items: LeaveRequest[] = data?.items ?? [];
const total = data?.total ?? 0;
const openReview = (
request: LeaveRequest,
action: "APPROVE" | "REJECT",
): void => {
setReviewTarget(request);
setReviewAction(action);
setReviewComment("");
setReviewError(null);
};
const closeReview = (): void => {
setReviewTarget(null);
setReviewComment("");
setReviewError(null);
};
const handleApprove = async (): Promise<void> => {
if (!reviewTarget) return;
setSubmitting(true);
setReviewError(null);
const input: ApproveLeaveRequestInput = {
requestId: reviewTarget.id,
action: reviewAction,
comment: reviewComment.trim() || null,
};
const res = await approveLeave({ input });
setSubmitting(false);
if (res.error) {
setReviewError(res.error.message);
return;
}
closeReview();
reexecute({ requestPolicy: "network-only" });
};
const handleSubmitLeave = async (): Promise<void> => {
if (!formStart || !formEnd || !formReason.trim()) {
setFormError("请填写起止日期和原因");
return;
}
if (formEnd < formStart) {
setFormError("结束日期不能早于开始日期");
return;
}
setFormSubmitting(true);
setFormError(null);
setFormSuccess(null);
const input: SubmitLeaveRequestInput = {
type: formType,
startDate: formStart,
endDate: formEnd,
reason: formReason.trim(),
};
const res = await submitLeave({ input });
setFormSubmitting(false);
if (res.error) {
setFormError(res.error.message);
return;
}
setFormSuccess(
`已提交:${res.data?.submitLeaveRequest?.requestId ?? "—"},等待审批`,
);
// 重置表单
setFormType("SICK");
setFormStart(todayStr());
setFormEnd(todayStr());
setFormReason("");
reexecute({ requestPolicy: "network-only" });
};
const closeSubmit = (): void => {
setShowSubmit(false);
setFormError(null);
setFormSuccess(null);
setFormReason("");
};
return (
<div className="px-10 py-10">
<header className="mb-8 flex items-baseline justify-between">
<div>
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL LeaveRequestsQuery + ApproveLeaveRequestMutation +
SubmitLeaveRequestMutationP7 MSW mock
</p>
</div>
<button
type="button"
onClick={() => {
setShowSubmit(true);
setFormSuccess(null);
}}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
>
</button>
</header>
<div className="rule-thin mb-8" />
{/* 筛选栏 */}
<section className="mb-8">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-2 flex-wrap">
<label className="text-tiny uppercase tracking-wide text-ink-muted">
</label>
{DATA_SCOPES.map((s) => {
const active = dataScope === s.value;
return (
<button
key={s.value}
type="button"
onClick={() => {
setDataScope(s.value);
setPage(1);
}}
className={`px-3 py-1 text-tiny uppercase tracking-wide rounded-button border transition-colors ${
active
? "bg-accent text-ink-on-accent border-accent"
: "bg-transparent text-ink-muted border-rule hover:text-ink"
}`}
>
{s.label}
</button>
);
})}
</div>
<div className="flex items-center gap-2 flex-wrap">
<label className="text-tiny uppercase tracking-wide text-ink-muted">
</label>
{STATUS_FILTERS.map((s) => {
const active = statusFilter === s.value;
return (
<button
key={s.value}
type="button"
onClick={() => {
setStatusFilter(s.value);
setPage(1);
}}
className={`px-3 py-1 text-tiny uppercase tracking-wide rounded-button border transition-colors ${
active
? "bg-accent text-ink-on-accent border-accent"
: "bg-transparent text-ink-muted border-rule hover:text-ink"
}`}
>
{s.label}
</button>
);
})}
</div>
</div>
</section>
{result.fetching ? (
<Loading lines={5} />
) : result.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{result.error.message}
</p>
</div>
) : items.length === 0 ? (
<Empty title="暂无请假申请" description="当前筛选条件下没有记录" />
) : (
<section>
<div className="flex items-baseline justify-between mb-4">
<h2 className="text-xl font-serif text-ink">
<span className="ml-2 text-sm font-sans text-ink-muted">
{total}
</span>
</h2>
</div>
<div className="rule-thin mb-4" />
<div className="border border-rule rounded-card overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-subtle">
<tr>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
</tr>
</thead>
<tbody>
{items.map((r) => (
<tr key={r.id} className="border-t border-rule">
<td className="px-4 py-3">
<p className="font-serif text-ink">{r.applicantName}</p>
<p className="text-tiny font-mono text-ink-muted">
{r.applicantRole}
</p>
</td>
<td className="px-4 py-3 text-sm text-ink">
{LEAVE_TYPE_LABEL[r.type]}
</td>
<td className="px-4 py-3 font-mono text-tiny text-ink-muted">
{r.startDate} ~ {r.endDate}
</td>
<td className="px-4 py-3 text-tiny text-ink-muted max-w-xs">
<p className="truncate">{r.reason}</p>
</td>
<td className="px-4 py-3">
<span
className={`inline-block px-2 py-0.5 rounded-button text-tiny font-mono border ${statusBadgeClass(
r.status,
)}`}
>
{LEAVE_STATUS_LABEL[r.status]}
</span>
</td>
<td className="px-4 py-3">
{r.status === "PENDING" ? (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => openReview(r, "APPROVE")}
className="text-tiny uppercase tracking-wide text-success hover:opacity-70"
>
</button>
<button
type="button"
onClick={() => openReview(r, "REJECT")}
className="text-tiny uppercase tracking-wide text-danger hover:opacity-70"
>
</button>
</div>
) : (
<span className="text-tiny text-ink-muted">
{r.reviewedAt
? `${r.reviewerName ?? "—"} · ${r.reviewedAt.slice(
0,
10,
)}`
: "—"}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 分页 */}
<div className="mt-6 flex items-center justify-between">
<button
type="button"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle disabled:opacity-40 disabled:cursor-not-allowed"
>
</button>
<span className="text-tiny text-ink-muted">
{page} {pageSize}
</span>
<button
type="button"
onClick={() => setPage((p) => p + 1)}
disabled={items.length < pageSize}
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle disabled:opacity-40 disabled:cursor-not-allowed"
>
</button>
</div>
</section>
)}
{/* 审批对话框 */}
{reviewTarget && (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: "hsl(var(--color-ink) / 0.3)" }}
role="dialog"
aria-modal="true"
>
<div className="w-full max-w-lg p-8 bg-paper border border-rule rounded-card">
<div className="flex items-baseline justify-between mb-4">
<h3 className="text-xl font-serif text-ink">
{reviewAction === "APPROVE" ? "批准请假" : "拒绝请假"}
</h3>
<button
type="button"
onClick={closeReview}
className="text-tiny text-ink-muted hover:opacity-70"
>
</button>
</div>
<div className="rule-thin mb-4" />
<div className="mb-4">
<p className="text-sm text-ink-muted">
<span className="text-ink">
{reviewTarget.applicantName}
</span>
</p>
<p className="mt-1 text-sm text-ink-muted">
<span className="text-ink">
{LEAVE_TYPE_LABEL[reviewTarget.type]}
</span>
</p>
<p className="mt-1 text-sm text-ink-muted">
<span className="font-mono text-ink">
{reviewTarget.startDate} ~ {reviewTarget.endDate}
</span>
</p>
<p className="mt-2 text-sm text-ink-muted">
<span className="text-ink">{reviewTarget.reason}</span>
</p>
</div>
<div className="mb-4">
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<textarea
value={reviewComment}
onChange={(e) => setReviewComment(e.target.value)}
rows={3}
placeholder="可选:填写审批意见"
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none focus:border-accent"
/>
</div>
{reviewError && (
<p className="mb-3 text-tiny text-danger">{reviewError}</p>
)}
<div className="flex items-center justify-end gap-3">
<button
type="button"
onClick={closeReview}
disabled={submitting}
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle disabled:opacity-50"
>
</button>
<button
type="button"
onClick={handleApprove}
disabled={submitting}
className={`px-4 py-2 text-sm text-ink-on-accent rounded-button disabled:opacity-50 hover:opacity-80 ${
reviewAction === "APPROVE" ? "bg-success" : "bg-danger"
}`}
>
{submitting
? "处理中..."
: reviewAction === "APPROVE"
? "确认批准"
: "确认拒绝"}
</button>
</div>
</div>
</div>
)}
{/* 提交请假对话框 */}
{showSubmit && (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: "hsl(var(--color-ink) / 0.3)" }}
role="dialog"
aria-modal="true"
>
<div className="w-full max-w-lg p-8 bg-paper border border-rule rounded-card">
<div className="flex items-baseline justify-between mb-4">
<h3 className="text-xl font-serif text-ink"></h3>
<button
type="button"
onClick={closeSubmit}
className="text-tiny text-ink-muted hover:opacity-70"
>
</button>
</div>
<div className="rule-thin mb-4" />
{formSuccess ? (
<div className="mb-4 p-4 border border-rule rounded-card bg-subtle">
<p className="text-sm text-success">{formSuccess}</p>
<button
type="button"
onClick={() => setFormSuccess(null)}
className="mt-2 text-tiny text-accent hover:opacity-70"
>
</button>
</div>
) : null}
<div className="mb-4">
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<select
value={formType}
onChange={(e) => setFormType(e.target.value as LeaveType)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
>
{LEAVE_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
<div className="mb-4 grid grid-cols-2 gap-4">
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<input
type="date"
value={formStart}
onChange={(e) => setFormStart(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
/>
</div>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<input
type="date"
value={formEnd}
onChange={(e) => setFormEnd(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
/>
</div>
</div>
<div className="mb-4">
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<textarea
value={formReason}
onChange={(e) => setFormReason(e.target.value)}
rows={3}
placeholder="请填写请假原因"
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none focus:border-accent"
/>
</div>
{formError && (
<p className="mb-3 text-tiny text-danger">{formError}</p>
)}
<div className="flex items-center justify-end gap-3">
<button
type="button"
onClick={closeSubmit}
disabled={formSubmitting}
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle disabled:opacity-50"
>
</button>
<button
type="button"
onClick={handleSubmitLeave}
disabled={formSubmitting}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
>
{formSubmitting ? "提交中..." : "提交申请"}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,497 @@
"use client";
/**
* 课案编辑器核心P7-insights
*
* 数据来源:
* - GraphQL LessonPlanDetailQuery
* - GraphQL UpdateLessonPlanMutation
* - GraphQL AIGenerateLessonPlanMutation
*
* 三栏布局:
* - 左侧:教材/章节选择 + 班级关联
* - 中间富文本编辑器contenteditable工具栏加粗/斜体/标题/列表)
* - 右侧AI 助手面板(生成 + 历史记录)
*
* 顶部:保存/发布/导出 PDFwindow.print()
*
* 维护者ai13teacher-portal
*/
import { useEffect, useRef, useState } from "react";
import { useQuery, useMutation } from "urql";
import { useParams } from "next/navigation";
import Link from "next/link";
import { Loading, Empty } from "@edu/ui-components";
import {
LessonPlanDetailQuery,
UpdateLessonPlanMutation,
AIGenerateLessonPlanMutation,
} from "@/lib/graphql-p7-insights";
import type {
LessonPlanDetail,
AIGenerateLessonPlanInput,
LessonPlanAIHistoryItem,
} from "@/lib/graphql-p7-insights";
import { ClassesQuery } from "@/lib/graphql";
import type { Class } from "@/lib/graphql";
const STATUS_OPTIONS = [
{ value: "DRAFT", label: "草稿" },
{ value: "PUBLISHED", label: "已发布" },
{ value: "ARCHIVED", label: "已归档" },
] as const;
const TEXTBOOKS = [
{ id: "", title: "不关联教材" },
{ id: "tb-001", title: "小学语文三年级上册" },
{ id: "tb-002", title: "小学数学六年级上册" },
{ id: "tb-003", title: "初中英语七年级上册" },
];
export default function LessonPlanEditPage(): JSX.Element {
const params = useParams<{ planId: string }>();
const planId = params?.planId ?? "";
const [result] = useQuery({
query: LessonPlanDetailQuery,
variables: { planId },
pause: !planId,
});
const [classesResult] = useQuery({ query: ClassesQuery });
const classes: Class[] = classesResult.data?.classes ?? [];
const plan: LessonPlanDetail | undefined = result.data?.lessonPlanDetail;
const editorRef = useRef<HTMLDivElement | null>(null);
const [title, setTitle] = useState<string>("");
const [status, setStatus] = useState<string>("DRAFT");
const [textbookId, setTextbookId] = useState<string>("");
const [chapter, setChapter] = useState<string>("");
const [classIds, setClassIds] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [aiPrompt, setAiPrompt] = useState<string>("");
const [aiHistory, setAiHistory] = useState<LessonPlanAIHistoryItem[]>([]);
const [generating, setGenerating] = useState(false);
const [aiError, setAiError] = useState<string | null>(null);
const [, updateLessonPlan] = useMutation(UpdateLessonPlanMutation);
const [, aiGenerate] = useMutation(AIGenerateLessonPlanMutation);
// 同步初始数据到本地 state + 编辑器内容
useEffect(() => {
if (plan) {
setTitle(plan.title);
setStatus(plan.status);
setTextbookId(plan.textbookId ?? "");
setChapter(plan.chapter);
setClassIds(plan.classIds);
setAiHistory(plan.aiHistory);
if (editorRef.current) {
editorRef.current.innerText = plan.content;
}
}
}, [plan]);
const getEditorContent = (): string => {
if (!editorRef.current) return "";
// 简化:使用 innerText 作为内容contenteditable 内的文本)
return editorRef.current.innerText;
};
const setEditorContent = (content: string): void => {
if (editorRef.current) {
editorRef.current.innerText = content;
}
};
const appendEditorContent = (content: string): void => {
if (!editorRef.current) return;
const current = editorRef.current.innerText;
editorRef.current.innerText = `${current}\n\n${content}`;
};
const exec = (command: string, value?: string): void => {
if (typeof document !== "undefined" && editorRef.current) {
editorRef.current.focus();
document.execCommand(command, false, value);
}
};
const handleSave = async (newStatus?: string): Promise<void> => {
if (!plan) return;
setSaving(true);
setSaveMsg(null);
const res = await updateLessonPlan({
input: {
planId: plan.id,
title,
content: getEditorContent(),
status: (newStatus ?? status) as "DRAFT" | "PUBLISHED" | "ARCHIVED",
textbookId: textbookId || null,
chapter,
classIds,
},
});
setSaving(false);
if (res.error || !res.data?.updateLessonPlan) {
setSaveMsg(`保存失败:${res.error?.message ?? "未知错误"}`);
return;
}
if (newStatus) setStatus(newStatus);
setSaveMsg("已保存 ✓");
setTimeout(() => setSaveMsg(null), 2000);
};
const handleExportPdf = (): void => {
if (typeof window !== "undefined") window.print();
};
const handleAIGenerate = async (): Promise<void> => {
if (!plan || generating || !aiPrompt) return;
setGenerating(true);
setAiError(null);
const input: AIGenerateLessonPlanInput = {
subject: plan.subject,
grade: plan.grade,
chapter: plan.chapter,
prompt: aiPrompt,
};
const res = await aiGenerate({ input });
setGenerating(false);
if (res.error || !res.data?.aiGenerateLessonPlan) {
setAiError(res.error?.message ?? "生成失败");
return;
}
const generated = res.data.aiGenerateLessonPlan as {
id: string;
content: string;
summary: string;
model: string;
generatedAt: string;
};
appendEditorContent(generated.content);
setAiHistory((prev) => [
{
id: generated.id,
prompt: aiPrompt,
generatedAt: generated.generatedAt,
model: generated.model,
},
...prev,
]);
setAiPrompt("");
};
if (!planId) {
return (
<div className="px-10 py-10">
<Empty title="未指定课案" description="请从课案列表进入编辑" />
</div>
);
}
if (result.fetching) {
return (
<div className="px-10 py-10">
<Loading lines={6} />
</div>
);
}
if (result.error) {
return (
<div className="px-10 py-10">
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{result.error.message}
</p>
</div>
<Link
href="/lesson-plans"
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
>
</Link>
</div>
);
}
if (!plan) {
return (
<div className="px-10 py-10">
<Empty title="课案不存在" description="可能已被删除" />
<Link
href="/lesson-plans"
className="mt-4 inline-block text-tiny uppercase tracking-wide text-accent hover:opacity-70"
>
</Link>
</div>
);
}
return (
<div className="px-10 py-10">
{/* 顶部工具栏 */}
<header className="mb-6 flex items-baseline justify-between flex-wrap gap-4">
<div>
<Link
href="/lesson-plans"
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
<h1 className="mt-2 text-2xl font-serif text-ink">{plan.title}</h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL LessonPlanDetailQuery · P7-insights
</p>
</div>
<div className="flex items-center gap-3">
{saveMsg && (
<span className="text-tiny text-success font-mono">{saveMsg}</span>
)}
<button
type="button"
onClick={() => handleSave()}
disabled={saving}
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle disabled:opacity-50"
>
{saving ? "保存中…" : "保存"}
</button>
<button
type="button"
onClick={() => handleSave("PUBLISHED")}
disabled={saving}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
>
</button>
<button
type="button"
onClick={handleExportPdf}
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle"
>
PDF
</button>
</div>
</header>
<div className="rule-thin mb-6" />
{/* 三栏布局 */}
<div className="grid grid-cols-1 lg:grid-cols-[260px_1fr_320px] gap-6">
{/* 左侧:教材/章节 + 班级关联 */}
<aside className="space-y-6">
<div className="p-5 border border-rule rounded-card bg-surface">
<h3 className="text-tiny uppercase tracking-wide text-ink-muted mb-3">
</h3>
<label className="block mb-3">
<span className="text-tiny text-ink-muted"></span>
<select
value={textbookId}
onChange={(e) => setTextbookId(e.target.value)}
className="mt-1 w-full px-2 py-1.5 bg-transparent border-b border-rule text-sm text-ink"
>
{TEXTBOOKS.map((t) => (
<option key={t.id} value={t.id}>
{t.title}
</option>
))}
</select>
</label>
<label className="block">
<span className="text-tiny text-ink-muted"></span>
<input
type="text"
value={chapter}
onChange={(e) => setChapter(e.target.value)}
className="mt-1 w-full px-2 py-1.5 bg-transparent border-b border-rule text-sm text-ink"
/>
</label>
<label className="block mt-3">
<span className="text-tiny text-ink-muted"></span>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
className="mt-1 w-full px-2 py-1.5 bg-transparent border-b border-rule text-sm text-ink"
>
{STATUS_OPTIONS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</label>
</div>
<div className="p-5 border border-rule rounded-card bg-surface">
<h3 className="text-tiny uppercase tracking-wide text-ink-muted mb-3">
</h3>
{classes.length === 0 ? (
<p className="text-tiny text-ink-muted"></p>
) : (
<ul className="space-y-1.5">
{classes.map((c) => {
const checked = classIds.includes(c.id);
return (
<li key={c.id}>
<label className="flex items-center gap-2 text-sm text-ink">
<input
type="checkbox"
checked={checked}
onChange={(e) => {
if (e.target.checked) {
setClassIds((prev) => [...prev, c.id]);
} else {
setClassIds((prev) =>
prev.filter((id) => id !== c.id),
);
}
}}
className="accent-accent"
/>
{c.name}
</label>
</li>
);
})}
</ul>
)}
</div>
</aside>
{/* 中间:富文本编辑器 */}
<section>
<div className="mb-3">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-2 py-1.5 bg-transparent border-b border-rule text-xl font-serif text-ink focus:outline-none focus:border-accent"
placeholder="课案标题"
/>
</div>
<div className="mb-3 flex gap-1 border border-rule rounded-button p-1 bg-surface">
<button
type="button"
onClick={() => exec("bold")}
className="px-3 py-1 text-sm text-ink hover:bg-subtle rounded font-bold"
title="加粗"
>
B
</button>
<button
type="button"
onClick={() => exec("italic")}
className="px-3 py-1 text-sm text-ink hover:bg-subtle rounded italic"
title="斜体"
>
I
</button>
<button
type="button"
onClick={() => exec("formatBlock", "<h2>")}
className="px-3 py-1 text-sm text-ink hover:bg-subtle rounded font-serif"
title="标题"
>
H
</button>
<button
type="button"
onClick={() => exec("insertUnorderedList")}
className="px-3 py-1 text-sm text-ink hover:bg-subtle rounded"
title="无序列表"
>
</button>
<button
type="button"
onClick={() => exec("insertOrderedList")}
className="px-3 py-1 text-sm text-ink hover:bg-subtle rounded"
title="有序列表"
>
1.
</button>
<button
type="button"
onClick={() => setEditorContent(getEditorContent())}
className="px-3 py-1 text-sm text-ink hover:bg-subtle rounded ml-auto"
title="刷新内容"
>
</button>
</div>
<div
ref={editorRef}
contentEditable
suppressContentEditableWarning
className="min-h-[480px] p-5 border border-rule rounded-card bg-surface text-sm text-ink font-sans focus:outline-none focus:border-accent whitespace-pre-wrap"
role="textbox"
aria-label="课案内容编辑器"
/>
</section>
{/* 右侧AI 助手 */}
<aside className="space-y-6">
<div className="p-5 border border-rule rounded-card bg-surface">
<h3 className="text-tiny uppercase tracking-wide text-ink-muted mb-3">
AI
</h3>
<label className="block mb-3">
<span className="text-tiny text-ink-muted"></span>
<textarea
value={aiPrompt}
onChange={(e) => setAiPrompt(e.target.value)}
rows={4}
placeholder="如:为本节课生成更详细的练习题与板书设计"
className="mt-1 w-full px-2 py-1.5 bg-transparent border border-rule rounded text-sm text-ink resize-none focus:outline-none focus:border-accent"
/>
</label>
{aiError && (
<p className="text-tiny text-danger mb-2">{aiError}</p>
)}
<button
type="button"
onClick={handleAIGenerate}
disabled={generating || !aiPrompt}
className="w-full px-3 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
>
{generating ? "生成中…" : "AI 生成教案"}
</button>
<p className="mt-2 text-tiny text-ink-muted">
</p>
</div>
<div className="p-5 border border-rule rounded-card bg-surface">
<h3 className="text-tiny uppercase tracking-wide text-ink-muted mb-3">
</h3>
{aiHistory.length === 0 ? (
<p className="text-tiny text-ink-muted"></p>
) : (
<ul className="space-y-3">
{aiHistory.map((h) => (
<li
key={h.id}
className="pb-3 border-b border-rule last:border-b-0"
>
<p className="text-sm text-ink">{h.prompt}</p>
<p className="mt-1 text-tiny text-ink-muted font-mono">
{h.model} · {h.generatedAt.slice(0, 16).replace("T", " ")}
</p>
</li>
))}
</ul>
)}
</div>
</aside>
</div>
</div>
);
}

View File

@@ -0,0 +1,217 @@
"use client";
/**
* 课案日历视图P7-insights
*
* 数据来源GraphQL LessonPlanCalendarQuery
*
* 功能:
* - 月份切换
* - 日历网格(每天显示课案数,点击展开当天课案列表)
* - CSS Grid 实现
*
* 维护者ai13teacher-portal
*/
import { useState, useMemo } from "react";
import { useQuery } from "urql";
import Link from "next/link";
import { Loading } from "@edu/ui-components";
import { LessonPlanCalendarQuery } from "@/lib/graphql-p7-insights";
import type { LessonPlanCalendarDay } from "@/lib/graphql-p7-insights";
const WEEKDAYS = ["一", "二", "三", "四", "五", "六", "日"];
export default function LessonPlanCalendarPage(): JSX.Element {
const today = new Date();
const [year, setYear] = useState<number>(today.getFullYear());
const [month, setMonth] = useState<number>(today.getMonth() + 1);
const [selectedDay, setSelectedDay] = useState<LessonPlanCalendarDay | null>(
null,
);
const [result] = useQuery({
query: LessonPlanCalendarQuery,
variables: { year, month },
});
const calendar = result.data?.lessonPlanCalendar;
/** 月首日 weekday周一为 0 */
const firstWeekday = useMemo(() => {
const first = new Date(year, month - 1, 1);
// JS getDay周日=0周一=1...;我们以周一为起始,转换
return (first.getDay() + 6) % 7;
}, [year, month]);
/** 月份天数 */
const daysInMonth = useMemo(
() => new Date(year, month, 0).getDate(),
[year, month],
);
const goPrevMonth = (): void => {
if (month === 1) {
setYear((y) => y - 1);
setMonth(12);
} else {
setMonth((m) => m - 1);
}
setSelectedDay(null);
};
const goNextMonth = (): void => {
if (month === 12) {
setYear((y) => y + 1);
setMonth(1);
} else {
setMonth((m) => m + 1);
}
setSelectedDay(null);
};
/** 按日期生成单元格(含前置空格) */
const cells: Array<{ day: number; data: LessonPlanCalendarDay | null }> = useMemo(() => {
const list: Array<{ day: number; data: LessonPlanCalendarDay | null }> = [];
for (let i = 0; i < firstWeekday; i++) {
list.push({ day: 0, data: null });
}
const dayMap = new Map<string, LessonPlanCalendarDay>();
calendar?.days.forEach((d: LessonPlanCalendarDay) => dayMap.set(d.date, d));
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = `${year}-${String(month).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
list.push({ day: d, data: dayMap.get(dateStr) ?? null });
}
return list;
}, [firstWeekday, daysInMonth, calendar, year, month]);
return (
<div className="px-10 py-10">
<header className="mb-8">
<Link
href="/lesson-plans"
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
<h1 className="mt-3 text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL LessonPlanCalendarQuery · P7-insights
</p>
</header>
<div className="rule-thin mb-8" />
{/* 月份切换 */}
<div className="mb-6 flex items-center justify-between">
<h2 className="text-xl font-serif text-ink">
{year} {month}
</h2>
<div className="flex gap-2">
<button
type="button"
onClick={goPrevMonth}
className="px-3 py-1.5 text-sm text-ink border border-rule rounded-button hover:bg-subtle"
>
</button>
<button
type="button"
onClick={goNextMonth}
className="px-3 py-1.5 text-sm text-ink border border-rule rounded-button hover:bg-subtle"
>
</button>
</div>
</div>
{result.fetching ? (
<Loading lines={5} />
) : result.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{result.error.message}
</p>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 日历网格 */}
<div className="lg:col-span-2">
<div className="grid grid-cols-7 gap-1 mb-2">
{WEEKDAYS.map((w) => (
<div
key={w}
className="text-center text-tiny uppercase tracking-wide text-ink-muted py-2"
>
{w}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{cells.map((c, i) => {
if (c.day === 0) {
return <div key={`empty-${i}`} className="aspect-square" />;
}
const data = c.data;
const count = data?.planCount ?? 0;
const isSelected =
selectedDay?.date === (data?.date ?? "");
return (
<button
key={`day-${c.day}`}
type="button"
onClick={() => setSelectedDay(data)}
className={`aspect-square p-2 border rounded text-left transition-colors ${
isSelected
? "border-accent bg-accent/10"
: count > 0
? "border-rule bg-surface hover:bg-subtle"
: "border-rule bg-transparent hover:bg-subtle"
}`}
>
<div className="text-sm font-serif text-ink">{c.day}</div>
{count > 0 && (
<div className="mt-1 text-tiny font-mono text-accent">
{count}
</div>
)}
</button>
);
})}
</div>
</div>
{/* 当天课案列表 */}
<aside className="p-5 border border-rule rounded-card bg-surface">
<h3 className="text-tiny uppercase tracking-wide text-ink-muted mb-3">
</h3>
{!selectedDay || selectedDay.planCount === 0 ? (
<p className="text-sm text-ink-muted">
{selectedDay
? `${selectedDay.date} 当天无课案`
: "点击日历选择某一天查看课案列表"}
</p>
) : (
<>
<p className="text-sm text-ink mb-3 font-serif">
{selectedDay.date}
</p>
<ul className="space-y-2">
{selectedDay.planTitles.map((t, i) => (
<li
key={i}
className="text-sm text-ink py-2 border-b border-rule"
>
{t}
</li>
))}
</ul>
</>
)}
</aside>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,377 @@
"use client";
/**
* 课标覆盖热力图页
*
* 数据来源P7 扩展MSW mock
* - GraphQL LessonPlanHeatmapQuery按教材 ID 拉取热力图(教材知识点 × 课案覆盖矩阵)
*
* 布局:
* - 顶部:教材选择 select + 导出 CSV 按钮
* - 热力图矩阵X 轴教材知识点Y 轴:课案)
* - 每个格子颜色深浅表示覆盖度0-3浅 → 深)
* - SVG 实现hover 显示 tooltip知识点名/课案名/覆盖数)
*
* 维护者ai13teacher-portal
*/
import { useState, useMemo } from "react";
import { useQuery } from "urql";
import Link from "next/link";
import { Loading, Empty } from "@edu/ui-components";
import { LessonPlanHeatmapQuery } from "@/lib/graphql-p7-advanced";
import type { LessonPlanHeatmap as HeatmapData } from "@/lib/graphql-p7-advanced";
import { listHeatmapTextbooks } from "@/mocks/fixtures/lesson-plan-heatmap";
/** 教材选项mock从 fixture 获取) */
const TEXTBOOK_OPTIONS = listHeatmapTextbooks();
/** SVG 布局常量 */
const CELL_W = 22;
const CELL_H = 26;
const LABEL_COL_W = 160; // Y 轴标签(课案名)宽度
const LABEL_ROW_H = 60; // X 轴标签(知识点名)高度
const HEADER_PADDING = 10;
/** 按覆盖度映射填充色语义令牌变量0 最浅3 最深) */
function coverageColor(coverage: number): string {
switch (coverage) {
case 0:
return "var(--color-surface)";
case 1:
return "var(--color-accent-soft, hsl(var(--accent-h) 60% 80%))";
case 2:
return "var(--color-accent-muted, hsl(var(--accent-h) 55% 65%))";
case 3:
return "var(--color-accent)";
default:
return "var(--color-surface)";
}
}
/** 按 coverage 取浅色文字(深色格子用反色文字) */
function textColor(coverage: number): string {
return coverage >= 2
? "var(--color-ink-on-accent)"
: "var(--color-ink-muted)";
}
/** Tooltip 状态 */
interface TooltipState {
kpName: string;
planName: string;
coverage: number;
x: number;
y: number;
}
export default function LessonPlanHeatmapPage(): React.ReactNode {
const [textbookId, setTextbookId] = useState(
TEXTBOOK_OPTIONS[0]?.textbookId ?? "",
);
const [result] = useQuery({
query: LessonPlanHeatmapQuery,
variables: { textbookId },
pause: !textbookId,
});
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const heatmap: HeatmapData | null = result.data?.lessonPlanHeatmap ?? null;
// 构建 cell 查找 Map`${kpId}|${planId}` → coverage
const cellMap = useMemo(() => {
const m = new Map<string, number>();
if (heatmap) {
for (const cell of heatmap.cells) {
m.set(`${cell.kpId}|${cell.planId}`, cell.coverage);
}
}
return m;
}, [heatmap]);
// SVG 尺寸
const kpCount = heatmap?.knowledgePoints.length ?? 0;
const planCount = heatmap?.lessonPlans.length ?? 0;
const svgWidth = LABEL_COL_W + kpCount * CELL_W + HEADER_PADDING;
const svgHeight = LABEL_ROW_H + planCount * CELL_H + HEADER_PADDING;
/** 导出 CSV */
const handleExportCsv = () => {
if (!heatmap) return;
const header = [
"课案/知识点",
...heatmap.knowledgePoints.map((kp) => kp.name),
].join(",");
const rows = heatmap.lessonPlans.map((plan) => {
const cells = heatmap.knowledgePoints.map((kp) => {
const cov = cellMap.get(`${kp.kpId}|${plan.planId}`) ?? 0;
return String(cov);
});
return [plan.title, ...cells].join(",");
});
const csv = [header, ...rows].join("\n");
// 添加 BOM 以支持 Excel 中文
const blob = new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `heatmap-${heatmap.textbookId}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
return (
<div className="px-10 py-10">
<header className="mb-8">
<p className="mb-2">
<Link
href="/lesson-plans"
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
</p>
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL LessonPlanHeatmapQuery · core-edu P7 MSW mock
</p>
</header>
<div className="rule-thin mb-8" />
{/* 操作栏:教材选择 + 导出 CSV */}
<section className="mb-6 flex items-end justify-between gap-4">
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<select
value={textbookId}
onChange={(e) => setTextbookId(e.target.value)}
className="px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
>
{TEXTBOOK_OPTIONS.map((tb) => (
<option key={tb.textbookId} value={tb.textbookId}>
{tb.textbookTitle}{tb.subject}
</option>
))}
</select>
</div>
{heatmap && (
<button
type="button"
onClick={handleExportCsv}
className="px-4 py-2 text-sm text-ink bg-transparent border border-rule rounded-button hover:bg-subtle"
>
CSV
</button>
)}
</section>
{result.fetching ? (
<Loading lines={8} />
) : result.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{result.error.message}
</p>
</div>
) : !heatmap ? (
<Empty title="未找到热力图数据" description="请选择其他教材" />
) : heatmap.knowledgePoints.length === 0 ? (
<Empty title="暂无知识点" description="该教材无知识点数据" />
) : (
<section>
{/* 图例 */}
<div className="mb-4 flex items-center gap-4">
<span className="text-tiny uppercase tracking-wide text-ink-muted">
</span>
{[0, 1, 2, 3].map((c) => (
<div key={c} className="flex items-center gap-2">
<span
className="inline-block w-4 h-4 rounded-button"
style={{ background: coverageColor(c) }}
/>
<span className="text-tiny text-ink-muted">{c} </span>
</div>
))}
</div>
{/* SVG 热力图(可滚动容器) */}
<div
className="relative overflow-auto border border-rule rounded-card bg-paper"
style={{ maxHeight: "70vh" }}
onMouseLeave={() => setTooltip(null)}
>
<svg
width={svgWidth}
height={svgHeight}
role="img"
aria-label={`${heatmap.textbookTitle} 课标覆盖热力图`}
>
{/* X 轴标签:知识点名(旋转 -60° */}
<g>
{heatmap.knowledgePoints.map((kp, i) => {
const x = LABEL_COL_W + i * CELL_W + CELL_W / 2;
const y = LABEL_ROW_H - 8;
return (
<text
key={kp.kpId}
x={x}
y={y}
textAnchor="end"
fontSize="11"
fontFamily="var(--font-family-sans)"
fill="var(--color-ink-muted)"
transform={`rotate(-60, ${x}, ${y})`}
>
{kp.name}
</text>
);
})}
</g>
{/* Y 轴标签:课案名 */}
<g>
{heatmap.lessonPlans.map((plan, j) => {
const y = LABEL_ROW_H + j * CELL_H + CELL_H / 2 + 4;
return (
<text
key={plan.planId}
x={LABEL_COL_W - 8}
y={y}
textAnchor="end"
fontSize="11"
fontFamily="var(--font-family-sans)"
fill="var(--color-ink)"
>
{plan.title}
</text>
);
})}
</g>
{/* 单元格 */}
<g>
{heatmap.knowledgePoints.map((kp, i) =>
heatmap.lessonPlans.map((plan, j) => {
const cellX = LABEL_COL_W + i * CELL_W;
const cellY = LABEL_ROW_H + j * CELL_H;
const coverage = cellMap.get(`${kp.kpId}|${plan.planId}`) ?? 0;
return (
<rect
key={`${kp.kpId}-${plan.planId}`}
x={cellX + 1}
y={cellY + 1}
width={CELL_W - 2}
height={CELL_H - 2}
fill={coverageColor(coverage)}
stroke="var(--color-rule)"
strokeWidth={0.5}
onMouseEnter={() =>
setTooltip({
kpName: kp.name,
planName: plan.title,
coverage,
x: cellX + CELL_W,
y: cellY,
})
}
onMouseLeave={() => setTooltip(null)}
className="cursor-pointer"
>
<title>{`${kp.name} × ${plan.title}${coverage} 课案`}</title>
</rect>
);
}),
)}
</g>
{/* 单元格内覆盖数文字(仅 coverage > 0 显示) */}
<g pointerEvents="none">
{heatmap.knowledgePoints.map((kp, i) =>
heatmap.lessonPlans.map((plan, j) => {
const coverage = cellMap.get(`${kp.kpId}|${plan.planId}`) ?? 0;
if (coverage === 0) return null;
const cellX = LABEL_COL_W + i * CELL_W + CELL_W / 2;
const cellY = LABEL_ROW_H + j * CELL_H + CELL_H / 2 + 4;
return (
<text
key={`text-${kp.kpId}-${plan.planId}`}
x={cellX}
y={cellY}
textAnchor="middle"
fontSize="10"
fontFamily="var(--font-family-mono)"
fill={textColor(coverage)}
>
{coverage}
</text>
);
}),
)}
</g>
</svg>
{/* Tooltiphover 显示) */}
{tooltip && (
<div
className="absolute pointer-events-none p-2 border border-rule rounded-card bg-paper text-tiny"
style={{
left: `${tooltip.x + 8}px`,
top: `${tooltip.y + 8}px`,
zIndex: 10,
}}
>
<p className="font-serif text-ink">{tooltip.kpName}</p>
<p className="text-ink-muted">{tooltip.planName}</p>
<p className="mt-1 text-accent">
{tooltip.coverage}
</p>
</div>
)}
</div>
{/* 统计摘要 */}
<div className="mt-6 grid grid-cols-3 gap-4">
<StatCard
label="知识点数"
value={heatmap.knowledgePoints.length}
/>
<StatCard
label="课案数"
value={heatmap.lessonPlans.length}
/>
<StatCard
label="总覆盖数"
value={heatmap.cells.reduce((acc, c) => acc + c.coverage, 0)}
/>
</div>
</section>
)}
</div>
);
}
/** 统计卡片子组件 */
function StatCard({
label,
value,
}: {
label: string;
value: number;
}): React.ReactNode {
return (
<div className="p-4 border border-rule rounded-card bg-surface">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
{label}
</p>
<p className="mt-2 text-2xl font-serif text-ink">{value}</p>
</div>
);
}

View File

@@ -0,0 +1,185 @@
"use client";
/**
* 校内课案库P7-insights
*
* 数据来源GraphQL LessonPlanLibraryQuery + ForkLessonPlanMutation
*
* 功能:
* - 学科 + 年级筛选
* - 列表:标题/作者/学科/年级/章节/评分/Fork 数/操作
* - "Fork 到我的课案" 调用 ForkLessonPlanMutation
*
* 维护者ai13teacher-portal
*/
import { useState } from "react";
import { useQuery, useMutation } from "urql";
import Link from "next/link";
import { Loading, Empty } from "@edu/ui-components";
import {
LessonPlanLibraryQuery,
ForkLessonPlanMutation,
} from "@/lib/graphql-p7-insights";
import type { LessonPlanLibraryItem } from "@/lib/graphql-p7-insights";
const SUBJECT_FILTERS = ["全部", "语文", "数学", "英语"] as const;
const GRADE_FILTERS = [
"全部",
"小学三年级",
"小学六年级",
"初中一年级",
"高中一年级",
"高中三年级",
] as const;
export default function LessonPlanLibraryPage(): JSX.Element {
const [subject, setSubject] = useState<string>("全部");
const [grade, setGrade] = useState<string>("全部");
const [forkingId, setForkingId] = useState<string | null>(null);
const [forkedIds, setForkedIds] = useState<Set<string>>(new Set());
const [error, setError] = useState<string | null>(null);
const [result] = useQuery({
query: LessonPlanLibraryQuery,
variables:
subject === "全部" && grade === "全部"
? {}
: {
subject: subject === "全部" ? null : subject,
grade: grade === "全部" ? null : grade,
},
});
const [, forkLessonPlan] = useMutation(ForkLessonPlanMutation);
const items: LessonPlanLibraryItem[] = result.data?.lessonPlanLibrary ?? [];
const handleFork = async (planId: string): Promise<void> => {
setForkingId(planId);
setError(null);
const res = await forkLessonPlan({ planId });
setForkingId(null);
if (res.error || !res.data?.forkLessonPlan) {
setError(res.error?.message ?? "Fork 失败");
return;
}
setForkedIds((prev) => {
const next = new Set(prev);
next.add(planId);
return next;
});
};
return (
<div className="px-10 py-10">
<header className="mb-8">
<Link
href="/lesson-plans"
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
<h1 className="mt-3 text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL LessonPlanLibraryQuery · ForkP7-insights
</p>
</header>
<div className="rule-thin mb-8" />
{/* 筛选 */}
<div className="mb-6 flex items-baseline gap-6 flex-wrap">
<div className="flex items-center gap-3">
<label className="text-tiny uppercase tracking-wide text-ink-muted">
</label>
<select
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
>
{SUBJECT_FILTERS.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
<div className="flex items-center gap-3">
<label className="text-tiny uppercase tracking-wide text-ink-muted">
</label>
<select
value={grade}
onChange={(e) => setGrade(e.target.value)}
className="px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
>
{GRADE_FILTERS.map((g) => (
<option key={g} value={g}>
{g}
</option>
))}
</select>
</div>
</div>
{error && (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">{error}</p>
</div>
)}
{result.fetching ? (
<Loading lines={5} />
) : items.length === 0 ? (
<Empty
title="暂无课案"
description="当前筛选条件下没有可 Fork 的校内课案"
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{items.map((item) => {
const forked = forkedIds.has(item.id);
const forking = forkingId === item.id;
return (
<article
key={item.id}
className="p-5 border border-rule rounded-card bg-surface flex flex-col gap-3"
>
<h3 className="font-serif text-lg text-ink">{item.title}</h3>
<div className="flex flex-wrap gap-1.5">
<span className="px-2 py-0.5 rounded bg-subtle text-tiny text-ink">
{item.subject}
</span>
<span className="px-2 py-0.5 rounded bg-subtle text-tiny text-ink">
{item.grade}
</span>
<span className="px-2 py-0.5 rounded bg-subtle text-tiny text-ink-muted">
{item.chapter}
</span>
</div>
<p className="text-tiny text-ink-muted">
{item.author} · Fork {item.forkCount} · {" "}
<span className="font-mono text-accent">{item.rating}</span>
</p>
<button
type="button"
onClick={() => handleFork(item.id)}
disabled={forking || forked}
className="mt-2 px-3 py-1.5 text-tiny text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed"
>
{forking
? "Fork 中…"
: forked
? "已 Fork ✓"
: "Fork 到我的课案"}
</button>
</article>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,220 @@
"use client";
/**
* 新建课案页P7-insights
*
* 数据来源GraphQL CreateLessonPlanMutation
*
* 功能:
* - 模板选择器5 个模板)
* - 表单:标题/学科/年级/章节/教材关联
* - "选择模板后创建" 跳转到 /lesson-plans/[planId]/edit
*
* 维护者ai13teacher-portal
*/
import { useState } from "react";
import { useMutation } from "urql";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Loading } from "@edu/ui-components";
import { CreateLessonPlanMutation } from "@/lib/graphql-p7-insights";
import type {
CreateLessonPlanInput,
LessonPlanTemplate,
} from "@/lib/graphql-p7-insights";
import { LESSON_PLAN_TEMPLATES } from "@/mocks/fixtures/lesson-plans";
const SUBJECTS = ["语文", "数学", "英语"] as const;
const GRADES = [
"小学三年级",
"小学六年级",
"初中一年级",
"高中一年级",
"高中三年级",
] as const;
export default function NewLessonPlanPage(): JSX.Element {
const router = useRouter();
const [template, setTemplate] = useState<LessonPlanTemplate>("REGULAR");
const [title, setTitle] = useState("");
const [subject, setSubject] = useState<string>(SUBJECTS[0] ?? "数学");
const [grade, setGrade] = useState<string>(GRADES[0] ?? "小学三年级");
const [chapter, setChapter] = useState("");
const [textbookId, setTextbookId] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [, createLessonPlan] = useMutation(CreateLessonPlanMutation);
const handleCreate = async (): Promise<void> => {
if (!title || !chapter) {
setError("请填写标题与章节");
return;
}
setSubmitting(true);
setError(null);
const input: CreateLessonPlanInput = {
title,
subject,
grade,
chapter,
textbookId: textbookId || null,
template,
};
const res = await createLessonPlan({ input });
setSubmitting(false);
if (res.error || !res.data?.createLessonPlan) {
setError(res.error?.message ?? "创建失败");
return;
}
const created = res.data.createLessonPlan as { id: string };
router.push(`/lesson-plans/${created.id}/edit`);
};
return (
<div className="px-10 py-10">
<header className="mb-8">
<Link
href="/lesson-plans"
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
<h1 className="mt-3 text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL CreateLessonPlanMutation · P7-insights
</p>
</header>
<div className="rule-thin mb-8" />
{/* 模板选择器 */}
<section className="mb-10">
<h2 className="text-xl font-serif text-ink mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{LESSON_PLAN_TEMPLATES.map((t) => {
const active = t.id === template;
return (
<button
key={t.id}
type="button"
onClick={() => setTemplate(t.id)}
className={`p-4 border rounded-card text-left transition-colors ${
active
? "border-accent bg-accent/10"
: "border-rule bg-surface hover:bg-subtle"
}`}
>
<p className="font-serif text-ink">{t.name}</p>
<p className="mt-1 text-tiny text-ink-muted">{t.description}</p>
</button>
);
})}
</div>
</section>
{/* 表单 */}
<section className="mb-8 max-w-2xl">
<h2 className="text-xl font-serif text-ink mb-4"></h2>
{submitting ? (
<Loading lines={4} />
) : (
<div className="grid grid-cols-2 gap-4">
<label className="col-span-2 flex flex-col gap-1">
<span className="text-tiny uppercase tracking-wide text-ink-muted">
</span>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="如:函数的单调性与极值"
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink focus:outline-none focus:border-accent"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-tiny uppercase tracking-wide text-ink-muted">
</span>
<select
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink"
>
{SUBJECTS.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-tiny uppercase tracking-wide text-ink-muted">
</span>
<select
value={grade}
onChange={(e) => setGrade(e.target.value)}
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink"
>
{GRADES.map((g) => (
<option key={g} value={g}>
{g}
</option>
))}
</select>
</label>
<label className="col-span-2 flex flex-col gap-1">
<span className="text-tiny uppercase tracking-wide text-ink-muted">
</span>
<input
type="text"
value={chapter}
onChange={(e) => setChapter(e.target.value)}
placeholder="如:选修 2-2·导数应用"
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink focus:outline-none focus:border-accent"
/>
</label>
<label className="col-span-2 flex flex-col gap-1">
<span className="text-tiny uppercase tracking-wide text-ink-muted">
</span>
<input
type="text"
value={textbookId}
onChange={(e) => setTextbookId(e.target.value)}
placeholder="如tb-001"
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink focus:outline-none focus:border-accent"
/>
</label>
</div>
)}
{error && (
<div className="mt-4 mark-left py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">{error}</p>
</div>
)}
<div className="mt-6 flex gap-3">
<button
type="button"
onClick={handleCreate}
disabled={submitting}
className="px-5 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
>
{submitting ? "创建中…" : "选择模板后创建"}
</button>
<Link
href="/lesson-plans"
className="px-5 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle"
>
</Link>
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,162 @@
"use client";
/**
* 课案列表页P7-insights
*
* 数据来源02-architecture-design.md §6 备课):
* - GraphQL LessonPlansQuery
*
* 功能:
* - 学科筛选
* - 列表:标题/学科/年级/章节/状态/更新时间/操作
* - "新建课案" 链接(→ /lesson-plans/new
* - "校库" 链接(→ /lesson-plans/library
* - "日历视图" 链接(→ /lesson-plans/calendar
*
* 维护者ai13teacher-portal
*/
import { useState } from "react";
import { useQuery } from "urql";
import Link from "next/link";
import { Loading, Empty } from "@edu/ui-components";
import { LessonPlansQuery } from "@/lib/graphql-p7-insights";
import type { LessonPlanItem, LessonPlanStatus } from "@/lib/graphql-p7-insights";
const SUBJECT_FILTERS = ["全部", "语文", "数学", "英语"] as const;
const STATUS_LABEL: Record<LessonPlanStatus, string> = {
DRAFT: "草稿",
PUBLISHED: "已发布",
ARCHIVED: "已归档",
};
function formatDate(iso: string): string {
return iso.slice(0, 16).replace("T", " ");
}
export default function LessonPlansPage(): JSX.Element {
const [subject, setSubject] = useState<string>("全部");
const [result] = useQuery({
query: LessonPlansQuery,
variables: subject === "全部" ? {} : { subject },
});
const plans: LessonPlanItem[] = result.data?.lessonPlans ?? [];
return (
<div className="px-10 py-10">
<header className="mb-8">
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL LessonPlansQuery · P7-insightsMSW mock
</p>
</header>
<div className="rule-thin mb-8" />
{/* 工具栏 */}
<div className="mb-6 flex items-baseline justify-between flex-wrap gap-4">
<div className="flex items-center gap-3">
<label className="text-tiny uppercase tracking-wide text-ink-muted">
</label>
<select
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
>
{SUBJECT_FILTERS.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
<div className="flex gap-3">
<Link
href="/lesson-plans/new"
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
>
</Link>
<Link
href="/lesson-plans/library"
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle"
>
</Link>
<Link
href="/lesson-plans/calendar"
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle"
>
</Link>
</div>
</div>
{result.fetching ? (
<Loading lines={5} />
) : result.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{result.error.message}
</p>
</div>
) : plans.length === 0 ? (
<Empty
title="暂无课案"
description="点击右上角「新建课案」开始创建第一份课案"
/>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-tiny uppercase tracking-wide text-ink-muted border-b border-rule">
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal text-right"></th>
</tr>
</thead>
<tbody>
{plans.map((p) => (
<tr key={p.id} className="border-b border-rule">
<td className="py-3 pr-4">
<Link
href={`/lesson-plans/${p.id}/edit`}
className="font-serif text-ink hover:text-accent"
>
{p.title}
</Link>
</td>
<td className="py-3 pr-4 text-ink">{p.subject}</td>
<td className="py-3 pr-4 text-ink">{p.grade}</td>
<td className="py-3 pr-4 text-ink-muted">{p.chapter}</td>
<td className="py-3 pr-4">
<span className="px-2 py-0.5 rounded bg-subtle text-tiny text-ink">
{STATUS_LABEL[p.status]}
</span>
</td>
<td className="py-3 pr-4 text-ink-muted font-mono text-tiny">
{formatDate(p.updatedAt)}
</td>
<td className="py-3 pr-4 text-right">
<Link
href={`/lesson-plans/${p.id}/edit`}
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
>
</Link>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}

View File

@@ -0,0 +1,260 @@
"use client";
/**
* Notifications 页面 - 通知中心P5
*
* 数据来源02-architecture-design.md §5
* - GraphQL MyNotificationsQuery持久化通知列表
* - WebSocket 实时推送push-gatewayuse-notifications-websocket hook
*
* 功能:
* - 通知列表(标题、消息、时间、类型)
* - 未读高亮 + 标记已读(乐观更新) + 全部标记已读
* - 类型筛选(全部 / 未读 / 作业 / 考试 / 广播)
* - WebSocket 连接状态指示器
*
* 维护者ai13teacher-portal
*/
import { useMemo, useState } from "react";
import { useMutation, useQuery } from "urql";
import { Loading, Empty } from "@edu/ui-components";
import {
MyNotificationsQuery,
MarkAsReadMutation,
} from "@/lib/graphql-p5";
import type { NotificationItem, NotificationType } from "@/lib/graphql-p5";
import {
useNotificationsWebSocket,
NOTIFICATION_TYPE_LABEL,
} from "@/hooks/use-notifications-websocket";
import type { ConnectionState } from "@/hooks/use-notifications-websocket";
type FilterKey = "all" | "unread" | NotificationType;
const FILTERS: { key: FilterKey; label: string }[] = [
{ key: "all", label: "全部" },
{ key: "unread", label: "未读" },
{ key: "HOMEWORK_SUBMITTED", label: "作业" },
{ key: "EXAM_GRADED", label: "考试" },
{ key: "BROADCAST", label: "广播" },
];
function relativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
if (diff < minute) return "刚刚";
if (diff < hour) return `${Math.floor(diff / minute)} 分钟前`;
if (diff < day) return `${Math.floor(diff / hour)} 小时前`;
if (diff < 7 * day) return `${Math.floor(diff / day)} 天前`;
return new Date(iso).toLocaleDateString("zh-CN");
}
function ConnectionDot({ state }: { state: ConnectionState }): JSX.Element {
const map: Record<ConnectionState, { dot: string; text: string }> = {
connected: { dot: "bg-success", text: "已连接" },
connecting: { dot: "bg-warning", text: "连接中" },
disconnected: { dot: "bg-danger", text: "已断开" },
error: { dot: "bg-danger", text: "连接错误" },
};
const cfg = map[state];
return (
<span className="inline-flex items-center gap-2">
<span className={`inline-block w-2 h-2 rounded-full ${cfg.dot}`} />
<span className="text-tiny text-ink-muted">{cfg.text}</span>
</span>
);
}
export default function NotificationsPage(): JSX.Element {
const [filter, setFilter] = useState<FilterKey>("all");
const [readOverrides, setReadOverrides] = useState<Record<string, boolean>>(
{},
);
const [result] = useQuery({ query: MyNotificationsQuery });
const [, markAsRead] = useMutation(MarkAsReadMutation);
const { notifications: wsNotifications, connectionState, reconnect } =
useNotificationsWebSocket();
// 合并WebSocket 实时推送(新) + GraphQL 持久化列表,按 id 去重
const merged = useMemo<NotificationItem[]>(() => {
const persisted = (result.data?.myNotifications as NotificationItem[] | undefined) ?? [];
const seen = new Set<string>();
const list: NotificationItem[] = [];
for (const n of [...wsNotifications, ...persisted]) {
if (seen.has(n.id)) continue;
seen.add(n.id);
list.push(n);
}
return list;
}, [result.data, wsNotifications]);
const displayed = useMemo<NotificationItem[]>(() => {
const withOverrides = merged.map((n) => ({
...n,
read: readOverrides[n.id] ?? n.read,
}));
switch (filter) {
case "all":
return withOverrides;
case "unread":
return withOverrides.filter((n) => !n.read);
default:
return withOverrides.filter((n) => n.type === filter);
}
}, [merged, readOverrides, filter]);
const unreadCount = useMemo(
() =>
merged.filter((n) => !(readOverrides[n.id] ?? n.read)).length,
[merged, readOverrides],
);
const handleMarkAsRead = (id: string): void => {
setReadOverrides((prev) => ({ ...prev, [id]: true }));
void markAsRead({ id }).then((res) => {
if (res.error) {
setReadOverrides((prev) => ({ ...prev, [id]: false }));
}
});
};
const handleMarkAllRead = (): void => {
const unreadIds = merged
.filter((n) => !(readOverrides[n.id] ?? n.read))
.map((n) => n.id);
if (unreadIds.length === 0) return;
const overrides: Record<string, boolean> = {};
for (const id of unreadIds) overrides[id] = true;
setReadOverrides((prev) => ({ ...prev, ...overrides }));
for (const id of unreadIds) {
void markAsRead({ id }).then((res) => {
if (res.error) {
setReadOverrides((prev) => ({ ...prev, [id]: false }));
}
});
}
};
return (
<div className="px-10 py-10">
<header className="mb-8">
<div className="flex items-baseline justify-between">
<div>
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL MyNotificationsQuery + WebSocket P5
</p>
</div>
<ConnectionDot state={connectionState} />
</div>
</header>
<div className="rule-thin mb-8" />
{/* 筛选 + 操作 */}
<section className="mb-6 flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-2 flex-wrap">
{FILTERS.map((f) => (
<button
key={f.key}
type="button"
onClick={() => setFilter(f.key)}
className={`px-3 py-1 text-tiny uppercase tracking-wide rounded-button border transition-colors ${
filter === f.key
? "bg-accent text-ink-on-accent border-accent"
: "bg-transparent text-ink-muted border-rule hover:text-ink"
}`}
>
{f.label}
</button>
))}
</div>
<div className="flex items-center gap-4">
<span className="text-tiny text-ink-muted">
<span className="font-mono text-accent">{unreadCount}</span>
</span>
<button
type="button"
onClick={handleMarkAllRead}
disabled={unreadCount === 0}
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70 disabled:opacity-40 disabled:cursor-not-allowed"
>
</button>
<button
type="button"
onClick={reconnect}
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</button>
</div>
</section>
{result.fetching && merged.length === 0 ? (
<Loading lines={5} />
) : result.error && merged.length === 0 ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{result.error.message}
</p>
</div>
) : displayed.length === 0 ? (
<Empty title="暂无通知" description="当前筛选条件下没有通知" />
) : (
<ul className="space-y-0">
{displayed.map((n) => {
const isRead = readOverrides[n.id] ?? n.read;
return (
<li
key={n.id}
className={`py-4 grid grid-cols-12 gap-4 items-baseline border-b border-rule ${
isRead ? "" : "bg-subtle"
}`}
>
<div className="col-span-9 pl-2">
<div className="flex items-center gap-3 mb-1">
<span className="text-tiny uppercase tracking-wide px-2 py-0.5 rounded-button border border-rule text-ink-muted">
{NOTIFICATION_TYPE_LABEL[n.type]}
</span>
<h3
className={`text-lg font-serif ${
isRead ? "text-ink-muted" : "text-ink"
}`}
>
{n.title}
</h3>
{!isRead && (
<span className="inline-block w-2 h-2 rounded-full bg-accent" />
)}
</div>
<p className="text-sm text-ink-muted">{n.message}</p>
<p className="mt-1 text-tiny font-mono text-ink-subtle">
{relativeTime(n.createdAt)}
</p>
</div>
<div className="col-span-3 text-right">
{isRead ? (
<span className="text-tiny text-ink-subtle"></span>
) : (
<button
type="button"
onClick={() => handleMarkAsRead(n.id)}
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
>
</button>
)}
</div>
</li>
);
})}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,451 @@
"use client";
/**
* 自适应练习分析页P7-insights
*
* 数据来源GraphQL PracticeAnalyticsQuery
*
* 6 个 Widget
* 1. 班级筛选
* 2. 5 项统计卡片(总练习数/完成率/平均正确率/薄弱知识点/参与度)
* 3. 班级对比表5 班级 × 5 指标)
* 4. 类型分布饼图5 类型:单选/多选/判断/填空/简答)- 纯 SVG
* 5. 知识点薄弱度柱图10 知识点 × 正确率)- 纯 SVG
* 6. 学生排名表 + 未参与提醒
*
* 维护者ai13teacher-portal
*/
import { useState } from "react";
import { useQuery } from "urql";
import { Loading, Empty } from "@edu/ui-components";
import { PracticeAnalyticsQuery } from "@/lib/graphql-p7-insights";
import type {
PracticeAnalytics as PracticeAnalyticsType,
PracticeTypeBreakdown,
PracticeKpWeakness,
} from "@/lib/graphql-p7-insights";
const CLASSES = [
{ id: "", name: "全部班级" },
{ id: "550e8400-e29b-41d4-a716-446655440010", name: "高三(1)班" },
{ id: "550e8400-e29b-41d4-a716-446655440011", name: "高三(2)班" },
{ id: "550e8400-e29b-41d4-a716-446655440012", name: "高三(3)班" },
{ id: "550e8400-e29b-41d4-a716-446655440013", name: "高三(4)班" },
{ id: "550e8400-e29b-41d4-a716-446655440014", name: "高三(5)班" },
];
/** 饼图色板(使用语义令牌变量,禁止 hex 字面量) */
const PIE_COLORS = [
"var(--color-accent)",
"var(--color-success)",
"var(--color-warning)",
"var(--color-danger)",
"var(--color-ink-muted)",
];
/** 类型分布饼图 */
function TypePieChart({
data,
}: {
data: PracticeTypeBreakdown[];
}): React.ReactNode {
const W = 320;
const H = 240;
const cx = W / 2 - 30;
const cy = H / 2;
const r = 80;
const total = data.reduce((acc, d) => acc + d.count, 0) || 1;
function arcPath(
startAngle: number,
endAngle: number,
): string {
const x1 = cx + r * Math.cos(startAngle);
const y1 = cy + r * Math.sin(startAngle);
const x2 = cx + r * Math.cos(endAngle);
const y2 = cy + r * Math.sin(endAngle);
const largeArc = endAngle - startAngle > Math.PI ? 1 : 0;
return `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} Z`;
}
let accAngle = -Math.PI / 2;
const slices = data.map((d, i) => {
const angle = (d.count / total) * 2 * Math.PI;
const start = accAngle;
const end = accAngle + angle;
accAngle = end;
return { d, path: arcPath(start, end), color: PIE_COLORS[i % PIE_COLORS.length] ?? PIE_COLORS[0] };
});
return (
<svg
viewBox={`0 0 ${W} ${H}`}
className="w-full max-w-md border border-rule rounded-card bg-surface"
role="img"
aria-label="练习类型分布饼图"
>
{slices.map((s, i) => (
<path
key={i}
d={s.path}
fill={s.color}
fillOpacity={0.85}
stroke="var(--bg-paper)"
strokeWidth={2}
/>
))}
{/* 图例 */}
{slices.map((s, i) => {
const ly = 20 + i * 20;
return (
<g key={`legend-${i}`}>
<rect
x={W - 100}
y={ly - 9}
width={12}
height={12}
fill={s.color}
fillOpacity={0.85}
/>
<text
x={W - 82}
y={ly}
fontSize="10"
fontFamily="var(--font-family-sans)"
fill="var(--color-ink)"
>
{s.d.type}{Math.round(s.d.ratio * 100)}%
</text>
</g>
);
})}
</svg>
);
}
/** 知识点薄弱度柱图(垂直柱) */
function KpWeaknessBarChart({
data,
}: {
data: PracticeKpWeakness[];
}): React.ReactNode {
const W = 540;
const H = 240;
const PAD = { top: 20, right: 12, bottom: 60, left: 36 };
const innerW = W - PAD.left - PAD.right;
const innerH = H - PAD.top - PAD.bottom;
const barW = innerW / data.length - 6;
function accuracyColor(rate: number): string {
if (rate >= 0.8) return "var(--color-success)";
if (rate >= 0.6) return "var(--color-warning)";
return "var(--color-danger)";
}
return (
<svg
viewBox={`0 0 ${W} ${H}`}
className="w-full max-w-2xl border border-rule rounded-card bg-surface"
role="img"
aria-label="知识点正确率柱图"
>
{/* Y 轴网格 */}
{[0, 0.5, 1].map((r, i) => {
const y = PAD.top + innerH * r;
const v = Math.round(100 * (1 - r));
return (
<g key={`grid-${i}`}>
<line
x1={PAD.left}
y1={y}
x2={W - PAD.right}
y2={y}
stroke="var(--color-rule)"
strokeWidth={1}
strokeDasharray="3 3"
/>
<text
x={PAD.left - 8}
y={y + 4}
textAnchor="end"
fontSize="10"
fontFamily="var(--font-family-mono)"
fill="var(--color-ink-muted)"
>
{v}%
</text>
</g>
);
})}
{/* 柱子 */}
{data.map((d, i) => {
const x = PAD.left + i * (barW + 6) + 3;
const h = d.accuracy * innerH;
const y = PAD.top + innerH - h;
const color = accuracyColor(d.accuracy);
return (
<g key={d.knowledgePoint}>
<rect
x={x}
y={y}
width={barW}
height={h}
fill={color}
fillOpacity={0.85}
/>
<text
x={x + barW / 2}
y={y - 4}
textAnchor="middle"
fontSize="10"
fontFamily="var(--font-family-mono)"
fontWeight="600"
fill="var(--color-ink)"
>
{Math.round(d.accuracy * 100)}
</text>
<text
x={x + barW / 2}
y={H - PAD.bottom + 14}
textAnchor="middle"
fontSize="9"
fontFamily="var(--font-family-sans)"
fill="var(--color-ink-muted)"
>
{d.knowledgePoint.length > 4
? d.knowledgePoint.slice(0, 4) + "…"
: d.knowledgePoint}
</text>
</g>
);
})}
</svg>
);
}
export default function PracticePage(): JSX.Element {
const [classId, setClassId] = useState<string>("");
const [result] = useQuery({
query: PracticeAnalyticsQuery,
variables: { classId: classId || null },
});
const analytics: PracticeAnalyticsType | undefined =
result.data?.practiceAnalytics;
return (
<div className="px-10 py-10">
<header className="mb-8">
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL PracticeAnalyticsQuery · P7-insights
</p>
</header>
<div className="rule-thin mb-8" />
{/* 班级筛选 */}
<div className="mb-6 flex items-center gap-3">
<label className="text-tiny uppercase tracking-wide text-ink-muted">
</label>
<select
value={classId}
onChange={(e) => setClassId(e.target.value)}
className="px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
>
{CLASSES.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
{result.fetching ? (
<Loading lines={6} />
) : result.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{result.error.message}
</p>
</div>
) : !analytics ? (
<Empty title="暂无数据" description="该班级暂无练习数据" />
) : (
<>
{/* 5 项统计卡片 */}
<section className="mb-8 grid grid-cols-2 md:grid-cols-5 gap-4">
<div className="p-5 border border-rule rounded-card">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
</p>
<p className="mt-2 text-3xl font-serif text-ink">
{analytics.summary.totalSessions}
</p>
</div>
<div className="p-5 border border-rule rounded-card">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
</p>
<p className="mt-2 text-3xl font-serif text-success">
{Math.round(analytics.summary.completionRate * 100)}%
</p>
</div>
<div className="p-5 border border-rule rounded-card">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
</p>
<p className="mt-2 text-3xl font-serif text-accent">
{Math.round(analytics.summary.avgAccuracy * 100)}%
</p>
</div>
<div className="p-5 border border-rule rounded-card">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
</p>
<p className="mt-2 text-3xl font-serif text-warning">
{analytics.summary.weakKpCount}
</p>
</div>
<div className="p-5 border border-rule rounded-card">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
</p>
<p className="mt-2 text-3xl font-serif text-ink">
{Math.round(analytics.summary.participationRate * 100)}%
</p>
</div>
</section>
{/* 班级对比表 */}
<section className="mb-8">
<h2 className="text-xl font-serif text-ink mb-4"></h2>
<div className="rule-thin mb-6" />
<table className="w-full text-sm">
<thead>
<tr className="text-left text-tiny uppercase tracking-wide text-ink-muted border-b border-rule">
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal"></th>
</tr>
</thead>
<tbody>
{analytics.classComparison.map((c) => (
<tr key={c.classId} className="border-b border-rule">
<td className="py-3 pr-4 text-ink">{c.className}</td>
<td className="py-3 pr-4 font-mono text-ink">
{c.totalSessions}
</td>
<td className="py-3 pr-4 font-mono text-success">
{Math.round(c.completionRate * 100)}%
</td>
<td className="py-3 pr-4 font-mono text-accent">
{Math.round(c.avgAccuracy * 100)}%
</td>
<td className="py-3 pr-4 font-mono text-ink-muted">
{Math.round(c.participationRate * 100)}%
</td>
<td className="py-3 pr-4 font-mono text-warning">
{c.weakKpCount}
</td>
</tr>
))}
</tbody>
</table>
</section>
{/* 类型分布饼图 + 知识点薄弱度柱图 */}
<section className="mb-8 grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<h2 className="text-xl font-serif text-ink mb-4">
</h2>
<div className="rule-thin mb-6" />
<TypePieChart data={analytics.typeBreakdown} />
</div>
<div>
<h2 className="text-xl font-serif text-ink mb-4">
</h2>
<div className="rule-thin mb-6" />
<KpWeaknessBarChart data={analytics.kpWeakness} />
</div>
</section>
{/* 学生排名表 */}
<section className="mb-8">
<h2 className="text-xl font-serif text-ink mb-4"></h2>
<div className="rule-thin mb-6" />
<table className="w-full text-sm">
<thead>
<tr className="text-left text-tiny uppercase tracking-wide text-ink-muted border-b border-rule">
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal"></th>
<th className="py-3 pr-4 font-normal"></th>
</tr>
</thead>
<tbody>
{analytics.rankings.map((r) => (
<tr key={r.studentId} className="border-b border-rule">
<td className="py-3 pr-4 font-serif text-ink-muted">
{r.rank}
</td>
<td className="py-3 pr-4 font-mono text-ink-muted text-tiny">
{r.studentNo}
</td>
<td className="py-3 pr-4 text-ink">{r.studentName}</td>
<td className="py-3 pr-4 font-mono text-ink">
{r.totalSessions}
</td>
<td className="py-3 pr-4 font-mono text-accent">
{Math.round(r.accuracy * 100)}%
</td>
<td className="py-3 pr-4 text-ink-muted">
{r.weakKp}
</td>
</tr>
))}
</tbody>
</table>
</section>
{/* 未参与提醒 */}
<section>
<h2 className="text-xl font-serif text-ink mb-4">
7
</h2>
<div className="rule-thin mb-6" />
{analytics.inactiveStudents.length === 0 ? (
<p className="text-sm text-ink-muted"> </p>
) : (
<div className="p-4 border border-warning rounded-card bg-warning/10">
<ul className="space-y-2">
{analytics.inactiveStudents.map((s) => (
<li
key={s.studentId}
className="flex items-baseline justify-between text-sm"
>
<span className="text-ink">{s.studentName}</span>
<span className="font-mono text-tiny text-warning">
{s.lastActiveDays}
</span>
</li>
))}
</ul>
</div>
)}
</section>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,886 @@
"use client";
/**
* 题库主页 - 六维筛选 + CRUD + 批量导入导出
*
* 数据来源P7 扩展MSW mock
* - GraphQL QuestionsLibraryQuery题库列表六维筛选 + 分页 + 总数)
* - GraphQL QuestionCreate/Update/Delete题目 CRUD
* - GraphQL QuestionBatchImport批量导入
* - GraphQL QuestionBatchExport批量导出查询后用 Blob 下载 JSON
*
* 维护者ai13teacher-portal
*/
import { useState, useMemo, useCallback, useRef, useEffect } from "react";
import { useQuery, useMutation } from "urql";
import Link from "next/link";
import { Loading, Empty } from "@edu/ui-components";
import {
QuestionsLibraryQuery,
QuestionCreateMutation,
QuestionUpdateMutation,
QuestionDeleteMutation,
QuestionBatchImportMutation,
QuestionBatchExportQuery,
} from "@/lib/graphql-p7-exams";
import type {
QuestionItem,
QuestionType,
QuestionDifficulty,
QuestionsLibraryFilter,
QuestionCreateInput,
QuestionUpdateInput,
QuestionBatchImportItem,
} from "@/lib/graphql-p7-exams";
import { MOCK_TEXTBOOKS, MOCK_TEXTBOOK_DETAILS } from "@/mocks/fixtures/textbooks";
import {
QUESTION_TYPE_OPTIONS,
QUESTION_DIFFICULTY_OPTIONS,
QUESTION_TYPE_LABEL,
QUESTION_DIFFICULTY_LABEL,
} from "@/mocks/fixtures/questions";
/** 类型中文 */
const TYPE_LABEL: Record<QuestionType, string> = QUESTION_TYPE_LABEL;
/** 难度中文 */
const DIFFICULTY_LABEL: Record<QuestionDifficulty, string> =
QUESTION_DIFFICULTY_LABEL;
/** 编辑/新建弹窗的表单状态 */
interface QuestionFormState {
questionId: string | null; // null 表示新建
content: string;
type: QuestionType;
difficulty: QuestionDifficulty;
textbookId: string;
chapterId: string;
kpId: string;
score: string;
answer: string;
analysis: string;
}
/** 初始空表单 */
function emptyForm(): QuestionFormState {
return {
questionId: null,
content: "",
type: "SINGLE_CHOICE",
difficulty: "MEDIUM",
textbookId: "",
chapterId: "",
kpId: "",
score: "5",
answer: "",
analysis: "",
};
}
/** 从 QuestionItem 填充表单 */
function fillForm(q: QuestionItem): QuestionFormState {
return {
questionId: q.questionId,
content: q.content,
type: q.type,
difficulty: q.difficulty,
textbookId: q.textbookId ?? "",
chapterId: q.chapterId ?? "",
kpId: q.kpId ?? "",
score: String(q.score),
answer: q.answer,
analysis: q.analysis,
};
}
export default function QuestionsPage(): React.ReactNode {
// === 筛选条件 ===
const [filterQ, setFilterQ] = useState("");
const [filterType, setFilterType] = useState<QuestionType | "ALL">("ALL");
const [filterDifficulty, setFilterDifficulty] = useState<
QuestionDifficulty | "ALL"
>("ALL");
const [filterTextbook, setFilterTextbook] = useState("all");
const [filterChapter, setFilterChapter] = useState("all");
const [filterKp, setFilterKp] = useState("all");
const [page, setPage] = useState(1);
const pageSize = 20;
// === 编辑弹窗状态 ===
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState<QuestionFormState>(emptyForm());
const [dialogError, setDialogError] = useState<string | null>(null);
const [dialogSaving, setDialogSaving] = useState(false);
// === 导入导出状态 ===
const [importMsg, setImportMsg] = useState<string | null>(null);
const [exportMsg, setExportMsg] = useState<string | null>(null);
const [exportTriggered, setExportTriggered] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
// 当前选中教材 → 章节列表
const dialogChapters = useMemo(() => {
if (!form.textbookId) return [];
const tb = MOCK_TEXTBOOK_DETAILS.find((t) => t.id === form.textbookId);
return tb?.chapters ?? [];
}, [form.textbookId]);
const dialogKps = useMemo(() => {
if (!form.chapterId) return [];
const ch = dialogChapters.find((c) => c.id === form.chapterId);
return ch?.knowledgePoints ?? [];
}, [form.chapterId, dialogChapters]);
// 筛选栏的章节列表(按筛选教材联动)
const filterChapterOptions = useMemo(() => {
if (filterTextbook === "all") return [];
const tb = MOCK_TEXTBOOK_DETAILS.find((t) => t.id === filterTextbook);
return tb?.chapters ?? [];
}, [filterTextbook]);
const filterKpOptions = useMemo(() => {
if (filterChapter === "all") return [];
const ch = filterChapterOptions.find((c) => c.id === filterChapter);
return ch?.knowledgePoints ?? [];
}, [filterChapter, filterChapterOptions]);
// === GraphQL 查询 ===
const filter: QuestionsLibraryFilter = useMemo(
() => ({
q: filterQ || null,
type: filterType === "ALL" ? null : filterType,
difficulty: filterDifficulty === "ALL" ? null : filterDifficulty,
textbook: filterTextbook === "all" ? null : filterTextbook,
chapter: filterChapter === "all" ? null : filterChapter,
kp: filterKp === "all" ? null : filterKp,
page,
pageSize,
}),
[
filterQ,
filterType,
filterDifficulty,
filterTextbook,
filterChapter,
filterKp,
page,
],
);
const [result] = useQuery({
query: QuestionsLibraryQuery,
variables: { filter },
});
// === Mutations ===
const [, createQuestion] = useMutation(QuestionCreateMutation);
const [, updateQuestion] = useMutation(QuestionUpdateMutation);
const [, deleteQuestion] = useMutation(QuestionDeleteMutation);
const [, batchImport] = useMutation(QuestionBatchImportMutation);
const [exportResult, batchExport] = useQuery({
query: QuestionBatchExportQuery,
variables: { filter },
pause: true, // 按需触发
});
const items: QuestionItem[] =
(result.data?.questionsLibrary.items as QuestionItem[] | undefined) ?? [];
const total = result.data?.questionsLibrary.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
// === 操作 ===
const openCreate = useCallback(() => {
setForm(emptyForm());
setDialogError(null);
setDialogOpen(true);
}, []);
const openEdit = useCallback((q: QuestionItem) => {
setForm(fillForm(q));
setDialogError(null);
setDialogOpen(true);
}, []);
const handleDelete = useCallback(
async (q: QuestionItem) => {
if (!window.confirm(`确认删除题目 ${q.questionId}`)) return;
const res = await deleteQuestion({ questionId: q.questionId });
if (res.error) {
window.alert(`删除失败:${res.error.message}`);
}
},
[deleteQuestion],
);
const submitForm = async () => {
setDialogError(null);
if (!form.content.trim()) {
setDialogError("请填写题干");
return;
}
if (!form.answer.trim()) {
setDialogError("请填写答案");
return;
}
setDialogSaving(true);
if (form.questionId) {
// 更新
const input: QuestionUpdateInput = {
questionId: form.questionId,
content: form.content.trim(),
type: form.type,
difficulty: form.difficulty,
textbookId: form.textbookId || null,
chapterId: form.chapterId || null,
kpId: form.kpId || null,
score: Number(form.score) || 0,
answer: form.answer.trim(),
analysis: form.analysis.trim() || null,
};
const res = await updateQuestion({ input });
setDialogSaving(false);
if (res.error) {
setDialogError(res.error.message);
return;
}
} else {
// 新建
const input: QuestionCreateInput = {
content: form.content.trim(),
type: form.type,
difficulty: form.difficulty,
textbookId: form.textbookId || null,
chapterId: form.chapterId || null,
kpId: form.kpId || null,
score: Number(form.score) || 0,
answer: form.answer.trim(),
analysis: form.analysis.trim() || null,
};
const res = await createQuestion({ input });
setDialogSaving(false);
if (res.error) {
setDialogError(res.error.message);
return;
}
}
setDialogOpen(false);
};
// 批量导入:解析 JSON 文件
const handleImportClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = async (
e: React.ChangeEvent<HTMLInputElement>,
) => {
const file = e.target.files?.[0];
if (!file) return;
setImportMsg(null);
try {
const text = await file.text();
const parsed = JSON.parse(text) as unknown;
if (!Array.isArray(parsed)) {
setImportMsg("导入失败JSON 根节点必须是数组");
return;
}
const items: QuestionBatchImportItem[] = parsed
.filter(
(it): it is QuestionBatchImportItem =>
typeof it === "object" && it !== null,
)
.map((it) => ({
content: String(it.content ?? ""),
type: (it.type as QuestionType) ?? "SINGLE_CHOICE",
difficulty: (it.difficulty as QuestionDifficulty) ?? "MEDIUM",
textbookId: it.textbookId ?? null,
chapterId: it.chapterId ?? null,
kpId: it.kpId ?? null,
score: Number(it.score) || 0,
answer: String(it.answer ?? ""),
analysis: it.analysis ?? null,
}));
if (items.length === 0) {
setImportMsg("导入失败:未解析到有效题目");
return;
}
const res = await batchImport({ input: { items } });
if (res.error) {
setImportMsg(`导入失败:${res.error.message}`);
return;
}
const data = res.data?.questionBatchImport;
if (data) {
setImportMsg(
`导入完成:成功 ${data.importedCount} 题,失败 ${data.failedCount}`,
);
}
} catch (err) {
setImportMsg(
`导入失败:${err instanceof Error ? err.message : String(err)}`,
);
} finally {
// 重置 input 以便重复选择同一文件
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
};
// 批量导出:触发查询后由 useEffect 监听结果变化下载 JSON
const handleExport = () => {
setExportMsg(null);
setExportTriggered(true);
batchExport();
};
// 监听导出查询结果:数据到达后生成 Blob 下载
useEffect(() => {
if (!exportTriggered) return;
const data = exportResult.data?.questionBatchExport;
if (data) {
const json = JSON.stringify(
{
exportedAt: data.exportedAt,
count: data.items.length,
items: data.items,
},
null,
2,
);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `questions-export-${Date.now()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setExportMsg(`已导出 ${data.items.length}`);
setExportTriggered(false);
}
if (exportResult.error) {
setExportMsg(`导出失败:${exportResult.error.message}`);
setExportTriggered(false);
}
}, [exportTriggered, exportResult.data, exportResult.error]);
const resetFilters = () => {
setFilterQ("");
setFilterType("ALL");
setFilterDifficulty("ALL");
setFilterTextbook("all");
setFilterChapter("all");
setFilterKp("all");
setPage(1);
};
return (
<div className="px-10 py-10">
<header className="mb-8 flex items-baseline justify-between">
<div>
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL QuestionsLibraryQuery · content P7 MSW mock
</p>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleImportClick}
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle"
>
</button>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileChange}
className="hidden"
/>
<button
type="button"
onClick={handleExport}
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle"
>
</button>
<button
type="button"
onClick={openCreate}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
>
</button>
</div>
</header>
<div className="rule-thin mb-8" />
{/* 筛选栏 */}
<section className="mb-6">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
<input
type="text"
value={filterQ}
onChange={(e) => {
setFilterQ(e.target.value);
setPage(1);
}}
placeholder="关键词(题干/知识点)"
className="px-3 py-1.5 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
/>
<select
value={filterType}
onChange={(e) => {
setFilterType(e.target.value as QuestionType | "ALL");
setPage(1);
}}
className="px-2 py-1.5 bg-transparent border border-rule rounded-button text-tiny text-ink focus:outline-none"
>
<option value="ALL"></option>
{QUESTION_TYPE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<select
value={filterDifficulty}
onChange={(e) => {
setFilterDifficulty(e.target.value as QuestionDifficulty | "ALL");
setPage(1);
}}
className="px-2 py-1.5 bg-transparent border border-rule rounded-button text-tiny text-ink focus:outline-none"
>
<option value="ALL"></option>
{QUESTION_DIFFICULTY_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<select
value={filterTextbook}
onChange={(e) => {
setFilterTextbook(e.target.value);
setFilterChapter("all");
setFilterKp("all");
setPage(1);
}}
className="px-2 py-1.5 bg-transparent border border-rule rounded-button text-tiny text-ink focus:outline-none"
>
<option value="all"></option>
{MOCK_TEXTBOOKS.map((tb) => (
<option key={tb.id} value={tb.id}>
{tb.title}
</option>
))}
</select>
<select
value={filterChapter}
onChange={(e) => {
setFilterChapter(e.target.value);
setFilterKp("all");
setPage(1);
}}
disabled={filterChapterOptions.length === 0}
className="px-2 py-1.5 bg-transparent border border-rule rounded-button text-tiny text-ink focus:outline-none disabled:opacity-50"
>
<option value="all"></option>
{filterChapterOptions.map((c) => (
<option key={c.id} value={c.id}>
{c.title}
</option>
))}
</select>
<select
value={filterKp}
onChange={(e) => {
setFilterKp(e.target.value);
setPage(1);
}}
disabled={filterKpOptions.length === 0}
className="px-2 py-1.5 bg-transparent border border-rule rounded-button text-tiny text-ink focus:outline-none disabled:opacity-50"
>
<option value="all"></option>
{filterKpOptions.map((k) => (
<option key={k.id} value={k.id}>
{k.name}
</option>
))}
</select>
</div>
<div className="mt-3 flex items-center gap-3">
<button
type="button"
onClick={resetFilters}
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</button>
{importMsg && <span className="text-tiny text-ink-muted">{importMsg}</span>}
{exportMsg && <span className="text-tiny text-success">{exportMsg}</span>}
</div>
</section>
{/* 数据表 */}
{result.fetching ? (
<Loading lines={6} />
) : result.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{result.error.message}
</p>
</div>
) : items.length === 0 ? (
<Empty
title="暂无题目"
description="调整筛选条件或新建题目"
action={
<button
type="button"
onClick={openCreate}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
>
</button>
}
/>
) : (
<div className="border border-rule rounded-card overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-subtle">
<tr className="border-b border-rule">
<th className="text-left py-2 px-3 text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="text-left py-2 px-3 text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="text-left py-2 px-3 text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="text-left py-2 px-3 text-tiny uppercase tracking-wide text-ink-muted">
-
</th>
<th className="text-right py-2 px-3 text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="text-right py-2 px-3 text-tiny uppercase tracking-wide text-ink-muted">
</th>
</tr>
</thead>
<tbody>
{items.map((q) => (
<tr
key={q.questionId}
className="border-b border-rule hover:bg-subtle"
>
<td className="py-2 px-3 text-ink max-w-md">
<p className="line-clamp-2 whitespace-pre-line">
{q.content.slice(0, 100)}
{q.content.length > 100 ? "..." : ""}
</p>
</td>
<td className="py-2 px-3 text-ink-muted">
{TYPE_LABEL[q.type]}
</td>
<td className="py-2 px-3 text-ink-muted">
{DIFFICULTY_LABEL[q.difficulty]}
</td>
<td className="py-2 px-3 text-ink-muted text-tiny">
{q.textbookName ?? "-"}
<br />
{q.chapterName ?? "-"}
</td>
<td className="py-2 px-3 text-right font-mono text-ink">
{q.score}
</td>
<td className="py-2 px-3 text-right">
<button
type="button"
onClick={() => openEdit(q)}
className="text-tiny font-mono text-accent hover:opacity-70 mr-3"
>
</button>
<button
type="button"
onClick={() => handleDelete(q)}
className="text-tiny font-mono text-danger hover:opacity-70"
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* 分页 */}
{total > 0 && (
<div className="mt-4 flex items-center justify-between text-tiny text-ink-muted">
<span>
{page} / {totalPages} {total}
</span>
<div className="flex gap-2">
<button
type="button"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="px-3 py-1 border border-rule rounded-button disabled:opacity-30 hover:bg-subtle"
>
</button>
<button
type="button"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="px-3 py-1 border border-rule rounded-button disabled:opacity-30 hover:bg-subtle"
>
</button>
</div>
</div>
)}
{/* 编辑/新建弹窗 */}
{dialogOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-paper/80"
onClick={() => !dialogSaving && setDialogOpen(false)}
>
<div
className="bg-paper border border-rule rounded-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-xl font-serif text-ink mb-4">
{form.questionId ? "编辑题目" : "新建题目"}
</h2>
<div className="rule-thin mb-4" />
<div className="space-y-4">
{/* 题干 */}
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
<span className="text-danger">*</span>
</label>
<textarea
value={form.content}
onChange={(e) =>
setForm((f) => ({ ...f, content: e.target.value }))
}
rows={4}
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
placeholder="支持换行、选项等"
/>
</div>
{/* 类型 + 难度 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
<span className="text-danger">*</span>
</label>
<select
value={form.type}
onChange={(e) =>
setForm((f) => ({
...f,
type: e.target.value as QuestionType,
}))
}
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
>
{QUESTION_TYPE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
<span className="text-danger">*</span>
</label>
<select
value={form.difficulty}
onChange={(e) =>
setForm((f) => ({
...f,
difficulty: e.target.value as QuestionDifficulty,
}))
}
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
>
{QUESTION_DIFFICULTY_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
</div>
{/* 教材 / 章节 / 知识点 */}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<select
value={form.textbookId}
onChange={(e) =>
setForm((f) => ({
...f,
textbookId: e.target.value,
chapterId: "",
kpId: "",
}))
}
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-tiny text-ink focus:outline-none"
>
<option value=""></option>
{MOCK_TEXTBOOKS.map((tb) => (
<option key={tb.id} value={tb.id}>
{tb.title}
</option>
))}
</select>
</div>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<select
value={form.chapterId}
onChange={(e) =>
setForm((f) => ({
...f,
chapterId: e.target.value,
kpId: "",
}))
}
disabled={dialogChapters.length === 0}
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-tiny text-ink focus:outline-none disabled:opacity-50"
>
<option value=""></option>
{dialogChapters.map((c) => (
<option key={c.id} value={c.id}>
{c.title}
</option>
))}
</select>
</div>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<select
value={form.kpId}
onChange={(e) =>
setForm((f) => ({ ...f, kpId: e.target.value }))
}
disabled={dialogKps.length === 0}
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-tiny text-ink focus:outline-none disabled:opacity-50"
>
<option value=""></option>
{dialogKps.map((k) => (
<option key={k.id} value={k.id}>
{k.name}
</option>
))}
</select>
</div>
</div>
{/* 分值 */}
<div className="w-32">
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
<span className="text-danger">*</span>
</label>
<input
type="number"
min={0}
value={form.score}
onChange={(e) =>
setForm((f) => ({ ...f, score: e.target.value }))
}
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink font-mono focus:outline-none"
/>
</div>
{/* 答案 */}
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
<span className="text-danger">*</span>
</label>
<textarea
value={form.answer}
onChange={(e) =>
setForm((f) => ({ ...f, answer: e.target.value }))
}
rows={3}
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
/>
</div>
{/* 解析 */}
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<textarea
value={form.analysis}
onChange={(e) =>
setForm((f) => ({ ...f, analysis: e.target.value }))
}
rows={3}
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
/>
</div>
{/* 错误提示 */}
{dialogError && (
<div className="mark-left py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">{dialogError}</p>
</div>
)}
{/* 操作按钮 */}
<div className="flex items-center gap-3 pt-2">
<button
type="button"
onClick={submitForm}
disabled={dialogSaving}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
>
{dialogSaving
? "保存中..."
: form.questionId
? "保存修改"
: "创建题目"}
</button>
<button
type="button"
onClick={() => setDialogOpen(false)}
disabled={dialogSaving}
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</button>
</div>
</div>
</div>
</div>
)}
<p className="mt-8 text-tiny text-ink-muted">
<Link
href="/dashboard"
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
</p>
</div>
);
}

View File

@@ -0,0 +1,629 @@
"use client";
/**
* 调课申请页 - 教师调课申请管理
*
* 数据来源P7 扩展MSW mock
* - GraphQL ScheduleChangesQuery调课申请列表 + 分页
* - GraphQL SubmitScheduleChangeMutation提交调课/代课申请
*
* 功能:
* - 状态筛选(全部/待审批/已批准/已拒绝)
* - 类型筛选(全部/调课/代课)
* - 提交调课申请模态框(原班级/原日期/原节次/调整后日期/类型/代课教师/原因)
* - 状态徽章(待审批黄/已批准绿/已拒绝红)
*
* 维护者ai13teacher-portal
*/
import { useState, useMemo } from "react";
import { useQuery, useMutation } from "urql";
import { Loading, Empty } from "@edu/ui-components";
import { ClassesQuery } from "@/lib/graphql";
import type { Class } from "@/lib/graphql";
import {
ScheduleChangesQuery,
SubmitScheduleChangeMutation,
} from "@/lib/graphql-p7-admin";
import type {
ScheduleChange,
ScheduleChangeType,
ScheduleChangeStatus,
SubmitScheduleChangeInput,
} from "@/lib/graphql-p7-admin";
import {
SCHEDULE_CHANGE_TYPE_LABEL,
SCHEDULE_CHANGE_STATUS_LABEL,
} from "@/mocks/fixtures/leave-requests";
const TYPE_FILTERS: { value: ScheduleChangeType | "ALL"; label: string }[] = [
{ value: "ALL", label: "全部" },
{ value: "ADJUST", label: "调课" },
{ value: "SUBSTITUTE", label: "代课" },
];
const STATUS_FILTERS: {
value: ScheduleChangeStatus | "ALL";
label: string;
}[] = [
{ value: "ALL", label: "全部" },
{ value: "PENDING", label: "待审批" },
{ value: "APPROVED", label: "已批准" },
{ value: "REJECTED", label: "已拒绝" },
];
const PERIOD_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8];
const SUBJECT_OPTIONS = [
"数学",
"语文",
"英语",
"物理",
"化学",
"生物",
] as const;
function statusBadgeClass(status: ScheduleChangeStatus): string {
switch (status) {
case "PENDING":
return "text-warning border-warning";
case "APPROVED":
return "text-success border-success";
case "REJECTED":
return "text-danger border-danger";
default:
return "text-ink-muted border-rule";
}
}
function todayStr(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function dateOffset(days: number): string {
const d = new Date();
d.setDate(d.getDate() + days);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
export default function ScheduleChangesPage(): React.ReactNode {
const [typeFilter, setTypeFilter] = useState<ScheduleChangeType | "ALL">(
"ALL",
);
const [statusFilter, setStatusFilter] = useState<
ScheduleChangeStatus | "ALL"
>("ALL");
const [page, setPage] = useState(1);
const pageSize = 20;
// 提交对话框状态
const [showSubmit, setShowSubmit] = useState(false);
const [formClassId, setFormClassId] = useState("");
const [formOriginalDate, setFormOriginalDate] = useState(todayStr());
const [formOriginalPeriod, setFormOriginalPeriod] = useState(1);
const [formOriginalSubject, setFormOriginalSubject] = useState<string>(
SUBJECT_OPTIONS[0] ?? "数学",
);
const [formAdjustedDate, setFormAdjustedDate] = useState(dateOffset(1));
const [formAdjustedPeriod, setFormAdjustedPeriod] = useState(1);
const [formAdjustedSubject, setFormAdjustedSubject] = useState<string>(
SUBJECT_OPTIONS[0] ?? "数学",
);
const [formType, setFormType] = useState<ScheduleChangeType>("ADJUST");
const [formSubstituteTeacherId, setFormSubstituteTeacherId] = useState("");
const [formReason, setFormReason] = useState("");
const [formSubmitting, setFormSubmitting] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [formSuccess, setFormSuccess] = useState<string | null>(null);
const [classesResult] = useQuery({ query: ClassesQuery });
const classes: Class[] = classesResult.data?.classes ?? [];
const firstClassId = classes[0]?.id ?? "";
const [result, reexecute] = useQuery({
query: ScheduleChangesQuery,
variables: { page, pageSize },
});
const [, submitChange] = useMutation(SubmitScheduleChangeMutation);
const data = result.data?.scheduleChanges;
const allItems: ScheduleChange[] = data?.items ?? [];
const total = data?.total ?? 0;
// 客户端二次筛选type/status
const filteredItems = useMemo(() => {
return allItems.filter((item) => {
if (typeFilter !== "ALL" && item.type !== typeFilter) return false;
if (statusFilter !== "ALL" && item.status !== statusFilter) return false;
return true;
});
}, [allItems, typeFilter, statusFilter]);
const handleSubmit = async (): Promise<void> => {
const targetClassId = formClassId || firstClassId;
if (!targetClassId) {
setFormError("请选择班级");
return;
}
if (!formOriginalDate || !formAdjustedDate) {
setFormError("请填写原日期和调整后日期");
return;
}
if (!formReason.trim()) {
setFormError("请填写调课原因");
return;
}
setFormSubmitting(true);
setFormError(null);
setFormSuccess(null);
const input: SubmitScheduleChangeInput = {
classId: targetClassId,
originalDate: formOriginalDate,
originalPeriod: formOriginalPeriod,
originalSubject: formOriginalSubject,
adjustedDate: formAdjustedDate,
adjustedPeriod: formAdjustedPeriod,
adjustedSubject: formAdjustedSubject,
substituteTeacherId: formSubstituteTeacherId.trim() || null,
type: formType,
reason: formReason.trim(),
};
const res = await submitChange({ input });
setFormSubmitting(false);
if (res.error) {
setFormError(res.error.message);
return;
}
setFormSuccess(
`已提交:${res.data?.submitScheduleChange?.requestId ?? "—"},等待审批`,
);
// 重置表单
setFormReason("");
reexecute({ requestPolicy: "network-only" });
};
const closeSubmit = (): void => {
setShowSubmit(false);
setFormError(null);
setFormSuccess(null);
setFormReason("");
};
return (
<div className="px-10 py-10">
<header className="mb-8 flex items-baseline justify-between">
<div>
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL ScheduleChangesQuery + SubmitScheduleChangeMutationP7
MSW mock
</p>
</div>
<button
type="button"
onClick={() => {
setShowSubmit(true);
setFormSuccess(null);
}}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
>
</button>
</header>
<div className="rule-thin mb-8" />
{/* 筛选栏 */}
<section className="mb-8">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-2 flex-wrap">
<label className="text-tiny uppercase tracking-wide text-ink-muted">
</label>
{TYPE_FILTERS.map((t) => {
const active = typeFilter === t.value;
return (
<button
key={t.value}
type="button"
onClick={() => setTypeFilter(t.value)}
className={`px-3 py-1 text-tiny uppercase tracking-wide rounded-button border transition-colors ${
active
? "bg-accent text-ink-on-accent border-accent"
: "bg-transparent text-ink-muted border-rule hover:text-ink"
}`}
>
{t.label}
</button>
);
})}
</div>
<div className="flex items-center gap-2 flex-wrap">
<label className="text-tiny uppercase tracking-wide text-ink-muted">
</label>
{STATUS_FILTERS.map((s) => {
const active = statusFilter === s.value;
return (
<button
key={s.value}
type="button"
onClick={() => setStatusFilter(s.value)}
className={`px-3 py-1 text-tiny uppercase tracking-wide rounded-button border transition-colors ${
active
? "bg-accent text-ink-on-accent border-accent"
: "bg-transparent text-ink-muted border-rule hover:text-ink"
}`}
>
{s.label}
</button>
);
})}
</div>
</div>
</section>
{result.fetching ? (
<Loading lines={5} />
) : result.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{result.error.message}
</p>
</div>
) : filteredItems.length === 0 ? (
<Empty title="暂无调课申请" description="当前筛选条件下没有记录" />
) : (
<section>
<div className="flex items-baseline justify-between mb-4">
<h2 className="text-xl font-serif text-ink">
<span className="ml-2 text-sm font-sans text-ink-muted">
{filteredItems.length} {total}
</span>
</h2>
</div>
<div className="rule-thin mb-4" />
<div className="border border-rule rounded-card overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-subtle">
<tr>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
</th>
</tr>
</thead>
<tbody>
{filteredItems.map((r) => (
<tr key={r.id} className="border-t border-rule">
<td className="px-4 py-3">
<p className="font-serif text-ink">{r.requesterName}</p>
{r.substituteTeacherName && (
<p className="text-tiny text-ink-muted">
{r.substituteTeacherName}
</p>
)}
</td>
<td className="px-4 py-3 text-sm text-ink">
{r.className}
</td>
<td className="px-4 py-3 font-mono text-tiny text-ink-muted">
<p>{r.originalDate}</p>
<p>
{r.originalPeriod} · {r.originalSubject}
</p>
</td>
<td className="px-4 py-3 font-mono text-tiny text-ink-muted">
<p>{r.adjustedDate}</p>
<p>
{r.adjustedPeriod} · {r.adjustedSubject}
</p>
</td>
<td className="px-4 py-3 text-sm text-ink">
{SCHEDULE_CHANGE_TYPE_LABEL[r.type]}
</td>
<td className="px-4 py-3 text-tiny text-ink-muted max-w-xs">
<p className="truncate">{r.reason}</p>
{r.reviewComment && (
<p className="mt-1 text-tiny text-ink-subtle">
{r.reviewComment}
</p>
)}
</td>
<td className="px-4 py-3">
<span
className={`inline-block px-2 py-0.5 rounded-button text-tiny font-mono border ${statusBadgeClass(
r.status,
)}`}
>
{SCHEDULE_CHANGE_STATUS_LABEL[r.status]}
</span>
{r.reviewedAt && (
<p className="mt-1 text-tiny font-mono text-ink-muted">
{r.reviewedAt.slice(0, 10)}
</p>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 分页 */}
<div className="mt-6 flex items-center justify-between">
<button
type="button"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle disabled:opacity-40 disabled:cursor-not-allowed"
>
</button>
<span className="text-tiny text-ink-muted">
{page} {pageSize}
</span>
<button
type="button"
onClick={() => setPage((p) => p + 1)}
disabled={allItems.length < pageSize}
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle disabled:opacity-40 disabled:cursor-not-allowed"
>
</button>
</div>
</section>
)}
{/* 提交调课对话框 */}
{showSubmit && (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: "hsl(var(--color-ink) / 0.3)" }}
role="dialog"
aria-modal="true"
>
<div className="w-full max-w-2xl p-8 bg-paper border border-rule rounded-card max-h-[90vh] overflow-y-auto">
<div className="flex items-baseline justify-between mb-4">
<h3 className="text-xl font-serif text-ink"></h3>
<button
type="button"
onClick={closeSubmit}
className="text-tiny text-ink-muted hover:opacity-70"
>
</button>
</div>
<div className="rule-thin mb-4" />
{formSuccess ? (
<div className="mb-4 p-4 border border-rule rounded-card bg-subtle">
<p className="text-sm text-success">{formSuccess}</p>
<button
type="button"
onClick={() => setFormSuccess(null)}
className="mt-2 text-tiny text-accent hover:opacity-70"
>
</button>
</div>
) : null}
{/* 班级 + 类型 */}
<div className="mb-4 grid grid-cols-2 gap-4">
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<select
value={formClassId || firstClassId}
onChange={(e) => setFormClassId(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
>
{classes.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<select
value={formType}
onChange={(e) =>
setFormType(e.target.value as ScheduleChangeType)
}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
>
<option value="ADJUST"></option>
<option value="SUBSTITUTE"></option>
</select>
</div>
</div>
{/* 原课程 */}
<div className="mb-4 p-4 border border-rule rounded-card bg-surface">
<p className="mb-3 text-tiny uppercase tracking-wide text-ink-muted">
</p>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-tiny text-ink-muted mb-1">
</label>
<input
type="date"
value={formOriginalDate}
onChange={(e) => setFormOriginalDate(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-mono text-ink focus:outline-none focus:border-accent"
/>
</div>
<div>
<label className="block text-tiny text-ink-muted mb-1">
</label>
<select
value={formOriginalPeriod}
onChange={(e) =>
setFormOriginalPeriod(Number(e.target.value))
}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-mono text-ink focus:outline-none focus:border-accent"
>
{PERIOD_OPTIONS.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</div>
<div>
<label className="block text-tiny text-ink-muted mb-1">
</label>
<select
value={formOriginalSubject}
onChange={(e) => setFormOriginalSubject(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
>
{SUBJECT_OPTIONS.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
</div>
</div>
{/* 调整后 */}
<div className="mb-4 p-4 border border-rule rounded-card bg-surface">
<p className="mb-3 text-tiny uppercase tracking-wide text-ink-muted">
</p>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-tiny text-ink-muted mb-1">
</label>
<input
type="date"
value={formAdjustedDate}
onChange={(e) => setFormAdjustedDate(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-mono text-ink focus:outline-none focus:border-accent"
/>
</div>
<div>
<label className="block text-tiny text-ink-muted mb-1">
</label>
<select
value={formAdjustedPeriod}
onChange={(e) =>
setFormAdjustedPeriod(Number(e.target.value))
}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-mono text-ink focus:outline-none focus:border-accent"
>
{PERIOD_OPTIONS.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</div>
<div>
<label className="block text-tiny text-ink-muted mb-1">
</label>
<select
value={formAdjustedSubject}
onChange={(e) => setFormAdjustedSubject(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
>
{SUBJECT_OPTIONS.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
</div>
{formType === "SUBSTITUTE" && (
<div className="mt-3">
<label className="block text-tiny text-ink-muted mb-1">
ID
</label>
<input
type="text"
value={formSubstituteTeacherId}
onChange={(e) =>
setFormSubstituteTeacherId(e.target.value)
}
placeholder="例如u-002"
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-mono text-ink focus:outline-none focus:border-accent"
/>
</div>
)}
</div>
{/* 原因 */}
<div className="mb-4">
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
</label>
<textarea
value={formReason}
onChange={(e) => setFormReason(e.target.value)}
rows={3}
placeholder="请填写调课原因"
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none focus:border-accent"
/>
</div>
{formError && (
<p className="mb-3 text-tiny text-danger">{formError}</p>
)}
<div className="flex items-center justify-end gap-3">
<button
type="button"
onClick={closeSubmit}
disabled={formSubmitting}
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle disabled:opacity-50"
>
</button>
<button
type="button"
onClick={handleSubmit}
disabled={formSubmitting}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
>
{formSubmitting ? "提交中..." : "提交申请"}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,29 +1,97 @@
"use client";
/**
* Settings 页面 - 个人设置
* Settings 页面 - 个人设置P3 启用编辑模式)
*
* 数据来源ARB-001 §1.2GraphQL MeQuery只读展示
* - P2 阶段仅展示当前用户信息P2 纯读,无 Mutation
* - P3+ 启用 UpdateUserMutation 后开启编辑功能
* 数据来源ARB-001 §1.2
* - GraphQL MeQuery读取当前用户信息
* - GraphQL UpdateUserMutationP3 扩展MSW mock更新姓名/邮箱
* - 编辑按钮切换只读/编辑模式
* - 乐观更新:本地立即展示新值;失败回滚由错误提示
*
* 维护者ai13teacher-portal
*/
import { useQuery } from "urql";
import { useState, useEffect } from "react";
import { useQuery, useMutation } from "urql";
import { Loading } from "@edu/ui-components";
import { MeQuery } from "@/lib/graphql";
import { MeQuery, UpdateUserMutation } from "@/lib/graphql";
export default function SettingsPage() {
const [result] = useQuery({ query: MeQuery });
const [result, reexecuteQuery] = useQuery({ query: MeQuery });
const [updateResult, updateUser] = useMutation(UpdateUserMutation);
const [editing, setEditing] = useState(false);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// 进入编辑模式时用当前用户数据预填表单
useEffect(() => {
if (editing && result.data?.me) {
setName(result.data.me.name);
setEmail(result.data.me.email);
setError(null);
}
}, [editing, result.data?.me]);
const handleStartEdit = () => {
setEditing(true);
};
const handleCancel = () => {
setEditing(false);
setError(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!name.trim()) {
setError("姓名不能为空");
return;
}
if (!email.trim() || !email.includes("@")) {
setError("邮箱格式不正确");
return;
}
setSubmitting(true);
// 乐观更新:本地立即显示新值(更新成功后 reexecuteQuery 拉取最新数据)
const res = await updateUser({
input: { name: name.trim(), email: email.trim() },
});
setSubmitting(false);
if (res.error) {
setError(res.error.message);
return;
}
// 成功后重新拉取 me 刷新展示
reexecuteQuery({ requestPolicy: "network-only" });
setEditing(false);
};
return (
<div className="px-10 py-10">
<header className="mb-8">
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL MeQuery · P2 P3
</p>
<header className="mb-8 flex items-baseline justify-between">
<div>
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL MeQuery + UpdateUserMutation · P3
</p>
</div>
{!editing && result.data?.me && (
<button
type="button"
onClick={handleStartEdit}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
>
</button>
)}
</header>
<div className="rule-thin mb-8" />
@@ -38,6 +106,66 @@ export default function SettingsPage() {
</div>
) : !result.data?.me ? (
<p className="text-sm italic text-ink-muted"></p>
) : editing ? (
<section className="max-w-2xl">
<div className="mb-6">
<h2 className="text-xl font-serif text-ink mb-2"></h2>
<div className="rule-thin mb-4" />
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
<span className="text-danger">*</span>
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm text-ink focus:outline-none focus:border-b-2"
required
/>
</div>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
<span className="text-danger">*</span>
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm text-ink focus:outline-none focus:border-b-2"
required
/>
</div>
{error && (
<div className="mark-left py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">{error}</p>
</div>
)}
<div className="flex items-center gap-4 pt-2">
<button
type="submit"
disabled={submitting}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
>
{submitting ? "保存中..." : "保存"}
</button>
<button
type="button"
onClick={handleCancel}
disabled={submitting}
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70 disabled:opacity-50"
>
</button>
{updateResult.data && !error && !submitting && (
<span className="text-tiny text-success"></span>
)}
</div>
</form>
</section>
) : (
<section className="max-w-2xl">
<div className="mb-6">
@@ -89,7 +217,7 @@ export default function SettingsPage() {
<div className="mt-8 p-4 border border-rule rounded-card bg-subtle">
<p className="text-sm text-ink-muted">
P2 updateUser Mutation P3
P3 UpdateUserMutation"编辑"
</p>
</div>
</section>

View File

@@ -0,0 +1,454 @@
"use client";
/**
* 教材阅读器 - 章节树 + 内容 + 阅读设置
*
* 数据来源P7 扩展MSW mock
* - GraphQL TextbookDetailQuery教材详情章节树 + 知识点列表)
*
* 布局:左侧章节树(可展开/折叠)+ 中间章节内容(简单 markdown 渲染)+ 右侧设置面板
* 导航:"上一章"/"下一章" 按钮切换章节
*
* 维护者ai13teacher-portal
*/
import { useState, useMemo, useCallback } from "react";
import { useQuery } from "urql";
import { useParams } from "next/navigation";
import Link from "next/link";
import { Loading, Empty } from "@edu/ui-components";
import { TextbookDetailQuery } from "@/lib/graphql-p7-exams";
import type { TextbookChapter, TextbookDetail } from "@/lib/graphql-p7-exams";
/** 阅读设置 */
interface ReaderSettings {
fontSize: "small" | "medium" | "large";
lineHeight: "compact" | "normal" | "loose";
theme: "default" | "warm";
}
/** 字号映射到 Tailwind class */
const FONT_SIZE_CLASS: Record<ReaderSettings["fontSize"], string> = {
small: "text-sm",
medium: "text-base",
large: "text-lg",
};
/** 行高映射到 Tailwind class */
const LINE_HEIGHT_CLASS: Record<ReaderSettings["lineHeight"], string> = {
compact: "leading-tight",
normal: "leading-normal",
loose: "leading-loose",
};
/** 主题映射到 Tailwind class */
const THEME_CLASS: Record<ReaderSettings["theme"], string> = {
default: "bg-paper text-ink",
warm: "bg-subtle text-ink",
};
/** 默认设置 */
const DEFAULT_SETTINGS: ReaderSettings = {
fontSize: "medium",
lineHeight: "normal",
theme: "default",
};
/** 渲染简单 markdown## 标题 + 段落 */
function renderChapterContent(
content: string,
fontSizeClass: string,
lineHeightClass: string,
): React.ReactNode {
const lines = content.split("\n");
const blocks: React.ReactNode[] = [];
let currentHeading: string | null = null;
let currentParagraph: string[] = [];
const flushParagraph = (key: string) => {
if (currentParagraph.length > 0) {
blocks.push(
<p
key={`p-${key}`}
className={`mb-4 ${fontSizeClass} ${lineHeightClass}`}
>
{currentParagraph.join(" ")}
</p>,
);
currentParagraph = [];
}
};
lines.forEach((line, i) => {
const trimmed = line.trim();
if (trimmed.startsWith("# ")) {
// H1章节主标题已在 header 展示,跳过)
flushParagraph(`f${i}`);
} else if (trimmed.startsWith("## ")) {
// H2小节标题
flushParagraph(`f${i}`);
currentHeading = trimmed.slice(3);
blocks.push(
<h3
key={`h-${i}`}
className={`mt-6 mb-3 font-serif text-ink ${fontSizeClass === "text-sm" ? "text-base" : fontSizeClass === "text-base" ? "text-lg" : "text-xl"}`}
>
{currentHeading}
</h3>,
);
currentHeading = null;
} else if (trimmed === "") {
// 空行:段落分隔
flushParagraph(`f${i}`);
} else {
currentParagraph.push(trimmed);
}
});
flushParagraph("f-end");
return blocks;
}
export default function TextbookReaderPage(): React.ReactNode {
const params = useParams<{ id: string }>();
const textbookId = params?.id ?? "";
const [result] = useQuery({
query: TextbookDetailQuery,
variables: { id: textbookId },
pause: !textbookId,
});
const textbook: TextbookDetail | undefined =
(result.data?.textbookDetail as TextbookDetail | undefined) ?? undefined;
// === 阅读状态 ===
const [currentChapterIdx, setCurrentChapterIdx] = useState(0);
const [expandedChapters, setExpandedChapters] = useState<Set<string>>(
new Set(),
);
const [settings, setSettings] = useState<ReaderSettings>(DEFAULT_SETTINGS);
const [settingsOpen, setSettingsOpen] = useState(false);
const chapters: TextbookChapter[] = textbook?.chapters ?? [];
// 当前章节(安全访问)
const currentChapter: TextbookChapter | null =
chapters[currentChapterIdx] ?? null;
// 章节切换
const goToChapter = useCallback(
(idx: number) => {
if (idx >= 0 && idx < chapters.length) {
setCurrentChapterIdx(idx);
}
},
[chapters.length],
);
const goPrev = useCallback(() => {
setCurrentChapterIdx((i) => Math.max(0, i - 1));
}, []);
const goNext = useCallback(() => {
setCurrentChapterIdx((i) => Math.min(chapters.length - 1, i + 1));
}, [chapters.length]);
// 展开/折叠章节
const toggleChapter = useCallback((chapterId: string) => {
setExpandedChapters((prev) => {
const next = new Set(prev);
if (next.has(chapterId)) {
next.delete(chapterId);
} else {
next.add(chapterId);
}
return next;
});
}, []);
// 当前章节内容渲染
const renderedContent = useMemo(() => {
if (!currentChapter) return null;
return renderChapterContent(
currentChapter.content,
FONT_SIZE_CLASS[settings.fontSize],
LINE_HEIGHT_CLASS[settings.lineHeight],
);
}, [currentChapter, settings.fontSize, settings.lineHeight]);
return (
<div className="flex h-screen bg-paper">
{/* 左侧:章节树 */}
<aside className="w-64 flex-shrink-0 border-r border-rule bg-paper flex flex-col overflow-hidden">
<div className="px-6 py-6 flex-shrink-0">
<Link
href="/textbooks"
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
<h1 className="mt-2 text-lg font-serif text-ink">
{textbook?.title ?? "加载中..."}
</h1>
<p className="mt-1 text-tiny text-ink-muted">
{textbook?.subject} · {textbook?.grade} · {textbook?.version}
</p>
</div>
<div className="rule-thin mx-6" />
<nav className="flex-1 overflow-y-auto px-3 py-4">
{result.fetching ? (
<Loading lines={4} />
) : chapters.length === 0 ? (
<p className="px-3 py-2 text-tiny text-ink-muted"></p>
) : (
chapters.map((ch, idx) => {
const isExpanded = expandedChapters.has(ch.id);
const isActive = idx === currentChapterIdx;
return (
<div key={ch.id} className="mb-1">
<div className="flex items-center">
<button
type="button"
onClick={() => toggleChapter(ch.id)}
className="flex-shrink-0 px-1 py-1 text-tiny text-ink-muted hover:opacity-70"
aria-label={isExpanded ? "折叠" : "展开"}
>
{isExpanded ? "▾" : "▸"}
</button>
<button
type="button"
onClick={() => goToChapter(idx)}
className={`flex-1 text-left px-2 py-1.5 text-sm transition-colors ${
isActive
? "text-accent border-l-2 border-accent font-serif"
: "text-ink border-l-2 border-transparent font-sans hover:text-accent"
}`}
>
{ch.title}
</button>
</div>
{isExpanded && ch.knowledgePoints.length > 0 && (
<ul className="ml-6 mt-1 mb-2 space-y-1">
{ch.knowledgePoints.map((kp) => (
<li
key={kp.id}
className="text-tiny text-ink-muted px-2 py-0.5"
>
{kp.name}
</li>
))}
</ul>
)}
</div>
);
})
)}
</nav>
</aside>
{/* 中间:章节内容 */}
<main className="flex-1 overflow-y-auto">
{result.fetching ? (
<div className="px-10 py-10">
<Loading lines={8} />
</div>
) : result.error ? (
<div className="px-10 py-10">
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{result.error.message}
</p>
</div>
</div>
) : !textbook ? (
<div className="px-10 py-10">
<Empty title="教材不存在" description="可能已被删除或链接无效" />
</div>
) : !currentChapter ? (
<div className="px-10 py-10">
<Empty title="暂无章节" description="该教材尚未添加章节内容" />
</div>
) : (
<div className="px-10 py-10 max-w-3xl mx-auto">
{/* 章节标题 */}
<header className="mb-8">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
{currentChapterIdx + 1} / {chapters.length}
</p>
<h2 className="mt-1 text-3xl font-serif text-ink">
{currentChapter.title}
</h2>
</header>
<div className="rule-thin mb-8" />
{/* 章节正文 */}
<article
className={`prose-sm rounded-card p-6 ${THEME_CLASS[settings.theme]}`}
>
{renderedContent}
</article>
{/* 知识点列表 */}
{currentChapter.knowledgePoints.length > 0 && (
<section className="mt-8">
<h3 className="text-base font-serif text-ink mb-3">
</h3>
<ul className="space-y-2">
{currentChapter.knowledgePoints.map((kp, i) => (
<li
key={kp.id}
className="flex items-baseline gap-2 text-sm text-ink"
>
<span className="font-mono text-tiny text-ink-muted">
{String(i + 1).padStart(2, "0")}
</span>
<span>{kp.name}</span>
{kp.description && (
<span className="text-tiny text-ink-muted">
{kp.description}
</span>
)}
</li>
))}
</ul>
</section>
)}
{/* 导航按钮 */}
<nav className="mt-10 flex items-center justify-between border-t border-rule pt-6">
<button
type="button"
onClick={goPrev}
disabled={currentChapterIdx <= 0}
className="px-4 py-2 text-sm text-ink border border-rule rounded-button disabled:opacity-30 hover:bg-subtle"
>
</button>
<span className="text-tiny text-ink-muted">
{currentChapterIdx + 1} / {chapters.length}
</span>
<button
type="button"
onClick={goNext}
disabled={currentChapterIdx >= chapters.length - 1}
className="px-4 py-2 text-sm text-ink border border-rule rounded-button disabled:opacity-30 hover:bg-subtle"
>
</button>
</nav>
</div>
)}
</main>
{/* 右侧:设置面板(可切换显示/隐藏) */}
<aside
className={`w-56 flex-shrink-0 border-l border-rule bg-paper flex flex-col transition-all ${
settingsOpen ? "opacity-100" : "opacity-0 pointer-events-none w-0"
}`}
>
<div className="px-6 py-6 flex-shrink-0">
<h2 className="text-base font-serif text-ink"></h2>
</div>
<div className="rule-thin mx-6" />
<div className="flex-1 px-6 py-4 space-y-6 overflow-y-auto">
{/* 字体大小 */}
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-2">
</label>
<div className="flex gap-2">
{(["small", "medium", "large"] as const).map((size) => (
<button
key={size}
type="button"
onClick={() =>
setSettings((s) => ({ ...s, fontSize: size }))
}
className={`px-3 py-1 text-tiny rounded-button border ${
settings.fontSize === size
? "border-accent text-accent"
: "border-rule text-ink-muted hover:bg-subtle"
}`}
>
{size === "small" ? "小" : size === "medium" ? "中" : "大"}
</button>
))}
</div>
</div>
{/* 行高 */}
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-2">
</label>
<div className="flex gap-2">
{(["compact", "normal", "loose"] as const).map((lh) => (
<button
key={lh}
type="button"
onClick={() =>
setSettings((s) => ({ ...s, lineHeight: lh }))
}
className={`px-3 py-1 text-tiny rounded-button border ${
settings.lineHeight === lh
? "border-accent text-accent"
: "border-rule text-ink-muted hover:bg-subtle"
}`}
>
{lh === "compact" ? "紧凑" : lh === "normal" ? "正常" : "宽松"}
</button>
))}
</div>
</div>
{/* 主题 */}
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-2">
</label>
<div className="flex gap-2">
{(["default", "warm"] as const).map((theme) => (
<button
key={theme}
type="button"
onClick={() => setSettings((s) => ({ ...s, theme }))}
className={`px-3 py-1 text-tiny rounded-button border ${
settings.theme === theme
? "border-accent text-accent"
: "border-rule text-ink-muted hover:bg-subtle"
}`}
>
{theme === "default" ? "默认" : "护眼"}
</button>
))}
</div>
</div>
{/* 重置 */}
<button
type="button"
onClick={() => setSettings(DEFAULT_SETTINGS)}
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</button>
</div>
</aside>
{/* 设置开关按钮(固定在右边缘) */}
<button
type="button"
onClick={() => setSettingsOpen((v) => !v)}
className="fixed right-0 top-1/2 -translate-y-1/2 px-2 py-3 bg-accent text-ink-on-accent text-tiny writing-mode-vertical rounded-l-button hover:bg-accent-hover"
style={{ writingMode: "vertical-rl" }}
aria-label="阅读设置"
>
{settingsOpen ? "收起设置" : "展开设置"}
</button>
</div>
);
}

View File

@@ -0,0 +1,478 @@
"use client";
/**
* 教材列表 - 筛选 + 新建 + 表格 + 分页
*
* 数据来源P7 扩展MSW mock
* - GraphQL TextbooksQuery教材列表q/subject/grade 筛选 + 分页 + 总数)
* - GraphQL TextbookCreateMutation新建教材
*
* 维护者ai13teacher-portal
*/
import { useState, useMemo, useCallback } from "react";
import { useQuery, useMutation } from "urql";
import Link from "next/link";
import { Loading, Empty } from "@edu/ui-components";
import { TextbooksQuery, TextbookCreateMutation } from "@/lib/graphql-p7-exams";
import type {
TextbookItem,
TextbooksFilter,
TextbookCreateInput,
} from "@/lib/graphql-p7-exams";
import {
TEXTBOOK_SUBJECTS,
TEXTBOOK_GRADES,
TEXTBOOK_VERSIONS,
} from "@/mocks/fixtures/textbooks";
/** 新建教材弹窗的表单状态 */
interface TextbookFormState {
title: string;
subject: string;
grade: string;
version: string;
coverUrl: string;
}
/** 初始空表单 */
function emptyForm(): TextbookFormState {
return {
title: "",
subject: "",
grade: "",
version: "",
coverUrl: "",
};
}
export default function TextbooksPage(): React.ReactNode {
// === 筛选条件 ===
const [filterQ, setFilterQ] = useState("");
const [filterSubject, setFilterSubject] = useState("all");
const [filterGrade, setFilterGrade] = useState("all");
const [page, setPage] = useState(1);
const pageSize = 20;
// === 新建弹窗状态 ===
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState<TextbookFormState>(emptyForm());
const [dialogError, setDialogError] = useState<string | null>(null);
const [dialogSaving, setDialogSaving] = useState(false);
// === GraphQL 查询 ===
const filter: TextbooksFilter = useMemo(
() => ({
q: filterQ || null,
subject: filterSubject === "all" ? null : filterSubject,
grade: filterGrade === "all" ? null : filterGrade,
page,
pageSize,
}),
[filterQ, filterSubject, filterGrade, page],
);
const [result] = useQuery({
query: TextbooksQuery,
variables: { filter },
});
// === Mutations ===
const [, createTextbook] = useMutation(TextbookCreateMutation);
const items: TextbookItem[] =
(result.data?.textbooks.items as TextbookItem[] | undefined) ?? [];
const total = result.data?.textbooks.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
// === 操作 ===
const openCreate = useCallback(() => {
setForm(emptyForm());
setDialogError(null);
setDialogOpen(true);
}, []);
const submitForm = async () => {
setDialogError(null);
if (!form.title.trim()) {
setDialogError("请填写教材名称");
return;
}
if (!form.subject) {
setDialogError("请选择学科");
return;
}
if (!form.grade) {
setDialogError("请选择年级");
return;
}
if (!form.version) {
setDialogError("请选择版本");
return;
}
setDialogSaving(true);
const input: TextbookCreateInput = {
title: form.title.trim(),
subject: form.subject,
grade: form.grade,
version: form.version,
coverUrl: form.coverUrl.trim() || null,
};
const res = await createTextbook({ input });
setDialogSaving(false);
if (res.error) {
setDialogError(res.error.message);
return;
}
setDialogOpen(false);
};
const resetFilters = () => {
setFilterQ("");
setFilterSubject("all");
setFilterGrade("all");
setPage(1);
};
return (
<div className="px-10 py-10">
<header className="mb-8 flex items-baseline justify-between">
<div>
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL TextbooksQuery · content P7 MSW mock
</p>
</div>
<button
type="button"
onClick={openCreate}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
>
</button>
</header>
<div className="rule-thin mb-8" />
{/* 筛选栏 */}
<section className="mb-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<input
type="text"
value={filterQ}
onChange={(e) => {
setFilterQ(e.target.value);
setPage(1);
}}
placeholder="教材名称关键词"
className="px-3 py-1.5 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
/>
<select
value={filterSubject}
onChange={(e) => {
setFilterSubject(e.target.value);
setPage(1);
}}
className="px-2 py-1.5 bg-transparent border border-rule rounded-button text-tiny text-ink focus:outline-none"
>
<option value="all"></option>
{TEXTBOOK_SUBJECTS.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
<select
value={filterGrade}
onChange={(e) => {
setFilterGrade(e.target.value);
setPage(1);
}}
className="px-2 py-1.5 bg-transparent border border-rule rounded-button text-tiny text-ink focus:outline-none"
>
<option value="all"></option>
{TEXTBOOK_GRADES.map((g) => (
<option key={g} value={g}>
{g}
</option>
))}
</select>
<button
type="button"
onClick={resetFilters}
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70 text-left"
>
</button>
</div>
</section>
{/* 数据表 */}
{result.fetching ? (
<Loading lines={6} />
) : result.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{result.error.message}
</p>
</div>
) : items.length === 0 ? (
<Empty
title="暂无教材"
description="调整筛选条件或新建教材"
action={
<button
type="button"
onClick={openCreate}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
>
</button>
}
/>
) : (
<div className="border border-rule rounded-card overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-subtle">
<tr className="border-b border-rule">
<th className="text-left py-2 px-3 text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="text-left py-2 px-3 text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="text-left py-2 px-3 text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="text-left py-2 px-3 text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="text-right py-2 px-3 text-tiny uppercase tracking-wide text-ink-muted">
</th>
<th className="text-right py-2 px-3 text-tiny uppercase tracking-wide text-ink-muted">
</th>
</tr>
</thead>
<tbody>
{items.map((tb) => (
<tr
key={tb.id}
className="border-b border-rule hover:bg-subtle"
>
<td className="py-2 px-3 text-ink">
{tb.title}
</td>
<td className="py-2 px-3 text-ink-muted">{tb.subject}</td>
<td className="py-2 px-3 text-ink-muted">{tb.grade}</td>
<td className="py-2 px-3 text-ink-muted">{tb.version}</td>
<td className="py-2 px-3 text-right font-mono text-ink">
{tb.chapterCount}
</td>
<td className="py-2 px-3 text-right">
<Link
href={`/textbooks/${tb.id}`}
className="text-tiny font-mono text-accent hover:opacity-70 mr-3"
>
</Link>
<button
type="button"
onClick={() =>
window.alert("教材编辑暂未实现P7 mock")
}
className="text-tiny font-mono text-ink-muted hover:opacity-70 mr-3"
>
</button>
<button
type="button"
onClick={() =>
window.alert("教材删除暂未实现P7 mock")
}
className="text-tiny font-mono text-danger hover:opacity-70"
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* 分页 */}
{total > 0 && (
<div className="mt-4 flex items-center justify-between text-tiny text-ink-muted">
<span>
{page} / {totalPages} {total}
</span>
<div className="flex gap-2">
<button
type="button"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="px-3 py-1 border border-rule rounded-button disabled:opacity-30 hover:bg-subtle"
>
</button>
<button
type="button"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="px-3 py-1 border border-rule rounded-button disabled:opacity-30 hover:bg-subtle"
>
</button>
</div>
</div>
)}
{/* 新建教材弹窗 */}
{dialogOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-paper/80"
onClick={() => !dialogSaving && setDialogOpen(false)}
>
<div
className="bg-paper border border-rule rounded-card p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-xl font-serif text-ink mb-4"></h2>
<div className="rule-thin mb-4" />
<div className="space-y-4">
{/* 教材名称 */}
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
<span className="text-danger">*</span>
</label>
<input
type="text"
value={form.title}
onChange={(e) =>
setForm((f) => ({ ...f, title: e.target.value }))
}
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
placeholder="如:高中数学必修一"
/>
</div>
{/* 学科 + 年级 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
<span className="text-danger">*</span>
</label>
<select
value={form.subject}
onChange={(e) =>
setForm((f) => ({ ...f, subject: e.target.value }))
}
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
>
<option value=""></option>
{TEXTBOOK_SUBJECTS.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
<span className="text-danger">*</span>
</label>
<select
value={form.grade}
onChange={(e) =>
setForm((f) => ({ ...f, grade: e.target.value }))
}
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
>
<option value=""></option>
{TEXTBOOK_GRADES.map((g) => (
<option key={g} value={g}>
{g}
</option>
))}
</select>
</div>
</div>
{/* 版本 + 封面 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
<span className="text-danger">*</span>
</label>
<select
value={form.version}
onChange={(e) =>
setForm((f) => ({ ...f, version: e.target.value }))
}
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
>
<option value=""></option>
{TEXTBOOK_VERSIONS.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
</div>
<div>
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
URL
</label>
<input
type="text"
value={form.coverUrl}
onChange={(e) =>
setForm((f) => ({ ...f, coverUrl: e.target.value }))
}
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
placeholder="可选"
/>
</div>
</div>
{/* 错误提示 */}
{dialogError && (
<div className="mark-left py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">{dialogError}</p>
</div>
)}
{/* 操作按钮 */}
<div className="flex items-center gap-3 pt-2">
<button
type="button"
onClick={submitForm}
disabled={dialogSaving}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
>
{dialogSaving ? "保存中..." : "创建教材"}
</button>
<button
type="button"
onClick={() => setDialogOpen(false)}
disabled={dialogSaving}
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</button>
</div>
</div>
</div>
</div>
)}
<p className="mt-8 text-tiny text-ink-muted">
<Link
href="/dashboard"
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</Link>
</p>
</div>
);
}

View File

@@ -3,6 +3,7 @@ import type { Metadata } from "next";
import { Inter, Fraunces, JetBrains_Mono } from "next/font/google";
import { GraphQLProvider } from "./providers";
import { ErrorBoundary } from "@edu/ui-components";
import { ObservabilityProvider } from "@/components/observability-provider";
/**
* 字体加载next/font/google self-host
@@ -48,9 +49,11 @@ export default function RootLayout({
className={`${inter.variable} ${fraunces.variable} ${mono.variable}`}
>
<body>
<ErrorBoundary>
<GraphQLProvider>{children}</GraphQLProvider>
</ErrorBoundary>
<ObservabilityProvider>
<ErrorBoundary>
<GraphQLProvider>{children}</GraphQLProvider>
</ErrorBoundary>
</ObservabilityProvider>
</body>
</html>
);

View File

@@ -26,6 +26,10 @@ import { usePermission } from "@edu/hooks";
import { ViewportsQuery, MeQuery } from "@/lib/graphql";
import type { ViewportItem, User } from "@/lib/graphql";
import { getToken, getUser, logout } from "@/lib/auth";
import {
useCrossTabSync,
broadcastCrossTabEvent,
} from "@/hooks/use-cross-tab-sync";
/**
* 权限上下文:从 localStorage 读取(登录时由 iam 返回并存储)。
@@ -54,6 +58,9 @@ export default function AppShell({ children }: { children: React.ReactNode }) {
}
}, [router]);
// 多 Tab 会话同步:监听 logout / role-change / token-refresh 事件
useCrossTabSync();
// GraphQL: viewports query替代 REST /api/v1/teacher/viewports
const [viewportsResult] = useQuery({
query: ViewportsQuery,
@@ -95,6 +102,12 @@ export default function AppShell({ children }: { children: React.ReactNode }) {
</p>
) : null;
// 退出登录:先广播给其他 Tab再执行本地登出
const handleLogout = () => {
broadcastCrossTabEvent("logout");
logout();
};
return (
<div className="min-h-screen flex bg-paper">
{/* 左侧栏:导航树 */}
@@ -140,7 +153,7 @@ export default function AppShell({ children }: { children: React.ReactNode }) {
</div>
)}
<button
onClick={logout}
onClick={handleLogout}
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
退

View File

@@ -0,0 +1,90 @@
"use client";
/**
* ParentPortalRemote - parent-portal MF Remote 接入组件
*
* 职责ARB-002 §2.3
* - NEXT_PUBLIC_MF_ENABLED=true通过 Module Federation 动态加载 parent-portal Remote
* - NEXT_PUBLIC_MF_ENABLED=false展示"需启用微前端模式"提示
* - 使用 next/dynamic 懒加载ssr: falseRemote 仅 CSR
* - ErrorBoundary 兜底Remote 加载/渲染失败时降级展示
*
* 维护者ai13teacher-portal
*/
import dynamic from "next/dynamic";
import { ErrorBoundary } from "@edu/ui-components";
const MF_ENABLED = process.env.NEXT_PUBLIC_MF_ENABLED === "true";
/** 动态加载 parent-portal Remote仅 CSR加载失败返回降级组件 */
const ParentRemote = dynamic(
() =>
import("parent/ParentApp")
.then((mod) => mod.default)
.catch(() => FallbackRemote),
{
ssr: false,
loading: () => (
<p className="text-sm text-ink-muted p-6"></p>
),
},
);
/** Remote 加载失败时的降级组件 */
function FallbackRemote(): React.ReactNode {
return (
<div className="p-8 text-center">
<p className="text-lg font-serif text-ink"></p>
<p className="mt-2 text-sm text-ink-muted">
parent-portal 4002 ParentApp
</p>
</div>
);
}
/** MF 未启用时的提示 */
function MfDisabledNotice(): React.ReactNode {
return (
<div className="p-8 border border-rule rounded-card bg-surface text-center">
<p className="text-lg font-serif text-ink">
</p>
<p className="mt-2 text-sm text-ink-muted">
NEXT_PUBLIC_MF_ENABLED=true
parent-portal next.config.js remotes
</p>
</div>
);
}
export default function ParentPortalRemote(): React.ReactNode {
if (!MF_ENABLED) {
return <MfDisabledNotice />;
}
return (
<ErrorBoundary
fallback={(error, reset) => (
<div
role="alert"
className="flex flex-col items-center justify-center gap-4 p-8"
>
<h2 className="text-lg font-serif text-ink">
</h2>
<p className="text-sm text-ink-muted">{error.message}</p>
<button
type="button"
onClick={reset}
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
>
</button>
</div>
)}
>
<ParentRemote />
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,90 @@
"use client";
/**
* 可观测性 Provider 组件P6 硬化)
*
* 整合 Sentry + Web Vitals + OTel 初始化,在应用启动时调用各 init 函数。
* - "use client" 客户端组件
* - 在 useEffect 中按需初始化各可观测性模块(动态 import 避免未安装包问题)
* - 不渲染任何可见 UI返回 children
* - 条件初始化:根据环境变量决定是否启用各模块
*
* 关联02-architecture-design.md §12 可观测性 / project_rules §12 可观测性规范
*/
import { useEffect, type ReactNode } from "react";
/**
* 可观测性 Provider。
*
* 在客户端挂载时初始化 Sentry / Web Vitals / OTel。
* 所有初始化均为条件性:未配置对应环境变量时跳过,未安装包时降级。
*
* 不渲染任何可见 UI仅透传 children。
*/
export function ObservabilityProvider({
children,
}: {
children: ReactNode;
}): ReactNode {
useEffect(() => {
// 并行初始化各可观测性模块(互不依赖)
const initObservability = async (): Promise<void> => {
// 1. Sentry 错误追踪条件NEXT_PUBLIC_SENTRY_DSN 配置时)
try {
const { initSentry } = await import(
"@/lib/observability/sentry"
);
await initSentry();
} catch (err) {
if (typeof console !== "undefined") {
console.warn("[teacher-portal] Sentry 初始化失败", err);
}
}
// 2. Web Vitals RUM 采集(生产环境启用)
if (process.env.NODE_ENV === "production") {
try {
const { initWebVitals } = await import(
"@/lib/observability/web-vitals"
);
await initWebVitals();
} catch (err) {
if (typeof console !== "undefined") {
console.warn("[teacher-portal] Web Vitals 初始化失败", err);
}
}
}
// 3. OTel browser SDK条件NEXT_PUBLIC_OTEL_ENABLED=true 时)
try {
const { initOTel } = await import("@/lib/observability/otel");
await initOTel();
} catch (err) {
if (typeof console !== "undefined") {
console.warn("[teacher-portal] OTel 初始化失败", err);
}
}
// 4. Cookie 迁移条件NEXT_PUBLIC_COOKIE_MIGRATION_ENABLED=true 时)
// 实际迁移在 iam refresh cookie 端点就绪后启用,此处仅做准备
try {
const { migrateTokenToCookie } = await import(
"@/lib/observability/cookie-migration"
);
// 后台执行迁移,不阻塞应用渲染
void migrateTokenToCookie();
} catch {
// 迁移失败不影响应用功能,静默忽略
}
};
// 后台初始化,不阻塞渲染
void initObservability();
}, []);
// 不渲染任何可见 UI仅透传 children
return children;
}
export default ObservabilityProvider;

View File

@@ -0,0 +1,36 @@
"use client";
/**
* Web Vitals 采集初始化组件P6 硬化)
*
* 参考 admin-portal/src/components/web-vitals-initializer.tsx 实现。
* - "use client" 客户端组件
* - 在 useEffect 中调用 initWebVitals()(动态 import 避免未安装包问题)
* - 不渲染任何可见 UI
*
* 仲裁:所有前端应用必须采集 Web Vitals
* 关联02-architecture-design.md §12 可观测性
*/
import { useEffect } from "react";
/**
* Web Vitals 采集初始化组件。
*
* 仅在生产环境启用采集,开发环境跳过以避免控制台噪音。
*/
export function WebVitalsInitializer(): null {
useEffect(() => {
if (process.env.NODE_ENV !== "production") return;
// 动态 import 避免未安装包导致构建失败
void import("@/lib/observability/web-vitals").then(
({ initWebVitals }) => {
void initWebVitals();
},
);
}, []);
return null;
}
export default WebVitalsInitializer;

View File

@@ -0,0 +1,105 @@
"use client";
/**
* useCrossTabSync - 多 Tab 会话同步 Hook
*
* 职责:
* - 通过 BroadcastChannel('edu-session') 监听跨 Tab 事件
* - 事件类型logout / role-change / token-refresh
* - logout调用 logout() 跳转登录页
* - role-change刷新页面重新获取权限
* - token-refresh更新 localStorage 中的 token
*
* 维护者ai13teacher-portal
* 关联02-architecture-design.md §2.1 会话状态
*/
import { useEffect } from "react";
import { setToken } from "@/lib/auth";
/** 跨 Tab 同步事件类型 */
export type CrossTabEventType = "logout" | "role-change" | "token-refresh";
/** 跨 Tab 事件消息体 */
export interface CrossTabMessage {
type: CrossTabEventType;
payload?: {
token?: string;
};
/** 触发时间戳(用于去重/排序) */
timestamp: number;
}
/** BroadcastChannel 名称 */
const CHANNEL_NAME = "edu-session";
/**
* 广播跨 Tab 事件给其他 Tab
*
* 当前 Tab 主动触发(如点击登出按钮),其他 Tab 通过此 hook 接收并响应。
*/
export function broadcastCrossTabEvent(
type: CrossTabEventType,
payload?: CrossTabMessage["payload"],
): void {
if (typeof window === "undefined") return;
try {
const channel = new BroadcastChannel(CHANNEL_NAME);
const message: CrossTabMessage = {
type,
payload,
timestamp: Date.now(),
};
channel.postMessage(message);
channel.close();
} catch (err) {
// BroadcastChannel 不可用时静默降级(单 Tab 模式不受影响)
console.warn("[useCrossTabSync] broadcast failed", err);
}
}
/**
* 订阅跨 Tab 事件并响应
*
* 必须在客户端组件中使用(依赖 window / BroadcastChannel
*/
export function useCrossTabSync(): void {
useEffect(() => {
if (typeof window === "undefined") return;
if (typeof BroadcastChannel === "undefined") return;
const channel = new BroadcastChannel(CHANNEL_NAME);
const handleMessage = (event: MessageEvent<CrossTabMessage>) => {
const message = event.data;
if (!message || typeof message !== "object") return;
switch (message.type) {
case "logout":
// 收到登出事件:清空本地状态并跳转登录页
// 注:不调用 broadcastCrossTabEvent 避免循环
window.location.href = "/login";
break;
case "role-change":
// 收到角色变更事件:刷新页面以重新获取权限
window.location.reload();
break;
case "token-refresh":
// 收到 token 刷新事件:更新本地 token
if (message.payload?.token) {
setToken(message.payload.token);
}
break;
}
};
channel.addEventListener("message", handleMessage);
return () => {
channel.removeEventListener("message", handleMessage);
channel.close();
};
}, []);
}

View File

@@ -0,0 +1,175 @@
"use client";
/**
* 通知 WebSocket HookP5
*
* 维护者ai13teacher-portal
* 关联02-architecture-design.md §5 实时推送push-gateway
*
* - 连接 push-gatewayws://localhost:8081/ws
* - useEffect 建立连接useRef 持有 WebSocket 实例
* - 监听消息事件,解析 JSON 后通过 state 通知
* - 自动重连(指数退避,最多 5 次)
* - 连接状态枚举connecting / connected / disconnected / error
* - Mock 模式NEXT_PUBLIC_API_MOCKING=enabled用 setInterval 每 30s 推送 1 条 mock 通知
* (不依赖 mock-socket避免新增 npm 依赖)
* - 返回:{ notifications, connectionState, reconnect }
*/
import { useCallback, useEffect, useRef, useState } from "react";
import type { NotificationItem, NotificationType } from "@/lib/graphql-p5";
import { mockNotifications } from "@/mocks/fixtures/notifications";
export type ConnectionState =
| "connecting"
| "connected"
| "disconnected"
| "error";
export interface UseNotificationsWebSocketResult {
notifications: NotificationItem[];
connectionState: ConnectionState;
reconnect: () => void;
}
const WS_URL = "ws://localhost:8081/ws";
const MAX_RETRIES = 5;
const MAX_DELAY_MS = 30000;
const MOCK_INTERVAL_MS = 30000;
export function useNotificationsWebSocket(): UseNotificationsWebSocketResult {
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [connectionState, setConnectionState] =
useState<ConnectionState>("disconnected");
const wsRef = useRef<WebSocket | null>(null);
const retryRef = useRef(0);
const reconnectFnRef = useRef<() => void>(() => {});
useEffect(() => {
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
let mockTimer: ReturnType<typeof setInterval> | undefined;
let mockIndex = 0;
let disposed = false;
const pushNotification = (item: NotificationItem): void => {
setNotifications((prev) => {
if (prev.some((n) => n.id === item.id)) return prev;
return [item, ...prev];
});
};
const connect = (): void => {
if (disposed) return;
setConnectionState("connecting");
let ws: WebSocket;
try {
ws = new WebSocket(WS_URL);
} catch {
setConnectionState("error");
scheduleReconnect();
return;
}
wsRef.current = ws;
ws.onopen = () => {
retryRef.current = 0;
setConnectionState("connected");
};
ws.onmessage = (event: MessageEvent) => {
const raw = typeof event.data === "string" ? event.data : "";
if (!raw) return;
try {
const msg = JSON.parse(raw) as {
type?: string;
payload?: NotificationItem;
};
const item = msg.payload;
if (item && item.id) {
pushNotification(item);
}
} catch {
// 忽略非 JSON 消息
}
};
ws.onerror = () => {
setConnectionState("error");
};
ws.onclose = () => {
if (disposed) return;
setConnectionState("disconnected");
scheduleReconnect();
};
};
const scheduleReconnect = (): void => {
if (disposed) return;
if (retryRef.current >= MAX_RETRIES) return;
const delay = Math.min(1000 * 2 ** retryRef.current, MAX_DELAY_MS);
retryRef.current += 1;
reconnectTimer = setTimeout(connect, delay);
};
const startMock = (): void => {
setConnectionState("connected");
mockTimer = setInterval(() => {
if (disposed) return;
const base =
mockNotifications[mockIndex % mockNotifications.length];
mockIndex += 1;
if (!base) return;
pushNotification({
...base,
id: `${base.id}-ws-${Date.now()}`,
read: false,
createdAt: new Date().toISOString(),
});
}, MOCK_INTERVAL_MS);
};
const start = (): void => {
if (process.env.NEXT_PUBLIC_API_MOCKING === "enabled") {
startMock();
} else {
connect();
}
};
start();
reconnectFnRef.current = (): void => {
retryRef.current = 0;
if (reconnectTimer) clearTimeout(reconnectTimer);
if (mockTimer) clearInterval(mockTimer);
wsRef.current?.close();
wsRef.current = null;
start();
};
return () => {
disposed = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
if (mockTimer) clearInterval(mockTimer);
wsRef.current?.close();
wsRef.current = null;
};
}, []);
const reconnect = useCallback((): void => {
reconnectFnRef.current();
}, []);
return { notifications, connectionState, reconnect };
}
/** 通知类型标签映射(供页面渲染图标/文案时复用) */
export const NOTIFICATION_TYPE_LABEL: Record<NotificationType, string> = {
HOMEWORK_SUBMITTED: "作业",
EXAM_GRADED: "考试",
BROADCAST: "广播",
ROLE_CHANGED: "角色",
};

View File

@@ -0,0 +1,122 @@
/**
* GraphQL P4 扩展 Query - 知识图谱 + 学情分析
*
* 维护者ai13teacher-portal
* 关联graphql.ts P2/P3 扩展、workline.md §3 P4 交付物
*
* schema 尚未在 ARB-001 P2 第一版中定义,开发期通过 MSW 拦截返回 mock 响应;
* 上游就绪后由 ai03 在 teacher-bff.graphql 扩展,届时合并本文件到 graphql.ts。
*
* 查询范围P4
* - KnowledgeGraph班级/科目知识点图谱(节点 + 前置依赖边)
* - ClassAnalytics班级学情分析均分/及格率/趋势/Top 学生/弱项)
* - StudentAnalytics单生学情分析趋势/弱项/强项/掌握度)
*/
import { gql } from "urql";
// ============ Types对齐 P4 schema 设计) ============
/** 知识图谱节点(知识点) */
export interface KnowledgeNode {
id: string;
name: string;
subject: string;
description: string | null;
masteryLevel: number; // 0-100
}
/** 知识图谱边(前置依赖关系) */
export interface KnowledgeEdge {
from: string;
to: string;
type: "PREREQUISITE" | "RELATED";
}
/** 知识图谱完整结构 */
export interface KnowledgeGraphData {
nodes: KnowledgeNode[];
edges: KnowledgeEdge[];
}
/** 单生学情分析 */
export interface StudentAnalytics {
studentId: string;
studentName: string;
avgScore: number;
trend: number[]; // 最近 5 次成绩趋势
weakPoints: string[]; // 弱项知识点名称
strongPoints: string[];
masteryRate: number; // 掌握度 0-100
}
/** 班级学情分析 */
export interface ClassAnalytics {
classId: string;
className: string;
avgScore: number;
passRate: number;
avgTrend: number[];
topStudents: StudentAnalytics[];
weakPoints: string[];
}
// ============ P4 Query ============
/** 知识图谱查询(按班级 + 科目过滤) */
export const KnowledgeGraphQuery = gql`
query KnowledgeGraph($classId: ID, $subject: String) {
knowledgeGraph(classId: $classId, subject: $subject) {
nodes {
id
name
subject
description
masteryLevel
}
edges {
from
to
type
}
}
}
`;
/** 班级学情分析查询 */
export const ClassAnalyticsQuery = gql`
query ClassAnalytics($classId: ID!) {
classAnalytics(classId: $classId) {
classId
className
avgScore
passRate
avgTrend
topStudents {
studentId
studentName
avgScore
trend
weakPoints
strongPoints
masteryRate
}
weakPoints
}
}
`;
/** 单生学情分析查询 */
export const StudentAnalyticsQuery = gql`
query StudentAnalytics($studentId: ID!) {
studentAnalytics(studentId: $studentId) {
studentId
studentName
avgScore
trend
weakPoints
strongPoints
masteryRate
}
}
`;

View File

@@ -0,0 +1,137 @@
/**
* GraphQL operations - P5 扩展(通知中心 + AI 辅助)
*
* 维护者ai13teacher-portal
* 关联02-architecture-design.md §5 实时推送、§6 AI 辅助
*
* schema 来源teacher-bff.graphqlP5 扩展MSW mock待 ai03 在上游补 schema
* - 通知myNotifications / markAsReadpush-gateway 实时推送 + GraphQL 持久化)
* - AIgenerateQuestion / generateLessonPlan / generateReport
*
* 注意:本文件独立于 graphql.tsP2/P3不修改共享文件最后统一合并。
*/
import { gql } from "urql";
// ============ Types通知中心 ============
export type NotificationType =
| "HOMEWORK_SUBMITTED"
| "EXAM_GRADED"
| "BROADCAST"
| "ROLE_CHANGED";
export interface NotificationItem {
id: string;
type: NotificationType;
title: string;
message: string;
read: boolean;
createdAt: string;
}
// ============ TypesAI 辅助) ============
export interface GenerateQuestionInput {
subject: string;
questionType: string;
difficulty: string;
knowledgePoints: string;
count: number;
}
export interface GeneratedQuestion {
id: string;
question: string;
options: string[];
answer: string;
explanation: string;
}
export interface GenerateLessonPlanInput {
classId: string;
subject: string;
topic: string;
objectives: string;
}
export interface GeneratedLessonPlan {
id: string;
content: string;
summary: string;
}
export interface GenerateReportInput {
classId: string;
reportType: string;
studentId?: string;
}
export interface GeneratedReport {
id: string;
content: string;
summary: string;
recommendations: string[];
}
// ============ Queries / Mutations ============
/** 通知列表查询 */
export const MyNotificationsQuery = gql`
query MyNotifications($unreadOnly: Boolean) {
myNotifications(unreadOnly: $unreadOnly) {
id
type
title
message
read
createdAt
}
}
`;
/** 标记已读 */
export const MarkAsReadMutation = gql`
mutation MarkAsRead($id: ID!) {
markAsRead(id: $id) {
id
read
}
}
`;
/** AI 辅助出题SSE 流式mock 用 ReadableStream */
export const GenerateQuestionMutation = gql`
mutation GenerateQuestion($input: GenerateQuestionInput!) {
generateQuestion(input: $input) {
id
question
options
answer
explanation
}
}
`;
/** AI 生成教案 */
export const GenerateLessonPlanMutation = gql`
mutation GenerateLessonPlan($input: GenerateLessonPlanInput!) {
generateLessonPlan(input: $input) {
id
content
summary
}
}
`;
/** AI 学情报告 */
export const GenerateReportMutation = gql`
mutation GenerateReport($input: GenerateReportInput!) {
generateReport(input: $input) {
id
content
summary
recommendations
}
}
`;

View File

@@ -0,0 +1,713 @@
/**
* GraphQL operations - P7 扩展(行政管理子模块:考勤 + 班级详情 + 课表 + 请假 + 调课)
*
* 维护者ai13teacher-portal
* 关联graphql.ts P2/P3 扩展、workline.md §3 P7 交付物
*
* schema 尚未在 ARB-001 P2 第一版中定义,开发期通过 MSW 拦截返回 mock 响应;
* 上游就绪后由 ai03 在 teacher-bff.graphql 扩展,届时合并本文件到 graphql.ts。
*
* 查询范围P7-admin
* - AttendanceList考勤记录列表按 classId/date/status 筛选 + 分页)
* - AttendanceSheet批量录入用学生 + 当天状态
* - SaveAttendanceSheet批量保存考勤录入
* - AttendanceStats班级考勤统计出勤率/迟到率/早退率/请假率/预警/趋势)
* - AttendanceReport周报/月报数据
* - ClassDetail班级详情聚合基本信息 + 学生 + 课表 + 近期作业 + 概览统计)
* - ClassSchedule班级课表周一到周日 × 节次)
* - LeaveRequests教师请假审批列表
* - ApproveLeaveRequest批准/拒绝请假
* - SubmitLeaveRequest教师提交请假/调课申请
* - ScheduleChanges我的调课申请列表
*/
import { gql } from "urql";
import type { DataScope } from "./graphql";
// ============ Types考勤 ============
/** 考勤状态 */
export type AttendanceStatus =
| "PRESENT"
| "ABSENT"
| "LATE"
| "EARLY_LEAVE"
| "LEAVE";
/** 考勤记录项 */
export interface AttendanceRecord {
id: string;
classId: string;
className: string;
studentId: string;
studentName: string;
studentNo: string;
date: string; // YYYY-MM-DD
status: AttendanceStatus;
note: string | null;
}
/** 考勤列表筛选条件 */
export interface AttendanceListFilter {
classId?: string | null;
startDate?: string | null;
endDate?: string | null;
statuses?: AttendanceStatus[] | null;
page?: number | null;
pageSize?: number | null;
}
/** 考勤列表分页结果 */
export interface AttendanceListResult {
items: AttendanceRecord[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
/** 考勤录入项(按学生 + 日期) */
export interface AttendanceSheetItem {
studentId: string;
studentName: string;
studentNo: string;
date: string;
status: AttendanceStatus | null;
note: string | null;
}
/** 考勤批量保存项 */
export interface SaveAttendanceRecordInput {
studentId: string;
status: AttendanceStatus;
note?: string | null;
}
/** 考勤批量保存输入 */
export interface SaveAttendanceSheetInput {
classId: string;
date: string;
records: SaveAttendanceRecordInput[];
}
/** 考勤批量保存结果 */
export interface SaveAttendanceSheetResult {
classId: string;
date: string;
savedCount: number;
savedAt: string;
}
// ============ Types考勤统计 ============
/** 班级考勤统计 */
export interface AttendanceStats {
classId: string;
className: string;
startDate: string;
endDate: string;
totalRecords: number;
presentCount: number;
absentCount: number;
lateCount: number;
earlyLeaveCount: number;
leaveCount: number;
presentRate: number;
lateRate: number;
earlyLeaveRate: number;
leaveRate: number;
warningCount: number;
trend: AttendanceTrendPoint[];
}
/** 考勤趋势点 */
export interface AttendanceTrendPoint {
date: string;
presentRate: number;
lateRate: number;
absentRate: number;
}
/** 班级考勤对比项 */
export interface AttendanceClassComparison {
classId: string;
className: string;
presentRate: number;
lateRate: number;
absentRate: number;
}
/** 学生考勤预警项 */
export interface AttendanceWarning {
studentId: string;
studentName: string;
studentNo: string;
className: string;
presentRate: number;
absentCount: number;
lateCount: number;
}
// ============ Types考勤报告 ============
/** 报告类型 */
export type AttendanceReportType = "WEEKLY" | "MONTHLY";
/** 考勤报告数据 */
export interface AttendanceReport {
classId: string;
className: string;
reportType: AttendanceReportType;
startDate: string;
endDate: string;
generatedAt: string;
summary: AttendanceReportSummary;
details: AttendanceReportDetail[];
}
/** 考勤报告摘要 */
export interface AttendanceReportSummary {
totalRecords: number;
presentRate: number;
lateRate: number;
earlyLeaveRate: number;
leaveRate: number;
warningCount: number;
}
/** 考勤报告明细 */
export interface AttendanceReportDetail {
studentId: string;
studentName: string;
studentNo: string;
presentCount: number;
absentCount: number;
lateCount: number;
earlyLeaveCount: number;
leaveCount: number;
presentRate: number;
}
// ============ Types班级详情 ============
/** 班级详情 */
export interface ClassDetail {
id: string;
name: string;
gradeId: string;
subject: string;
studentCount: number;
homeroomTeacher: string;
room: string | null;
schoolName: string;
}
/** 班级详情聚合 */
export interface ClassDetailAggregate {
class: ClassDetail;
students: ClassDetailStudent[];
schedule: ClassScheduleItem[];
recentHomework: ClassDetailHomework[];
overview: ClassOverviewStats;
trend: ClassDetailTrendPoint[];
}
/** 班级详情学生 */
export interface ClassDetailStudent {
id: string;
name: string;
studentNo: string;
email: string;
}
/** 班级详情近期作业 */
export interface ClassDetailHomework {
id: string;
title: string;
dueDate: string;
submissionRate: number;
avgScore: number | null;
}
/** 班级概览统计 */
export interface ClassOverviewStats {
attendanceRate: number;
averageScore: number;
homeworkCompletionRate: number;
recentExamTitle: string | null;
recentExamAvg: number | null;
}
/** 班级详情趋势点 */
export interface ClassDetailTrendPoint {
month: string;
avgScore: number;
}
// ============ Types课表 ============
/** 课表项 */
export interface ClassScheduleItem {
id: string;
classId: string;
className: string;
dayOfWeek: number; // 1=周一 ... 7=周日
period: number; // 第 N 节1-8
subject: string;
teacherName: string;
room: string;
startTime: string;
endTime: string;
}
// ============ Types请假 ============
/** 请假类型 */
export type LeaveType = "SICK" | "PERSONAL" | "ANNUAL" | "OTHER";
/** 请假申请状态 */
export type LeaveStatus = "PENDING" | "APPROVED" | "REJECTED";
/** 请假申请项 */
export interface LeaveRequest {
id: string;
applicantId: string;
applicantName: string;
applicantRole: string;
type: LeaveType;
startDate: string;
endDate: string;
reason: string;
status: LeaveStatus;
submittedAt: string;
reviewedAt: string | null;
reviewerId: string | null;
reviewerName: string | null;
reviewComment: string | null;
}
/** 请假列表筛选 */
export interface LeaveRequestsFilter {
dataScope?: DataScope | null;
status?: LeaveStatus | null;
page?: number | null;
pageSize?: number | null;
}
/** 请假列表分页结果 */
export interface LeaveRequestsResult {
items: LeaveRequest[];
total: number;
page: number;
pageSize: number;
}
/** 审批请假输入 */
export interface ApproveLeaveRequestInput {
requestId: string;
action: "APPROVE" | "REJECT";
comment?: string | null;
}
/** 审批请假结果 */
export interface ApproveLeaveRequestResult {
requestId: string;
status: LeaveStatus;
reviewedAt: string;
reviewerId: string;
reviewerName: string;
reviewComment: string | null;
}
/** 提交请假申请输入 */
export interface SubmitLeaveRequestInput {
type: LeaveType;
startDate: string;
endDate: string;
reason: string;
}
/** 提交请假申请结果 */
export interface SubmitLeaveRequestResult {
requestId: string;
status: LeaveStatus;
submittedAt: string;
}
// ============ Types调课 ============
/** 调课类型 */
export type ScheduleChangeType = "ADJUST" | "SUBSTITUTE";
/** 调课状态 */
export type ScheduleChangeStatus = "PENDING" | "APPROVED" | "REJECTED";
/** 调课申请项 */
export interface ScheduleChange {
id: string;
requesterId: string;
requesterName: string;
classId: string;
className: string;
originalDate: string;
originalPeriod: number;
originalSubject: string;
adjustedDate: string;
adjustedPeriod: number;
adjustedSubject: string;
substituteTeacherId: string | null;
substituteTeacherName: string | null;
type: ScheduleChangeType;
reason: string;
status: ScheduleChangeStatus;
submittedAt: string;
reviewedAt: string | null;
reviewComment: string | null;
}
/** 调课列表分页结果 */
export interface ScheduleChangesResult {
items: ScheduleChange[];
total: number;
page: number;
pageSize: number;
}
/** 提交调课申请输入 */
export interface SubmitScheduleChangeInput {
classId: string;
originalDate: string;
originalPeriod: number;
originalSubject: string;
adjustedDate: string;
adjustedPeriod: number;
adjustedSubject: string;
substituteTeacherId?: string | null;
type: ScheduleChangeType;
reason: string;
}
/** 提交调课申请结果 */
export interface SubmitScheduleChangeResult {
requestId: string;
status: ScheduleChangeStatus;
submittedAt: string;
}
// ============ Queries / Mutations ============
/** 考勤记录列表查询 */
export const AttendanceListQuery = gql`
query AttendanceList($filter: AttendanceListFilterInput) {
attendanceList(filter: $filter) {
items {
id
classId
className
studentId
studentName
studentNo
date
status
note
}
total
page
pageSize
totalPages
}
}
`;
/** 考勤录入查询(学生列表 + 当天状态) */
export const AttendanceSheetQuery = gql`
query AttendanceSheet($classId: ID!, $date: String!) {
attendanceSheet(classId: $classId, date: $date) {
studentId
studentName
studentNo
date
status
note
}
}
`;
/** 批量保存考勤 Mutation */
export const SaveAttendanceSheetMutation = gql`
mutation SaveAttendanceSheet($input: SaveAttendanceSheetInput!) {
saveAttendanceSheet(input: $input) {
classId
date
savedCount
savedAt
}
}
`;
/** 考勤统计查询 */
export const AttendanceStatsQuery = gql`
query AttendanceStats($classId: ID!, $startDate: String!, $endDate: String!) {
attendanceStats(classId: $classId, startDate: $startDate, endDate: $endDate) {
classId
className
startDate
endDate
totalRecords
presentCount
absentCount
lateCount
earlyLeaveCount
leaveCount
presentRate
lateRate
earlyLeaveRate
leaveRate
warningCount
trend {
date
presentRate
lateRate
absentRate
}
}
}
`;
/** 班级考勤对比查询 */
export const AttendanceClassComparisonQuery = gql`
query AttendanceClassComparison($startDate: String!, $endDate: String!) {
attendanceClassComparison(startDate: $startDate, endDate: $endDate) {
classId
className
presentRate
lateRate
absentRate
}
}
`;
/** 学生考勤预警查询 */
export const AttendanceWarningsQuery = gql`
query AttendanceWarnings($classId: ID!, $startDate: String!, $endDate: String!) {
attendanceWarnings(classId: $classId, startDate: $startDate, endDate: $endDate) {
studentId
studentName
studentNo
className
presentRate
absentCount
lateCount
}
}
`;
/** 考勤报告查询 */
export const AttendanceReportQuery = gql`
query AttendanceReport(
$classId: ID!
$reportType: AttendanceReportType!
$startDate: String!
$endDate: String!
) {
attendanceReport(
classId: $classId
reportType: $reportType
startDate: $startDate
endDate: $endDate
) {
classId
className
reportType
startDate
endDate
generatedAt
summary {
totalRecords
presentRate
lateRate
earlyLeaveRate
leaveRate
warningCount
}
details {
studentId
studentName
studentNo
presentCount
absentCount
lateCount
earlyLeaveCount
leaveCount
presentRate
}
}
}
`;
/** 班级详情查询 */
export const ClassDetailQuery = gql`
query ClassDetail($id: ID!) {
classDetail(id: $id) {
class {
id
name
gradeId
subject
studentCount
homeroomTeacher
room
schoolName
}
students {
id
name
studentNo
email
}
schedule {
id
classId
className
dayOfWeek
period
subject
teacherName
room
startTime
endTime
}
recentHomework {
id
title
dueDate
submissionRate
avgScore
}
overview {
attendanceRate
averageScore
homeworkCompletionRate
recentExamTitle
recentExamAvg
}
trend {
month
avgScore
}
}
}
`;
/** 班级课表查询 */
export const ClassScheduleQuery = gql`
query ClassSchedule($classId: ID) {
classSchedule(classId: $classId) {
id
classId
className
dayOfWeek
period
subject
teacherName
room
startTime
endTime
}
}
`;
/** 请假审批列表查询 */
export const LeaveRequestsQuery = gql`
query LeaveRequests($filter: LeaveRequestsFilterInput) {
leaveRequests(filter: $filter) {
items {
id
applicantId
applicantName
applicantRole
type
startDate
endDate
reason
status
submittedAt
reviewedAt
reviewerId
reviewerName
reviewComment
}
total
page
pageSize
}
}
`;
/** 审批请假 Mutation */
export const ApproveLeaveRequestMutation = gql`
mutation ApproveLeaveRequest($input: ApproveLeaveRequestInput!) {
approveLeaveRequest(input: $input) {
requestId
status
reviewedAt
reviewerId
reviewerName
reviewComment
}
}
`;
/** 提交请假申请 Mutation */
export const SubmitLeaveRequestMutation = gql`
mutation SubmitLeaveRequest($input: SubmitLeaveRequestInput!) {
submitLeaveRequest(input: $input) {
requestId
status
submittedAt
}
}
`;
/** 调课申请列表查询 */
export const ScheduleChangesQuery = gql`
query ScheduleChanges($page: Int, $pageSize: Int) {
scheduleChanges(page: $page, pageSize: $pageSize) {
items {
id
requesterId
requesterName
classId
className
originalDate
originalPeriod
originalSubject
adjustedDate
adjustedPeriod
adjustedSubject
substituteTeacherId
substituteTeacherName
type
reason
status
submittedAt
reviewedAt
reviewComment
}
total
page
pageSize
}
}
`;
/** 提交调课申请 Mutation */
export const SubmitScheduleChangeMutation = gql`
mutation SubmitScheduleChange($input: SubmitScheduleChangeInput!) {
submitScheduleChange(input: $input) {
requestId
status
submittedAt
}
}
`;

View File

@@ -0,0 +1,499 @@
/**
* GraphQL operations - P7 扩展(选修课 + 富文本编辑 + 监考 + 扫描批改 + 课标热力图)
*
* 维护者ai13teacher-portal
* 关联graphql.ts P2/P3 扩展、workline.md §3 P7 交付物
*
* schema 尚未在 ARB-001 P2 第一版中定义,开发期通过 MSW 拦截返回 mock 响应;
* 上游就绪后由 ai03 在 teacher-bff.graphql 扩展,届时合并本文件到 graphql.ts。
*
* 查询范围P7-advanced
* - ElectiveCourses / ElectiveCourseDetail选修课列表 + 详情
* - CreateElectiveCourse / UpdateElectiveCourse / PublishElectiveCourse / CancelElectiveCourse
* - ExamRichEditor / SaveExamRichContent富文本试卷内容
* - ProctoringStatus / ProctoringEvent监考状态 + 事件
* - SubmissionScanGrading / SaveScanGrading扫描批改
* - LessonPlanHeatmap课标覆盖热力图
*/
import { gql } from "urql";
// ============ Types选修课 ============
/** 选修课状态 */
export type ElectiveCourseStatus =
| "draft"
| "open"
| "closed"
| "cancelled";
/** 选课学生项 */
export interface ElectiveStudent {
studentId: string;
studentName: string;
studentNo: string;
enrolledAt: string;
status: "enrolled" | "cancelled";
}
/** 选修课列表项 */
export interface ElectiveCourseItem {
id: string;
title: string;
subject: string;
grade: string;
capacity: number;
enrolledCount: number;
teacherId: string;
teacherName: string;
semester: string;
status: ElectiveCourseStatus;
description: string | null;
}
/** 选修课详情(含学生列表) */
export interface ElectiveCourseDetail extends ElectiveCourseItem {
students: ElectiveStudent[];
createdAt: string;
updatedAt: string;
}
/** 创建选修课输入 */
export interface CreateElectiveCourseInput {
title: string;
subject: string;
grade: string;
capacity: number;
semester: string;
description?: string | null;
}
/** 更新选修课输入 */
export interface UpdateElectiveCourseInput {
id: string;
title?: string;
subject?: string;
grade?: string;
capacity?: number;
semester?: string;
description?: string | null;
}
/** 创建/更新结果 */
export interface ElectiveCourseMutationResult {
id: string;
status: ElectiveCourseStatus;
updatedAt: string;
}
// ============ Types富文本编辑 ============
/** 富文本试卷内容Tiptap examNodes JSON 结构mock 用 JSON */
export interface ExamRichContent {
examId: string;
title: string;
totalScore: number;
questionCount: number;
content: unknown; // Tiptap JSON结构由编辑器决定
updatedAt: string;
}
/** 保存富文本输入 */
export interface SaveExamRichContentInput {
examId: string;
content: unknown;
}
/** 保存富文本结果 */
export interface SaveExamRichContentResult {
examId: string;
updatedAt: string;
}
// ============ Types监考 ============
/** 监考学生状态 */
export type ProctoringStudentStatus =
| "online"
| "offline"
| "submitted"
| "flagged";
/** 监考摘要 */
export interface ProctoringSummary {
examId: string;
expectedCount: number;
onlineCount: number;
submittedCount: number;
flaggedCount: number;
isProctoring: boolean;
startedAt: string | null;
}
/** 监考学生状态项 */
export interface ProctoringStudent {
studentId: string;
studentName: string;
studentNo: string;
status: ProctoringStudentStatus;
lastEventAt: string | null;
ipAddress: string | null;
}
/** 监考事件类型 */
export type ProctoringEventType = "WARNING" | "FLAG" | "NOTE";
/** 监考事件项 */
export interface ProctoringEvent {
eventId: string;
examId: string;
studentId: string;
studentName: string;
studentNo: string;
eventType: ProctoringEventType;
note: string | null;
createdAt: string;
}
/** 监考状态完整结构 */
export interface ProctoringStatus {
summary: ProctoringSummary;
students: ProctoringStudent[];
recentEvents: ProctoringEvent[];
}
/** 提交监考事件输入 */
export interface ProctoringEventInput {
examId: string;
studentId: string;
eventType: ProctoringEventType;
note?: string | null;
}
/** 提交监考事件结果 */
export interface ProctoringEventResult {
eventId: string;
createdAt: string;
}
// ============ Types扫描批改 ============
/** 扫描图片项 */
export interface ScanImage {
imageId: string;
url: string;
page: number;
}
/** 扫描批改单题项 */
export interface ScanGradingItem {
questionId: string;
questionTitle: string;
questionOrder: number;
maxScore: number;
recognizedAnswer: string;
aiSuggestedScore: number | null;
aiSuggestion: string | null;
score: number | null;
feedback: string | null;
}
/** 扫描批改视图 */
export interface SubmissionScanGrading {
submissionId: string;
homeworkTitle: string;
studentName: string;
studentNo: string;
submittedAt: string;
images: ScanImage[];
items: ScanGradingItem[];
prevSubmissionId: string | null;
nextSubmissionId: string | null;
}
/** 保存扫描批改单项 */
export interface SaveScanGradingItem {
questionId: string;
score: number;
feedback?: string | null;
}
/** 保存扫描批改输入 */
export interface SaveScanGradingInput {
submissionId: string;
items: SaveScanGradingItem[];
}
/** 保存扫描批改结果 */
export interface SaveScanGradingResult {
submissionId: string;
totalScore: number;
savedAt: string;
}
// ============ Types课标热力图 ============
/** 热力图知识点项 */
export interface HeatmapKnowledgePoint {
kpId: string;
name: string;
chapterName: string;
}
/** 热力图课案项 */
export interface HeatmapLessonPlan {
planId: string;
title: string;
}
/** 热力图覆盖矩阵单元格 */
export interface HeatmapCell {
kpId: string;
planId: string;
coverage: number; // 0-3 覆盖课案数
}
/** 课标覆盖热力图 */
export interface LessonPlanHeatmap {
textbookId: string;
textbookTitle: string;
knowledgePoints: HeatmapKnowledgePoint[];
lessonPlans: HeatmapLessonPlan[];
cells: HeatmapCell[];
}
// ============ Queries / Mutations选修课 ============
/** 选修课列表查询(按状态筛选,教师范围) */
export const ElectiveCoursesQuery = gql`
query ElectiveCourses($status: String, $subject: String) {
electiveCourses(status: $status, subject: $subject) {
id
title
subject
grade
capacity
enrolledCount
teacherId
teacherName
semester
status
description
}
}
`;
/** 选修课详情查询(含学生列表) */
export const ElectiveCourseDetailQuery = gql`
query ElectiveCourseDetail($id: ID!) {
electiveCourseDetail(id: $id) {
id
title
subject
grade
capacity
enrolledCount
teacherId
teacherName
semester
status
description
students {
studentId
studentName
studentNo
enrolledAt
status
}
createdAt
updatedAt
}
}
`;
/** 创建选修课 Mutation */
export const CreateElectiveCourseMutation = gql`
mutation CreateElectiveCourse($input: CreateElectiveCourseInput!) {
createElectiveCourse(input: $input) {
id
status
updatedAt
}
}
`;
/** 更新选修课 Mutation */
export const UpdateElectiveCourseMutation = gql`
mutation UpdateElectiveCourse($input: UpdateElectiveCourseInput!) {
updateElectiveCourse(input: $input) {
id
status
updatedAt
}
}
`;
/** 发布选修课 Mutation */
export const PublishElectiveCourseMutation = gql`
mutation PublishElectiveCourse($id: ID!) {
publishElectiveCourse(id: $id) {
id
status
updatedAt
}
}
`;
/** 取消选修课 Mutation */
export const CancelElectiveCourseMutation = gql`
mutation CancelElectiveCourse($id: ID!) {
cancelElectiveCourse(id: $id) {
id
status
updatedAt
}
}
`;
// ============ Queries / Mutations富文本编辑 ============
/** 富文本试卷内容查询 */
export const ExamRichEditorQuery = gql`
query ExamRichEditor($examId: ID!) {
examRichEditor(examId: $examId) {
examId
title
totalScore
questionCount
content
updatedAt
}
}
`;
/** 保存富文本内容 Mutation */
export const SaveExamRichContentMutation = gql`
mutation SaveExamRichContent($input: SaveExamRichContentInput!) {
saveExamRichContent(input: $input) {
examId
updatedAt
}
}
`;
// ============ Queries / Mutations监考 ============
/** 监考状态查询 */
export const ProctoringStatusQuery = gql`
query ProctoringStatus($examId: ID!) {
proctoringStatus(examId: $examId) {
summary {
examId
expectedCount
onlineCount
submittedCount
flaggedCount
isProctoring
startedAt
}
students {
studentId
studentName
studentNo
status
lastEventAt
ipAddress
}
recentEvents {
eventId
examId
studentId
studentName
studentNo
eventType
note
createdAt
}
}
}
`;
/** 提交监考事件 Mutation */
export const ProctoringEventMutation = gql`
mutation ProctoringEvent($input: ProctoringEventInput!) {
proctoringEvent(input: $input) {
eventId
createdAt
}
}
`;
// ============ Queries / Mutations扫描批改 ============
/** 扫描批改视图查询 */
export const SubmissionScanGradingQuery = gql`
query SubmissionScanGrading($submissionId: ID!) {
submissionScanGrading(submissionId: $submissionId) {
submissionId
homeworkTitle
studentName
studentNo
submittedAt
images {
imageId
url
page
}
items {
questionId
questionTitle
questionOrder
maxScore
recognizedAnswer
aiSuggestedScore
aiSuggestion
score
feedback
}
prevSubmissionId
nextSubmissionId
}
}
`;
/** 保存扫描批改 Mutation */
export const SaveScanGradingMutation = gql`
mutation SaveScanGrading($input: SaveScanGradingInput!) {
saveScanGrading(input: $input) {
submissionId
totalScore
savedAt
}
}
`;
// ============ Queries课标热力图 ============
/** 课标覆盖热力图查询(按教材 ID */
export const LessonPlanHeatmapQuery = gql`
query LessonPlanHeatmap($textbookId: ID!) {
lessonPlanHeatmap(textbookId: $textbookId) {
textbookId
textbookTitle
knowledgePoints {
kpId
name
chapterName
}
lessonPlans {
planId
title
}
cells {
kpId
planId
coverage
}
}
}
`;

View File

@@ -0,0 +1,628 @@
/**
* GraphQL operations - P7 扩展(组卷 + 题库 + 教材 + 考试分析)
*
* 维护者ai13teacher-portal
* 关联graphql.ts P2/P3 扩展、workline.md §3 P7 交付物
*
* schema 尚未在 ARB-001 P2 第一版中定义,开发期通过 MSW 拦截返回 mock 响应;
* 上游就绪后由 ai03 在 teacher-bff.graphql 扩展,届时合并本文件到 graphql.ts。
*
* 查询范围P7
* - ExamBuild按 examId 拉取试卷结构 + 题库候选列表分页
* - SaveExamBuild保存组卷已选题目 + 分值 + 排序)
* - ExamAnalytics考后分析分数分布/均分/最高/最低/及格率/科目对比/每题正确率/学生排名)
* - QuestionsLibrary题库列表六维筛选 + 分页 + 总数)
* - QuestionCreate / QuestionUpdate / QuestionDelete题目 CRUD
* - QuestionBatchImport / QuestionBatchExport批量导入导出
* - Textbooks教材列表q/subject/grade 筛选 + 分页)
* - TextbookDetail教材详情章节树 + 知识点列表)
* - TextbookCreate创建教材
*/
import { gql } from "urql";
// ============ Types题库 ============
/** 题目类型 */
export type QuestionType =
| "SINGLE_CHOICE"
| "MULTIPLE_CHOICE"
| "TRUE_FALSE"
| "FILL_BLANK"
| "SHORT_ANSWER";
/** 难度等级 */
export type QuestionDifficulty = "EASY" | "MEDIUM" | "HARD";
/** 题库项 */
export interface QuestionItem {
questionId: string;
content: string;
type: QuestionType;
difficulty: QuestionDifficulty;
textbookId: string | null;
textbookName: string | null;
chapterId: string | null;
chapterName: string | null;
kpId: string | null;
kpName: string | null;
score: number;
answer: string;
analysis: string;
createdAt: string;
updatedAt: string;
}
/** 题库分页结果 */
export interface QuestionsLibraryPage {
items: QuestionItem[];
total: number;
page: number;
pageSize: number;
}
/** 题库筛选条件 */
export interface QuestionsLibraryFilter {
q?: string | null;
type?: QuestionType | null;
difficulty?: QuestionDifficulty | null;
kp?: string | null;
textbook?: string | null;
chapter?: string | null;
page?: number;
pageSize?: number;
}
/** 创建题目输入 */
export interface QuestionCreateInput {
content: string;
type: QuestionType;
difficulty: QuestionDifficulty;
textbookId?: string | null;
chapterId?: string | null;
kpId?: string | null;
score: number;
answer: string;
analysis?: string | null;
}
/** 更新题目输入 */
export interface QuestionUpdateInput {
questionId: string;
content?: string;
type?: QuestionType;
difficulty?: QuestionDifficulty;
textbookId?: string | null;
chapterId?: string | null;
kpId?: string | null;
score?: number;
answer?: string;
analysis?: string | null;
}
/** 批量导入项 */
export interface QuestionBatchImportItem {
content: string;
type: QuestionType;
difficulty: QuestionDifficulty;
textbookId?: string | null;
chapterId?: string | null;
kpId?: string | null;
score: number;
answer: string;
analysis?: string | null;
}
/** 批量导入输入 */
export interface QuestionBatchImportInput {
items: QuestionBatchImportItem[];
}
/** 批量导入结果 */
export interface QuestionBatchImportResult {
importedCount: number;
failedCount: number;
errors: string[];
}
/** 批量导出结果 */
export interface QuestionBatchExportResult {
items: QuestionItem[];
exportedAt: string;
}
// ============ Types教材 ============
/** 教材项(列表) */
export interface TextbookItem {
id: string;
title: string;
subject: string;
grade: string;
version: string;
coverUrl: string | null;
chapterCount: number;
createdAt: string;
}
/** 教材分页结果 */
export interface TextbooksPage {
items: TextbookItem[];
total: number;
page: number;
pageSize: number;
}
/** 教材筛选 */
export interface TextbooksFilter {
q?: string | null;
subject?: string | null;
grade?: string | null;
page?: number;
pageSize?: number;
}
/** 教材创建输入 */
export interface TextbookCreateInput {
title: string;
subject: string;
grade: string;
version: string;
coverUrl?: string | null;
}
/** 知识点 */
export interface KnowledgePoint {
id: string;
name: string;
description: string | null;
}
/** 章节节点(含知识点) */
export interface TextbookChapter {
id: string;
textbookId: string;
title: string;
order: number;
content: string;
parentId: string | null;
knowledgePoints: KnowledgePoint[];
}
/** 教材详情 */
export interface TextbookDetail {
id: string;
title: string;
subject: string;
grade: string;
version: string;
coverUrl: string | null;
chapters: TextbookChapter[];
}
// ============ Types组卷 ============
/** 已选题目(试卷节点) */
export interface ExamBuildNode {
questionId: string;
score: number;
sortOrder: number;
// 题目冗余字段(便于预览)
content: string;
type: QuestionType;
difficulty: QuestionDifficulty;
}
/** 试卷结构 */
export interface ExamBuildStructure {
examId: string;
title: string;
totalScore: number;
passScore: number;
duration: number;
selected: ExamBuildNode[];
// 题库候选列表(首屏分页)
candidates: QuestionsLibraryPage;
}
/** 保存组卷输入项 */
export interface SaveExamBuildNodeInput {
questionId: string;
score: number;
sortOrder: number;
}
/** 保存组卷输入 */
export interface SaveExamBuildInput {
examId: string;
questions: SaveExamBuildNodeInput[];
}
/** 保存组卷结果 */
export interface SaveExamBuildResult {
examId: string;
totalScore: number;
questionCount: number;
savedAt: string;
}
// ============ Types考试分析 ============
/** 顶部统计卡片 */
export interface ExamAnalyticsSummary {
expectedCount: number;
attendedCount: number;
avgScore: number;
maxScore: number;
minScore: number;
passRate: number;
}
/** 分数分布段 */
export interface ExamScoreBand {
label: string;
min: number;
max: number;
count: number;
}
/** 每题正确率 */
export interface ExamQuestionAccuracy {
questionId: string;
questionTitle: string;
order: number;
correctRate: number;
avgScore: number;
maxScore: number;
}
/** 知识点掌握度(同 grades 模块,但语义独立) */
export interface ExamKpMastery {
knowledgePoint: string;
mastery: number;
}
/** 班级对比(复用 grades 类型语义,独立定义) */
export interface ExamClassComparison {
classId: string;
className: string;
avg: number;
significant: boolean;
}
/** 历次考试趋势点 */
export interface ExamHistoryTrend {
examTitle: string;
examDate: string;
avg: number;
}
/** 学生排名项 */
export interface ExamStudentRank {
rank: number;
studentId: string;
studentName: string;
studentNo: string;
totalScore: number;
level: "A" | "B" | "C" | "D" | "E";
}
/** 考试分析完整结构 */
export interface ExamAnalytics {
examId: string;
examTitle: string;
summary: ExamAnalyticsSummary;
distribution: ExamScoreBand[];
questionAccuracy: ExamQuestionAccuracy[];
knowledgeMastery: ExamKpMastery[];
classComparison: ExamClassComparison[];
historyTrend: ExamHistoryTrend[];
rankings: ExamStudentRank[];
}
// ============ Queries / Mutations ============
/** 组卷查询(按 examId 拉取试卷结构 + 题库候选列表分页) */
export const ExamBuildQuery = gql`
query ExamBuild(
$examId: ID!
$candidatePage: Int
$candidatePageSize: Int
$filter: QuestionsLibraryFilter
) {
examBuild(
examId: $examId
candidatePage: $candidatePage
candidatePageSize: $candidatePageSize
filter: $filter
) {
examId
title
totalScore
passScore
duration
selected {
questionId
score
sortOrder
content
type
difficulty
}
candidates {
items {
questionId
content
type
difficulty
textbookId
textbookName
chapterId
chapterName
kpId
kpName
score
answer
analysis
createdAt
updatedAt
}
total
page
pageSize
}
}
}
`;
/** 保存组卷 Mutation */
export const SaveExamBuildMutation = gql`
mutation SaveExamBuild($input: SaveExamBuildInput!) {
saveExamBuild(input: $input) {
examId
totalScore
questionCount
savedAt
}
}
`;
/** 考试分析查询 */
export const ExamAnalyticsQuery = gql`
query ExamAnalytics($examId: ID!) {
examAnalytics(examId: $examId) {
examId
examTitle
summary {
expectedCount
attendedCount
avgScore
maxScore
minScore
passRate
}
distribution {
label
min
max
count
}
questionAccuracy {
questionId
questionTitle
order
correctRate
avgScore
maxScore
}
knowledgeMastery {
knowledgePoint
mastery
}
classComparison {
classId
className
avg
significant
}
historyTrend {
examTitle
examDate
avg
}
rankings {
rank
studentId
studentName
studentNo
totalScore
level
}
}
}
`;
/** 题库列表查询(六维筛选 + 分页 + 总数) */
export const QuestionsLibraryQuery = gql`
query QuestionsLibrary($filter: QuestionsLibraryFilter) {
questionsLibrary(filter: $filter) {
items {
questionId
content
type
difficulty
textbookId
textbookName
chapterId
chapterName
kpId
kpName
score
answer
analysis
createdAt
updatedAt
}
total
page
pageSize
}
}
`;
/** 创建题目 Mutation */
export const QuestionCreateMutation = gql`
mutation QuestionCreate($input: QuestionCreateInput!) {
questionCreate(input: $input) {
questionId
content
type
difficulty
textbookId
textbookName
chapterId
chapterName
kpId
kpName
score
answer
analysis
createdAt
updatedAt
}
}
`;
/** 更新题目 Mutation */
export const QuestionUpdateMutation = gql`
mutation QuestionUpdate($input: QuestionUpdateInput!) {
questionUpdate(input: $input) {
questionId
content
type
difficulty
textbookId
textbookName
chapterId
chapterName
kpId
kpName
score
answer
analysis
createdAt
updatedAt
}
}
`;
/** 删除题目 Mutation */
export const QuestionDeleteMutation = gql`
mutation QuestionDelete($questionId: ID!) {
questionDelete(questionId: $questionId) {
questionId
deleted
}
}
`;
/** 批量导入题目 Mutation */
export const QuestionBatchImportMutation = gql`
mutation QuestionBatchImport($input: QuestionBatchImportInput!) {
questionBatchImport(input: $input) {
importedCount
failedCount
errors
}
}
`;
/** 批量导出题目查询(按当前筛选条件) */
export const QuestionBatchExportQuery = gql`
query QuestionBatchExport($filter: QuestionsLibraryFilter) {
questionBatchExport(filter: $filter) {
items {
questionId
content
type
difficulty
textbookId
textbookName
chapterId
chapterName
kpId
kpName
score
answer
analysis
createdAt
updatedAt
}
exportedAt
}
}
`;
/** 教材列表查询 */
export const TextbooksQuery = gql`
query Textbooks($filter: TextbooksFilter) {
textbooks(filter: $filter) {
items {
id
title
subject
grade
version
coverUrl
chapterCount
createdAt
}
total
page
pageSize
}
}
`;
/** 教材详情查询(章节树 + 知识点列表) */
export const TextbookDetailQuery = gql`
query TextbookDetail($id: ID!) {
textbookDetail(id: $id) {
id
title
subject
grade
version
coverUrl
chapters {
id
textbookId
title
order
content
parentId
knowledgePoints {
id
name
description
}
}
}
}
`;
/** 创建教材 Mutation */
export const TextbookCreateMutation = gql`
mutation TextbookCreate($input: TextbookCreateInput!) {
textbookCreate(input: $input) {
id
title
subject
grade
version
coverUrl
chapterCount
createdAt
}
}
`;

View File

@@ -0,0 +1,418 @@
/**
* GraphQL operations - P7 扩展(成绩完整子模块 + 作业批改链路)
*
* 维护者ai13teacher-portal
* 关联graphql.ts P2/P3 扩展、workline.md §3 P7 交付物
*
* schema 尚未在 ARB-001 P2 第一版中定义,开发期通过 MSW 拦截返回 mock 响应;
* 上游就绪后由 ai03 在 teacher-bff.graphql 扩展,届时合并本文件到 graphql.ts。
*
* 查询范围P7
* - GradeEntry按 examId + classId 拉取学生成绩录入列表
* - SaveGradeEntries批量保存成绩录入
* - GradeStats班级成绩统计avg/median/max/min/passRate/stdDev/rankings
* - GradeAnalytics成绩分析趋势/分布/学科对比/班级对比/知识点掌握度)
* - ReportCard学生成绩报告卡
* - HomeworkSubmissions按作业维度统计提交/已批数
* - HomeworkAssignmentSubmissions按作业 ID 拉取所有提交列表
* - GradeSubmission单份提交批改
*/
import { gql } from "urql";
// ============ Types成绩录入 ============
/** 成绩录入项(单生单考试) */
export interface GradeEntryItem {
studentId: string;
studentName: string;
studentNo: string;
score: number | null;
feedback: string | null;
}
/** 批量保存成绩录入项 */
export interface SaveGradeEntryInput {
studentId: string;
score: number;
feedback?: string | null;
}
/** 批量保存成绩录入输入 */
export interface SaveGradeEntriesInput {
examId: string;
classId: string;
entries: SaveGradeEntryInput[];
}
/** 批量保存结果 */
export interface SaveGradeEntriesResult {
examId: string;
classId: string;
savedCount: number;
}
// ============ Types班级成绩统计 ============
/** 班级成绩统计 */
export interface GradeStats {
classId: string;
className: string;
subject: string;
avg: number;
median: number;
max: number;
min: number;
passRate: number;
stdDev: number;
totalCount: number;
rankings: GradeRanking[];
}
/** 排名项 */
export interface GradeRanking {
rank: number;
studentId: string;
studentName: string;
studentNo: string;
totalScore: number;
level: "A" | "B" | "C" | "D" | "E";
}
// ============ Types成绩分析 ============
/** 成绩分析聚合 */
export interface GradeAnalytics {
classId: string;
className: string;
trend: GradeTrendPoint[];
distribution: GradeDistributionBand[];
subjectComparison: SubjectComparisonItem[];
classComparison: ClassComparisonItem[];
knowledgeMastery: KnowledgeMasteryItem[];
}
/** 趋势点 */
export interface GradeTrendPoint {
month: string;
avg: number;
max: number;
min: number;
}
/** 分数分布段 */
export interface GradeDistributionBand {
label: string;
min: number;
max: number;
count: number;
}
/** 学科对比项 */
export interface SubjectComparisonItem {
subject: string;
avg: number;
}
/** 班级对比项 */
export interface ClassComparisonItem {
classId: string;
className: string;
avg: number;
significant: boolean;
}
/** 知识点掌握度项 */
export interface KnowledgeMasteryItem {
knowledgePoint: string;
mastery: number;
}
// ============ Types报告卡 ============
/** 学生成绩报告卡 */
export interface ReportCard {
studentId: string;
studentName: string;
studentNo: string;
className: string;
academicYear: string;
semester: string;
subjects: ReportCardSubject[];
totalScore: number;
classRank: number;
classSize: number;
gradeRank: number;
gradeSize: number;
teacherComment: string;
}
/** 报告卡科目项 */
export interface ReportCardSubject {
subject: string;
score: number;
rank: number;
teacherName: string;
}
// ============ Types作业提交 ============
/** 作业提交统计项(按作业维度) */
export interface HomeworkSubmissionStat {
homeworkId: string;
title: string;
classId: string;
className: string;
status: "draft" | "published" | "graded";
dueDate: string;
totalCount: number;
submittedCount: number;
gradedCount: number;
avgScore: number | null;
}
/** 单份作业提交详情(含题目作答) */
export interface HomeworkSubmissionDetail {
submissionId: string;
homeworkId: string;
homeworkTitle: string;
classId: string;
className: string;
studentId: string;
studentName: string;
studentNo: string;
status: "SUBMITTED" | "GRADED";
submittedAt: string;
totalScore: number | null;
answers: SubmissionAnswer[];
prevSubmissionId: string | null;
nextSubmissionId: string | null;
}
/** 单题作答 */
export interface SubmissionAnswer {
questionId: string;
questionTitle: string;
questionType: "SINGLE_CHOICE" | "MULTIPLE_CHOICE" | "SHORT_ANSWER" | "ESSAY";
maxScore: number;
studentAnswer: string;
correctAnswer: string;
score: number | null;
feedback: string | null;
aiSuggestion: string | null;
}
/** 单份批改输入 */
export interface GradeSubmissionInput {
submissionId: string;
score: number;
feedback?: string | null;
aiAssisted?: boolean;
questionScores?: Array<{
questionId: string;
score: number;
feedback?: string | null;
}>;
}
/** 单份批改结果 */
export interface GradeSubmissionResult {
submissionId: string;
status: "GRADED";
totalScore: number;
feedback: string | null;
}
/** AI 批量评分建议项 */
export interface AiBatchGradingItem {
submissionId: string;
suggestedScore: number;
suggestion: string;
}
// ============ Queries / Mutations ============
/** 成绩录入列表查询(按 examId + classId */
export const GradeEntryQuery = gql`
query GradeEntry($examId: ID!, $classId: ID!) {
gradeEntry(examId: $examId, classId: $classId) {
studentId
studentName
studentNo
score
feedback
}
}
`;
/** 批量保存成绩录入 Mutation */
export const SaveGradeEntriesMutation = gql`
mutation SaveGradeEntries($input: SaveGradeEntriesInput!) {
saveGradeEntries(input: $input) {
examId
classId
savedCount
}
}
`;
/** 班级成绩统计查询 */
export const GradeStatsQuery = gql`
query GradeStats($classId: ID!, $subject: String) {
gradeStats(classId: $classId, subject: $subject) {
classId
className
subject
avg
median
max
min
passRate
stdDev
totalCount
rankings {
rank
studentId
studentName
studentNo
totalScore
level
}
}
}
`;
/** 成绩分析查询 */
export const GradeAnalyticsQuery = gql`
query GradeAnalytics($classId: ID!, $semester: String, $examType: String) {
gradeAnalytics(classId: $classId, semester: $semester, examType: $examType) {
classId
className
trend {
month
avg
max
min
}
distribution {
label
min
max
count
}
subjectComparison {
subject
avg
}
classComparison {
classId
className
avg
significant
}
knowledgeMastery {
knowledgePoint
mastery
}
}
}
`;
/** 学生成绩报告卡查询 */
export const ReportCardQuery = gql`
query ReportCard($studentId: ID!, $academicYear: String, $semester: String) {
reportCard(studentId: $studentId, academicYear: $academicYear, semester: $semester) {
studentId
studentName
studentNo
className
academicYear
semester
subjects {
subject
score
rank
teacherName
}
totalScore
classRank
classSize
gradeRank
gradeSize
teacherComment
}
}
`;
/** 作业提交统计列表查询(按作业维度) */
export const HomeworkSubmissionsQuery = gql`
query HomeworkSubmissions($classId: ID, $status: String) {
homeworkSubmissions(classId: $classId, status: $status) {
homeworkId
title
classId
className
status
dueDate
totalCount
submittedCount
gradedCount
avgScore
}
}
`;
/** 按作业 ID 拉取所有提交列表查询 */
export const HomeworkAssignmentSubmissionsQuery = gql`
query HomeworkAssignmentSubmissions($homeworkId: ID!) {
homeworkAssignmentSubmissions(homeworkId: $homeworkId) {
submissionId
homeworkId
homeworkTitle
classId
className
studentId
studentName
studentNo
status
submittedAt
totalScore
answers {
questionId
questionTitle
questionType
maxScore
studentAnswer
correctAnswer
score
feedback
aiSuggestion
}
prevSubmissionId
nextSubmissionId
}
}
`;
/** 单份提交批改 Mutation */
export const GradeSubmissionMutation = gql`
mutation GradeSubmission($input: GradeSubmissionInput!) {
gradeSubmission(input: $input) {
submissionId
status
totalScore
feedback
}
}
`;
/** AI 批量评分建议查询mock返回 AI 评分建议列表) */
export const AiBatchGradingQuery = gql`
query AiBatchGrading($homeworkId: ID!) {
aiBatchGrading(homeworkId: $homeworkId) {
submissionId
suggestedScore
suggestion
}
}
`;

View File

@@ -0,0 +1,737 @@
/**
* GraphQL operations - P7 扩展(备课 + 课程计划 + 诊断 + 错题本 + 自适应练习)
*
* 维护者ai13teacher-portal
* 关联graphql.ts P2/P3 扩展、workline.md §3 P7 交付物
*
* schema 尚未在 ARB-001 P2 第一版中定义,开发期通过 MSW 拦截返回 mock 响应;
* 上游就绪后由 ai03 在 teacher-bff.graphql 扩展,届时合并本文件到 graphql.ts。
*
* 查询范围P7-insights
* - LessonPlans / LessonPlanDetail / LessonPlanLibrary / LessonPlanCalendar / LessonPlanHeatmap
* - CreateLessonPlan / UpdateLessonPlan / AIGenerateLessonPlan / ForkLessonPlan
* - CoursePlans / CoursePlanDetail / CreateCoursePlan / UpdateCoursePlan
* - DiagnosticReports / ClassDiagnostic / StudentDiagnostic
* - ErrorBook错题分析聚合
* - PracticeAnalytics自适应练习分析聚合
*/
import { gql } from "urql";
// ============ Types课案 ============
export type LessonPlanStatus = "DRAFT" | "PUBLISHED" | "ARCHIVED";
export type LessonPlanTemplate =
| "REGULAR"
| "UNIT"
| "REVIEW"
| "ACTIVITY"
| "PERSONALIZED";
export interface LessonPlanAIHistoryItem {
id: string;
prompt: string;
generatedAt: string;
model: string;
}
export interface LessonPlanItem {
id: string;
title: string;
subject: string;
grade: string;
chapter: string;
status: LessonPlanStatus;
textbookId: string | null;
textbookTitle: string | null;
classIds: string[];
updatedAt: string;
}
export interface LessonPlanDetail {
id: string;
title: string;
subject: string;
grade: string;
chapter: string;
content: string;
status: LessonPlanStatus;
textbookId: string | null;
textbookTitle: string | null;
classIds: string[];
aiHistory: LessonPlanAIHistoryItem[];
updatedAt: string;
createdAt: string;
}
export interface LessonPlanLibraryItem {
id: string;
title: string;
author: string;
subject: string;
grade: string;
chapter: string;
rating: number;
forkCount: number;
updatedAt: string;
}
export interface LessonPlanCalendarDay {
date: string;
planCount: number;
planTitles: string[];
}
export interface LessonPlanCalendarMonth {
year: number;
month: number;
days: LessonPlanCalendarDay[];
}
export interface LessonPlanHeatmapCell {
textbookKp: string;
planIds: string[];
covered: boolean;
}
export interface LessonPlanHeatmap {
rows: string[];
cols: string[];
cells: LessonPlanHeatmapCell[];
}
export interface CreateLessonPlanInput {
title: string;
subject: string;
grade: string;
chapter: string;
textbookId?: string | null;
template: LessonPlanTemplate;
}
export interface UpdateLessonPlanInput {
planId: string;
title?: string;
content?: string;
status?: LessonPlanStatus;
textbookId?: string | null;
chapter?: string;
classIds?: string[];
}
export interface AIGenerateLessonPlanInput {
subject: string;
grade: string;
chapter: string;
prompt: string;
}
export interface AIGenerateLessonPlanResult {
id: string;
content: string;
summary: string;
model: string;
generatedAt: string;
}
// ============ Types课程计划 ============
export type CoursePlanStatus = "DRAFT" | "PUBLISHED" | "ARCHIVED";
export interface CoursePlanChapter {
id: string;
title: string;
hours: number;
objectives: string;
keyPoints: string;
activities: string;
}
export interface CoursePlanItem {
id: string;
title: string;
subject: string;
grade: string;
academicYear: string;
semester: string;
totalHours: number;
status: CoursePlanStatus;
textbookId: string | null;
updatedAt: string;
}
export interface CoursePlanDetail {
id: string;
title: string;
subject: string;
grade: string;
academicYear: string;
semester: string;
totalHours: number;
status: CoursePlanStatus;
textbookId: string | null;
textbookTitle: string | null;
homeworkCount: number;
chapters: CoursePlanChapter[];
updatedAt: string;
}
export interface CreateCoursePlanInput {
title: string;
subject: string;
grade: string;
academicYear: string;
semester: string;
totalHours: number;
textbookId?: string | null;
}
export interface UpdateCoursePlanInput {
id: string;
title?: string;
status?: CoursePlanStatus;
chapters?: CoursePlanChapter[];
}
// ============ Types诊断 ============
export type DiagnosticReportType = "INDIVIDUAL" | "CLASS" | "GRADE";
export type DiagnosticReportStatus = "DRAFT" | "PUBLISHED" | "ARCHIVED";
export interface DiagnosticReportItem {
id: string;
title: string;
type: DiagnosticReportType;
targetType: string;
status: DiagnosticReportStatus;
createdAt: string;
}
export interface DiagnosticKpMastery {
knowledgePoint: string;
avg: number;
median: number;
studentCount: number;
distribution: { mastered: number; partial: number; weak: number };
}
export interface DiagnosticStudentRank {
rank: number;
studentId: string;
studentName: string;
masteryAvg: number;
}
export interface ClassDiagnostic {
classId: string;
className: string;
studentCount: number;
summary: { knowledgePoint: string; avg: number }[];
kpMastery: DiagnosticKpMastery[];
rankings: DiagnosticStudentRank[];
}
export interface DiagnosticRadarPoint {
axis: string;
value: number;
classAvg: number;
}
export interface StudentDiagnostic {
studentId: string;
studentName: string;
radar: DiagnosticRadarPoint[];
reports: { id: string; title: string; createdAt: string; status: DiagnosticReportStatus }[];
}
// ============ Types错题本 ============
export interface ErrorBookSummary {
totalErrors: number;
highFreqErrors: number;
weakKpCount: number;
avgErrorRate: number;
trendUp: boolean;
}
export interface ErrorBookClassComparison {
classId: string;
className: string;
errorCount: number;
}
export interface ErrorBookChapterWeakness {
chapter: string;
errorCount: number;
}
export interface ErrorBookKpWeakness {
knowledgePoint: string;
errorRate: number;
}
export interface ErrorBookStudentGroup {
studentId: string;
studentName: string;
errorCount: number;
topErrors: { questionId: string; content: string; errorRate: number }[];
}
export interface ErrorBookTopQuestion {
questionId: string;
content: string;
errorRate: number;
subject: string;
}
export interface ErrorBook {
subject: string;
classId: string;
summary: ErrorBookSummary;
classComparison: ErrorBookClassComparison[];
chapterWeakness: ErrorBookChapterWeakness[];
kpWeakness: ErrorBookKpWeakness[];
studentGroups: ErrorBookStudentGroup[];
topQuestions: ErrorBookTopQuestion[];
}
// ============ Types练习 ============
export interface PracticeSummary {
totalSessions: number;
completionRate: number;
avgAccuracy: number;
weakKpCount: number;
participationRate: number;
}
export interface PracticeClassComparison {
classId: string;
className: string;
totalSessions: number;
completionRate: number;
avgAccuracy: number;
participationRate: number;
weakKpCount: number;
}
export interface PracticeTypeBreakdown {
type: string;
count: number;
ratio: number;
}
export interface PracticeKpWeakness {
knowledgePoint: string;
accuracy: number;
}
export interface PracticeStudentRank {
rank: number;
studentId: string;
studentName: string;
studentNo: string;
totalSessions: number;
accuracy: number;
weakKp: string;
}
export interface PracticeInactiveStudent {
studentId: string;
studentName: string;
lastActiveDays: number;
}
export interface PracticeAnalytics {
classId: string;
summary: PracticeSummary;
classComparison: PracticeClassComparison[];
typeBreakdown: PracticeTypeBreakdown[];
kpWeakness: PracticeKpWeakness[];
rankings: PracticeStudentRank[];
inactiveStudents: PracticeInactiveStudent[];
}
// ============ Queries / Mutations课案 ============
export const LessonPlansQuery = gql`
query LessonPlans($subject: String) {
lessonPlans(subject: $subject) {
id
title
subject
grade
chapter
status
textbookId
textbookTitle
classIds
updatedAt
}
}
`;
export const LessonPlanDetailQuery = gql`
query LessonPlanDetail($planId: ID!) {
lessonPlanDetail(planId: $planId) {
id
title
subject
grade
chapter
content
status
textbookId
textbookTitle
classIds
aiHistory {
id
prompt
generatedAt
model
}
updatedAt
createdAt
}
}
`;
export const CreateLessonPlanMutation = gql`
mutation CreateLessonPlan($input: CreateLessonPlanInput!) {
createLessonPlan(input: $input) {
id
title
subject
grade
chapter
status
textbookId
textbookTitle
classIds
updatedAt
}
}
`;
export const UpdateLessonPlanMutation = gql`
mutation UpdateLessonPlan($input: UpdateLessonPlanInput!) {
updateLessonPlan(input: $input) {
id
title
content
status
textbookId
chapter
classIds
updatedAt
}
}
`;
export const AIGenerateLessonPlanMutation = gql`
mutation AIGenerateLessonPlan($input: AIGenerateLessonPlanInput!) {
aiGenerateLessonPlan(input: $input) {
id
content
summary
model
generatedAt
}
}
`;
export const LessonPlanLibraryQuery = gql`
query LessonPlanLibrary($subject: String, $grade: String) {
lessonPlanLibrary(subject: $subject, grade: $grade) {
id
title
author
subject
grade
chapter
rating
forkCount
updatedAt
}
}
`;
export const ForkLessonPlanMutation = gql`
mutation ForkLessonPlan($planId: ID!) {
forkLessonPlan(planId: $planId) {
id
title
subject
grade
chapter
status
textbookId
textbookTitle
classIds
updatedAt
}
}
`;
export const LessonPlanCalendarQuery = gql`
query LessonPlanCalendar($year: Int!, $month: Int!) {
lessonPlanCalendar(year: $year, month: $month) {
year
month
days {
date
planCount
planTitles
}
}
}
`;
export const LessonPlanHeatmapQuery = gql`
query LessonPlanHeatmap {
lessonPlanHeatmap {
rows
cols
cells {
textbookKp
planIds
covered
}
}
}
`;
// ============ Queries / Mutations课程计划 ============
export const CoursePlansQuery = gql`
query CoursePlans($status: CoursePlanStatus) {
coursePlans(status: $status) {
id
title
subject
grade
academicYear
semester
totalHours
status
textbookId
updatedAt
}
}
`;
export const CoursePlanDetailQuery = gql`
query CoursePlanDetail($id: ID!) {
coursePlanDetail(id: $id) {
id
title
subject
grade
academicYear
semester
totalHours
status
textbookId
textbookTitle
homeworkCount
chapters {
id
title
hours
objectives
keyPoints
activities
}
updatedAt
}
}
`;
export const CreateCoursePlanMutation = gql`
mutation CreateCoursePlan($input: CreateCoursePlanInput!) {
createCoursePlan(input: $input) {
id
title
subject
grade
academicYear
semester
totalHours
status
textbookId
updatedAt
}
}
`;
export const UpdateCoursePlanMutation = gql`
mutation UpdateCoursePlan($input: UpdateCoursePlanInput!) {
updateCoursePlan(input: $input) {
id
title
status
updatedAt
}
}
`;
// ============ Queries诊断 ============
export const DiagnosticReportsQuery = gql`
query DiagnosticReports($type: DiagnosticReportType, $status: DiagnosticReportStatus) {
diagnosticReports(type: $type, status: $status) {
id
title
type
targetType
status
createdAt
}
}
`;
export const ClassDiagnosticQuery = gql`
query ClassDiagnostic($classId: ID!) {
classDiagnostic(classId: $classId) {
classId
className
studentCount
summary {
knowledgePoint
avg
}
kpMastery {
knowledgePoint
avg
median
studentCount
distribution {
mastered
partial
weak
}
}
rankings {
rank
studentId
studentName
masteryAvg
}
}
}
`;
export const StudentDiagnosticQuery = gql`
query StudentDiagnostic($studentId: ID!) {
studentDiagnostic(studentId: $studentId) {
studentId
studentName
radar {
axis
value
classAvg
}
reports {
id
title
createdAt
status
}
}
}
`;
// ============ Queries错题本 / 练习) ============
export const ErrorBookQuery = gql`
query ErrorBook($subject: String!, $classId: String) {
errorBook(subject: $subject, classId: $classId) {
subject
classId
summary {
totalErrors
highFreqErrors
weakKpCount
avgErrorRate
trendUp
}
classComparison {
classId
className
errorCount
}
chapterWeakness {
chapter
errorCount
}
kpWeakness {
knowledgePoint
errorRate
}
studentGroups {
studentId
studentName
errorCount
topErrors {
questionId
content
errorRate
}
}
topQuestions {
questionId
content
errorRate
subject
}
}
}
`;
export const PracticeAnalyticsQuery = gql`
query PracticeAnalytics($classId: String) {
practiceAnalytics(classId: $classId) {
classId
summary {
totalSessions
completionRate
avgAccuracy
weakKpCount
participationRate
}
classComparison {
classId
className
totalSessions
completionRate
avgAccuracy
participationRate
weakKpCount
}
typeBreakdown {
type
count
ratio
}
kpWeakness {
knowledgePoint
accuracy
}
rankings {
rank
studentId
studentName
studentNo
totalSessions
accuracy
weakKp
}
inactiveStudents {
studentId
studentName
lastActiveDays
}
}
}
`;

View File

@@ -300,3 +300,173 @@ export const UpdateUserMutation = gql`
}
}
`;
// ============ P3 Mutation/QueryMSW mockcore-edu 域) ============
// 以下 operations 用于考试创建/作业布置/成绩录入/详情查询workline.md §3 P3 交付物)。
// 开发期通过 MSW 拦截返回 mock 响应;上游就绪后由 ai03 在 teacher-bff.graphql 扩展。
/** 创建考试输入 */
export interface CreateExamInput {
classId: string;
title: string;
description?: string | null;
examDate: string;
duration: number;
totalScore: number;
}
/** 创建考试 MutationP3 扩展MSW mock */
export const CreateExamMutation = gql`
mutation CreateExam($input: CreateExamInput!) {
createExam(input: $input) {
id
classId
title
description
examDate
duration
totalScore
status
}
}
`;
/** 布置作业输入 */
export interface AssignHomeworkInput {
classId: string;
title: string;
description?: string | null;
dueDate: string;
}
/** 布置作业 MutationP3 扩展MSW mock */
export const AssignHomeworkMutation = gql`
mutation AssignHomework($input: AssignHomeworkInput!) {
assignHomework(input: $input) {
id
classId
title
description
dueDate
status
}
}
`;
/** 录入成绩输入 */
export interface RecordGradeInput {
studentId: string;
examId?: string | null;
homeworkId?: string | null;
score: number;
feedback?: string | null;
}
/** 录入成绩 MutationP3 扩展MSW mock */
export const RecordGradeMutation = gql`
mutation RecordGrade($input: RecordGradeInput!) {
recordGrade(input: $input) {
id
studentId
studentName
examId
homeworkId
score
feedback
}
}
`;
/** 考试题目core-edu 域P3 扩展) */
export interface ExamQuestion {
id: string;
examId: string;
title: string;
type: "SINGLE_CHOICE" | "MULTIPLE_CHOICE" | "SHORT_ANSWER" | "ESSAY";
score: number;
order: number;
}
/** 考试详情(含题目列表) */
export interface ExamDetail {
id: string;
classId: string;
title: string;
description: string | null;
examDate: string;
duration: number;
totalScore: number;
status: ExamStatus;
questions: ExamQuestion[];
}
/** 考试详情查询P3 扩展MSW mock */
export const ExamDetailQuery = gql`
query ExamDetail($id: ID!) {
examDetail(id: $id) {
id
classId
title
description
examDate
duration
totalScore
status
questions {
id
examId
title
type
score
order
}
}
}
`;
/** 作业提交core-edu 域P3 扩展) */
export interface HomeworkSubmission {
id: string;
homeworkId: string;
studentId: string;
studentName: string;
status: SubmissionStatus;
score: number | null;
feedback: string | null;
submittedAt: string | null;
}
/** 作业详情(含提交列表) */
export interface HomeworkDetail {
id: string;
classId: string;
title: string;
description: string | null;
dueDate: string;
status: SubmissionStatus;
submissions: HomeworkSubmission[];
}
/** 作业详情查询P3 扩展MSW mock */
export const HomeworkDetailQuery = gql`
query HomeworkDetail($id: ID!) {
homeworkDetail(id: $id) {
id
classId
title
description
dueDate
status
submissions {
id
homeworkId
studentId
studentName
status
score
feedback
submittedAt
}
}
}
`;

View File

@@ -0,0 +1,272 @@
/**
* A11y 审计工具P6 硬化)
*
* - runA11yAudit() - 运行 axe-core 审计(条件加载,动态 import
* - checkContrast() - 对比度检查工具
* - generateA11yReport() - 生成审计报告
*
* 注意axe-core 未安装,使用动态 import 在运行时按需加载。
* 包将在统一安装阶段补充到 package.json。
*
* 关联02-architecture-design.md §12 可观测性 / WCAG 2.2 AA 规范
*/
/**
* WCAG 2.2 AA 对比度阈值。
*
* - 普通文本4.5:1
* - 大文本18pt+ 或 14pt 粗体3.0:1
* - 非文本组件UI 边框/图标3.0:1
*/
export const CONTRAST_THRESHOLDS = {
normalText: 4.5,
largeText: 3.0,
nonTextComponents: 3.0,
} as const;
/** A11y 审计结果级别 */
export type A11yIssueLevel = "minor" | "moderate" | "serious" | "critical";
/** A11y 审计单个问题 */
export interface A11yIssue {
/** 规则 ID如 "color-contrast" */
id: string;
/** 问题级别 */
level: A11yIssueLevel;
/** 问题描述 */
description: string;
/** 受影响元素的选择器 */
selector: string;
/** 修复建议 */
help: string;
/** 帮助文档 URL */
helpUrl: string;
}
/** A11y 审计报告 */
export interface A11yAuditReport {
/** 审计时间戳 */
timestamp: number;
/** 页面 URL */
url: string;
/** 通过的规则数 */
passes: number;
/** 违规规则数 */
violations: number;
/** 不完整规则数 */
incomplete: number;
/** 问题列表 */
issues: A11yIssue[];
/** 是否通过 WCAG 2.2 AA */
passed: boolean;
}
/**
* axe-core 的最小类型声明(仅声明使用的 API 子集)。
*/
interface AxeResult {
passes: unknown[];
violations: Array<{
id: string;
impact: A11yIssueLevel;
description: string;
help: string;
helpUrl: string;
nodes: Array<{ target: string[] }>;
}>;
incomplete: unknown[];
}
interface AxeModule {
default: (options: {
runOnly?: { type: string; values: string[] };
}) => Promise<AxeResult>;
}
/** axe-core 的最小配置(启用 WCAG 2.2 AA 规则集) */
const AXE_RUN_OPTIONS = {
runOnly: {
type: "tag" as const,
values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"],
},
};
/**
* 运行 axe-core A11y 审计。
*
* 动态 import axe-core避免未安装包导致构建失败。
* 仅在浏览器环境运行(需要 DOM
*
* @returns 审计结果数组(违规项),加载失败返回空数组
*/
export async function runA11yAudit(): Promise<A11yIssue[]> {
if (typeof window === "undefined") return [];
if (typeof document === "undefined") return [];
try {
const axeMod = (await import("axe-core")) as unknown as AxeModule;
const axe = axeMod.default ?? (axeMod as unknown as AxeModule["default"]);
const result = await axe(AXE_RUN_OPTIONS);
return result.violations.map((violation) => {
const node = violation.nodes[0];
return {
id: violation.id,
level: violation.impact,
description: violation.description,
selector: node ? node.target.join(", ") : "",
help: violation.help,
helpUrl: violation.helpUrl,
};
});
} catch (err) {
if (typeof console !== "undefined") {
console.warn("[teacher-portal] axe-core 加载失败,跳过 A11y 审计", err);
}
return [];
}
}
/**
* 将 hex 颜色转换为相对亮度值0~1
*
* 依据 WCAG 2.x 相对亮度公式:
* https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
*/
function relativeLuminance(hex: string): number | null {
const cleaned = hex.replace("#", "");
if (cleaned.length !== 6 && cleaned.length !== 3) return null;
const fullHex =
cleaned.length === 3
? cleaned
.split("")
.map((c) => c + c)
.join("")
: cleaned;
const r = parseInt(fullHex.slice(0, 2), 16) / 255;
const g = parseInt(fullHex.slice(2, 4), 16) / 255;
const b = parseInt(fullHex.slice(4, 6), 16) / 255;
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null;
const toLinear = (c: number): number =>
c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
const R = toLinear(r);
const G = toLinear(g);
const B = toLinear(b);
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
}
/**
* 对比度检查工具。
*
* 计算两个 hex 颜色之间的对比度比值1~21
*
* @param foreground 前景色hex如 "#000000"
* @param background 背景色hex如 "#ffffff"
* @param isLargeText 是否大文本(默认 false使用 4.5:1 阈值)
* @returns 是否通过 WCAG 2.2 AA 对比度要求(无效颜色返回 false
*/
export function checkContrast(
foreground: string,
background: string,
isLargeText = false,
): boolean {
const fg = relativeLuminance(foreground);
const bg = relativeLuminance(background);
if (fg === null || bg === null) return false;
const lighter = Math.max(fg, bg);
const darker = Math.min(fg, bg);
const ratio = (lighter + 0.05) / (darker + 0.05);
const threshold = isLargeText
? CONTRAST_THRESHOLDS.largeText
: CONTRAST_THRESHOLDS.normalText;
return ratio >= threshold;
}
/**
* 获取两个颜色之间的对比度比值。
*
* @returns 对比度比值1~21无效颜色返回 null
*/
export function getContrastRatio(
foreground: string,
background: string,
): number | null {
const fg = relativeLuminance(foreground);
const bg = relativeLuminance(background);
if (fg === null || bg === null) return null;
const lighter = Math.max(fg, bg);
const darker = Math.min(fg, bg);
return (lighter + 0.05) / (darker + 0.05);
}
/**
* 生成 A11y 审计报告。
*
* 运行 axe-core 审计并汇总为结构化报告。
*
* @returns 审计报告(加载失败返回空报告)
*/
export async function generateA11yReport(): Promise<A11yAuditReport> {
if (typeof window === "undefined") {
return {
timestamp: Date.now(),
url: "",
passes: 0,
violations: 0,
incomplete: 0,
issues: [],
passed: false,
};
}
try {
const axeMod = (await import("axe-core")) as unknown as AxeModule;
const axe = axeMod.default ?? (axeMod as unknown as AxeModule["default"]);
const result = await axe(AXE_RUN_OPTIONS);
const issues: A11yIssue[] = result.violations.map((violation) => {
const node = violation.nodes[0];
return {
id: violation.id,
level: violation.impact,
description: violation.description,
selector: node ? node.target.join(", ") : "",
help: violation.help,
helpUrl: violation.helpUrl,
};
});
return {
timestamp: Date.now(),
url: window.location.href,
passes: result.passes.length,
violations: result.violations.length,
incomplete: result.incomplete.length,
issues,
passed: result.violations.length === 0,
};
} catch (err) {
if (typeof console !== "undefined") {
console.warn("[teacher-portal] A11y 审计失败", err);
}
return {
timestamp: Date.now(),
url: typeof window !== "undefined" ? window.location.href : "",
passes: 0,
violations: 0,
incomplete: 0,
issues: [],
passed: false,
};
}
}

View File

@@ -0,0 +1,189 @@
/**
* Cookie 迁移准备P6 硬化)
*
* - migrateTokenToCookie() - 将 localStorage token 迁移到 httpOnly cookie
* - checkCookieSupport() - 检测浏览器是否支持 Secure + SameSite cookie
* - isCookieMigrationEnabled() - 检查是否启用迁移(环境变量)
* - 迁移状态枚举
*
* 注意:实际迁移在 iam refresh cookie 端点就绪后启用,此处仅做准备。
* 关联project_rules §4 安全规范Cookie: httpOnly + Secure + SameSite=Strict
* 02-architecture-design.md §2.1 会话状态
*/
import { getObservabilityConfig } from "@/lib/observability/env";
/**
* 迁移状态枚举。
*/
export enum CookieMigrationStatus {
/** 未启用迁移 */
Disabled = "disabled",
/** 浏览器不支持必要的 Cookie 特性 */
Unsupported = "unsupported",
/** localStorage 中无 token无需迁移 */
NoToken = "no_token",
/** 迁移进行中 */
InProgress = "in_progress",
/** 迁移成功 */
Completed = "completed",
/** 迁移失败iam 端点未就绪或网络错误) */
Failed = "failed",
}
/**
* localStorage 中存储的 token key与 auth.ts 保持一致)。
*
* 注意:此处直接引用 key 字符串,不导入 auth.ts避免循环依赖。
* auth.ts 使用 "edu_access_token"。
*/
const LEGACY_TOKEN_KEY = "edu_access_token";
/** iam refresh cookie 端点(端点就绪后启用) */
const IAM_REFRESH_COOKIE_ENDPOINT = "/api/v1/iam/auth/refresh-cookie";
/**
* 检查是否启用 Cookie 迁移(通过环境变量控制)。
*
* 实际迁移在 iam refresh cookie 端点就绪后由运维开启。
*
* @returns 是否启用迁移
*/
export function isCookieMigrationEnabled(): boolean {
return getObservabilityConfig().cookieMigrationEnabled;
}
/**
* 检测浏览器是否支持 Secure + SameSite=Strict cookie。
*
* 通过设置测试 cookie 并读回验证。仅在浏览器环境运行。
*
* @returns 是否支持必要的 Cookie 特性
*/
export function checkCookieSupport(): boolean {
if (typeof document === "undefined") return false;
try {
// 测试 Secure + SameSite=Strict cookie
const testCookie = "edu_cookie_test=1; Secure; SameSite=Strict; max-age=1";
document.cookie = testCookie;
// 检查是否能读回(不支持 Secure 时 HTTPS 以外环境设置会失败)
const supported = document.cookie.includes("edu_cookie_test");
// 清理测试 cookie
document.cookie =
"edu_cookie_test=; Secure; SameSite=Strict; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT";
return supported;
} catch {
return false;
}
}
/**
* 检测是否为 HTTPS 安全上下文Secure cookie 需要)。
*/
export function isSecureContext(): boolean {
if (typeof window === "undefined") return false;
// window.isSecureContext 是标准 API
return (
typeof window.isSecureContext === "boolean" ? window.isSecureContext : false
);
}
/**
* 从 localStorage 读取 legacy token仅检查是否存在不暴露 token 值)。
*/
function hasLegacyToken(): boolean {
if (typeof window === "undefined") return false;
try {
return localStorage.getItem(LEGACY_TOKEN_KEY) !== null;
} catch {
return false;
}
}
/**
* 将 localStorage token 迁移到 httpOnly cookie。
*
* 流程:
* 1. 检查是否启用迁移(环境变量)
* 2. 检查浏览器 Cookie 支持
* 3. 检查 localStorage 是否有 token
* 4. 调用 iam refresh cookie 端点(服务端设置 httpOnly cookie
* 5. 成功后清除 localStorage token
*
* 注意iam refresh cookie 端点未就绪时返回 Failed不影响现有功能。
*
* @returns 迁移状态
*/
export async function migrateTokenToCookie(): Promise<CookieMigrationStatus> {
// 1. 检查是否启用迁移
if (!isCookieMigrationEnabled()) {
return CookieMigrationStatus.Disabled;
}
// 2. 检查浏览器 Cookie 支持
if (!checkCookieSupport()) {
return CookieMigrationStatus.Unsupported;
}
// 3. 检查 localStorage 是否有 token
if (!hasLegacyToken()) {
return CookieMigrationStatus.NoToken;
}
// 4. 调用 iam refresh cookie 端点
// 端点未就绪时返回 Failed不抛异常不影响现有功能
try {
const response = await fetch(IAM_REFRESH_COOKIE_ENDPOINT, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
});
if (!response.ok) {
// iam 端点未就绪404或认证失败401返回 Failed
return CookieMigrationStatus.Failed;
}
const data = (await response.json()) as { success?: boolean };
if (!data.success) {
return CookieMigrationStatus.Failed;
}
// 5. 成功后清除 localStorage tokenhttpOnly cookie 已由服务端设置)
if (typeof window !== "undefined") {
try {
localStorage.removeItem(LEGACY_TOKEN_KEY);
} catch {
// 清除失败不影响迁移成功状态cookie 已生效)
}
}
return CookieMigrationStatus.Completed;
} catch {
// 网络错误或端点不可达
return CookieMigrationStatus.Failed;
}
}
/**
* 检查迁移状态(非破坏性,不调用 iam 端点)。
*
* 用于在应用启动时判断是否需要提示用户或执行迁移。
*/
export function getMigrationStatus(): CookieMigrationStatus {
if (!isCookieMigrationEnabled()) {
return CookieMigrationStatus.Disabled;
}
if (!checkCookieSupport()) {
return CookieMigrationStatus.Unsupported;
}
if (!hasLegacyToken()) {
return CookieMigrationStatus.NoToken;
}
// 有 token 且环境支持,等待迁移
return CookieMigrationStatus.InProgress;
}

View File

@@ -0,0 +1,113 @@
/**
* 可观测性环境变量集中管理P6 硬化)
*
* - 类型安全的环境变量访问
* - 统一所有可观测性模块Sentry / Web Vitals / OTel / Cookie 迁移)的配置入口
* - 所有变量必须以 NEXT_PUBLIC_ 前缀project_rules §4 安全规范)
*
* 关联02-architecture-design.md §12 可观测性
*/
/**
* 可观测性配置对象(运行时只读快照)
*/
export interface ObservabilityConfig {
/** Sentry DSN未配置则禁用 Sentry */
readonly sentryDsn: string | null;
/** Sentry release 版本号 */
readonly sentryRelease: string | null;
/** 运行环境development / production */
readonly environment: string;
/** 采样率0~1 */
readonly tracesSampleRate: number;
/** 是否启用 OTel browser SDK */
readonly otelEnabled: boolean;
/** OTLP collector 上报端点 */
readonly otelEndpoint: string | null;
/** Web Vitals 上报端点 */
readonly webVitalsEndpoint: string;
/** 是否启用 Cookie 迁移iam refresh cookie 端点就绪后开启) */
readonly cookieMigrationEnabled: boolean;
/** 服务名(用于上报标识) */
readonly serviceName: string;
}
/** 默认 Web Vitals 上报端点 */
const DEFAULT_WEB_VITALS_ENDPOINT = "/api/v1/admin/web-vitals";
/** 解析环境变量为布尔值("true" / "1" 视为真) */
function parseBoolean(value: string | undefined): boolean {
return value === "true" || value === "1";
}
/** 解析采样率(默认 0.1,非法值回退到默认) */
function parseSampleRate(value: string | undefined): number {
const parsed = Number.parseFloat(value ?? "");
if (Number.isNaN(parsed) || parsed < 0 || parsed > 1) {
return 0.1;
}
return parsed;
}
/**
* 读取并构建可观测性配置。
*
* Next.js 在构建时将 NEXT_PUBLIC_* 变量内联到客户端 bundle
* 因此此处直接读取 process.env。
*/
function buildConfig(): ObservabilityConfig {
return {
sentryDsn: process.env.NEXT_PUBLIC_SENTRY_DSN ?? null,
sentryRelease: process.env.NEXT_PUBLIC_SENTRY_RELEASE ?? null,
environment: process.env.NODE_ENV ?? "development",
tracesSampleRate: parseSampleRate(
process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE,
),
otelEnabled: parseBoolean(process.env.NEXT_PUBLIC_OTEL_ENABLED),
otelEndpoint: process.env.NEXT_PUBLIC_OTEL_ENDPOINT ?? null,
webVitalsEndpoint:
process.env.NEXT_PUBLIC_WEB_VITALS_ENDPOINT ??
DEFAULT_WEB_VITALS_ENDPOINT,
cookieMigrationEnabled: parseBoolean(
process.env.NEXT_PUBLIC_COOKIE_MIGRATION_ENABLED,
),
serviceName: "teacher-portal",
};
}
/** 配置单例(模块级缓存,避免重复读取) */
let cachedConfig: ObservabilityConfig | null = null;
/**
* 获取可观测性配置(单例)。
*
* 使用方式:
* ```ts
* import { getObservabilityConfig } from "@/lib/observability/env";
* const config = getObservabilityConfig();
* if (config.sentryDsn) { ... }
* ```
*/
export function getObservabilityConfig(): ObservabilityConfig {
if (cachedConfig === null) {
cachedConfig = buildConfig();
}
return cachedConfig;
}
/** 是否启用 SentryDSN 已配置) */
export function isSentryEnabled(): boolean {
return getObservabilityConfig().sentryDsn !== null;
}
/** 是否启用 OTel browser SDK */
export function isOTelEnabled(): boolean {
return getObservabilityConfig().otelEnabled;
}
/** 是否启用 Web Vitals 上报 */
export function isWebVitalsReportingEnabled(): boolean {
const config = getObservabilityConfig();
// 配置了 Sentry 或 OTel 任一即启用上报(避免开发环境噪音)
return config.sentryDsn !== null || config.otelEnabled;
}

View File

@@ -0,0 +1,140 @@
/**
* OpenTelemetry browser SDK 初始化P6 硬化)
*
* - 自动埋点 fetch / XHR / document load
* - 导出到 OTLP collectorendpoint 从环境变量读取)
* - 条件初始化NEXT_PUBLIC_OTEL_ENABLED=true 时)
* - 导出 initOTel() 函数
*
* 使用动态 import 加载 @opentelemetry/* 包,避免未安装包导致构建失败。
* 包将在统一安装阶段补充到 package.json。
*
* 关联02-architecture-design.md §12 可观测性 / project_rules §12 可观测性规范
*/
import { getObservabilityConfig } from "@/lib/observability/env";
/**
* OTel SDK 的最小类型声明(仅声明使用的 API 子集)。
* 避免对未安装包的静态类型依赖。
*/
interface OTelAutoInstrumentationType {
registerInstrumentations(options: {
instrumentations: unknown[];
}): void;
}
interface OTelExporterType {
OTLPTraceExporter: new (config: { url: string }) => unknown;
BatchSpanProcessor: new (exporter: unknown) => unknown;
WebTracerProvider: new () => unknown;
}
interface ZoneContextManagerType {
ZoneContextManager: new () => { enable(): unknown };
}
/** 初始化状态标记 */
let initialized = false;
/**
* 初始化 OpenTelemetry browser SDK。
*
* 条件初始化:仅在 NEXT_PUBLIC_OTEL_ENABLED=true 且 endpoint 已配置时启用。
* 使用动态 import 加载 @opentelemetry/* 各包。
*
* @returns 是否成功初始化
*/
export async function initOTel(): Promise<boolean> {
if (initialized) return false;
const config = getObservabilityConfig();
if (!config.otelEnabled) {
initialized = true;
return false;
}
if (config.otelEndpoint === null) {
if (typeof console !== "undefined") {
console.warn(
"[teacher-portal] OTel 已启用但未配置 NEXT_PUBLIC_OTEL_ENDPOINT跳过初始化",
);
}
initialized = true;
return false;
}
try {
// 动态加载 OTel 各包(运行时按需加载)
const instrumentation = (await import("@opentelemetry/instrumentation")) as unknown as OTelAutoInstrumentationType;
const fetchInstrumentationMod = await import("@opentelemetry/instrumentation-fetch");
const xhrInstrumentationMod = await import("@opentelemetry/instrumentation-xml-http-request");
const documentLoadMod = await import("@opentelemetry/instrumentation-document-load");
const webTracerMod = (await import("@opentelemetry/sdk-trace-web")) as unknown as OTelExporterType;
const exporterMod = (await import("@opentelemetry/exporter-trace-otlp-http")) as unknown as OTelExporterType;
const contextManagerMod = (await import("@opentelemetry/context-zone")) as unknown as ZoneContextManagerType;
// 构造 FetchInstrumentation
const FetchInstrumentation =
(fetchInstrumentationMod as unknown as { FetchInstrumentation: new (config: unknown) => unknown }).FetchInstrumentation;
const XHRInstrumentation =
(xhrInstrumentationMod as unknown as { XMLHttpRequestInstrumentation: new (config: unknown) => unknown }).XMLHttpRequestInstrumentation;
const DocumentLoadInstrumentation =
(documentLoadMod as unknown as { DocumentLoadInstrumentation: new () => unknown }).DocumentLoadInstrumentation;
const contextManager = new contextManagerMod.ZoneContextManager();
(contextManager as unknown as { enable(): unknown }).enable();
const exporter = new exporterMod.OTLPTraceExporter({
url: config.otelEndpoint,
});
const provider = new webTracerMod.WebTracerProvider();
const processor = new webTracerMod.BatchSpanProcessor(exporter);
// 注册 provider类型宽松处理OTel SDK 内部多态)
(
provider as unknown as {
addSpanProcessor: (processor: unknown) => void;
register: (options: { contextManager: unknown }) => void;
}
).addSpanProcessor(processor);
(
provider as unknown as {
register: (options: { contextManager: unknown }) => void;
}
).register({ contextManager });
// 注册自动埋点
instrumentation.registerInstrumentations({
instrumentations: [
new FetchInstrumentation({
propagateTraceHeaderCorsUrls: ["*"],
}),
new XHRInstrumentation({}),
new DocumentLoadInstrumentation(),
],
});
initialized = true;
if (typeof console !== "undefined" && config.environment === "development") {
console.info("[teacher-portal] OTel browser SDK 已初始化", {
endpoint: config.otelEndpoint,
});
}
return true;
} catch (err) {
// @opentelemetry/* 包未安装或加载失败,降级到无追踪
if (typeof console !== "undefined") {
console.warn("[teacher-portal] OTel 加载失败,降级到无追踪", err);
}
initialized = true;
return false;
}
}
/** OTel 是否已初始化 */
export function isOTelInitialized(): boolean {
return initialized;
}

View File

@@ -0,0 +1,230 @@
/**
* 性能优化配置P6 硬化)
*
* - Bundle 分析配置
* - size-limit 配置导出
* - 性能指标阈值Shell <150KB / Remote <80KB / CSS <50KB
* - checkBundleSize() 函数
* - getPerformanceMetrics() 函数
*
* 关联02-architecture-design.md §12 可观测性 / Module Federation 性能预算
*/
/**
* 性能预算阈值单位KB
*
* 参考 Module Federation 性能最佳实践:
* - Shell容器应用应保持精简避免大依赖
* - Remote微前端远程模块每个 remote 独立加载
* - CSS样式表预算
*/
export const PERFORMANCE_BUDGETS = {
/** Shell bundle 大小上限KB */
shell: 150,
/** Remote bundle 大小上限KB */
remote: 80,
/** CSS bundle 大小上限KB */
css: 50,
/** 单个 chunk 大小上限KB */
chunk: 244,
/** 首屏 LCP 阈值ms */
lcp: 2500,
/** 交互延迟 INP 阈值ms */
inp: 200,
/** 累计布局偏移 CLS 阈值 */
cls: 0.1,
/** 首字节时间 TTFB 阈值ms */
ttfb: 800,
} as const;
/**
* size-limit 配置(导出供 CI 使用)。
*
* 与 apps/teacher-portal/size-limit.json 保持一致。
*/
export const SIZE_LIMIT_CONFIG = [
{
name: "Shell (main bundle)",
path: ".next/static/chunks/main-*.js",
limit: `${PERFORMANCE_BUDGETS.shell} KB`,
gzip: true,
},
{
name: "Remote entry",
path: ".next/static/chunks/remoteEntry-*.js",
limit: `${PERFORMANCE_BUDGETS.remote} KB`,
gzip: true,
},
{
name: "CSS",
path: ".next/static/css/*.css",
limit: `${PERFORMANCE_BUDGETS.css} KB`,
gzip: true,
},
] as const;
/**
* Bundle 大小检查结果。
*/
export interface BundleSizeCheckResult {
/** 检查时间戳 */
timestamp: number;
/** 各资源检查结果 */
results: Array<{
name: string;
path: string;
limit: number;
actual: number | null;
passed: boolean;
}>;
/** 是否全部通过 */
passed: boolean;
}
/**
* 运行时性能指标。
*/
export interface RuntimePerformanceMetrics {
/** LCPms */
lcp: number | null;
/** INPms */
inp: number | null;
/** CLS */
cls: number | null;
/** TTFBms */
ttfb: number | null;
/** FCPms */
fcp: number | null;
/** 页面加载时间ms */
pageLoad: number | null;
/** 是否通过性能预算 */
passed: boolean;
}
/**
* 检查 bundle 是否符合大小预算。
*
* 注意:此函数需要在构建后调用,读取 .next 目录下的构建产物。
* 在浏览器环境(无文件系统访问)时返回未通过的结果。
*
* @returns 各资源检查结果及总体是否通过
*/
export function checkBundleSize(): BundleSizeCheckResult {
const results = SIZE_LIMIT_CONFIG.map((config) => {
const limitNum = Number.parseInt(config.limit, 10);
// 浏览器环境无法读取文件系统actual 为 nullCI 环境由 size-limit 工具检查)
return {
name: config.name,
path: config.path,
limit: limitNum,
actual: null,
passed: true, // CI 由 size-limit 工具实际检查
};
});
return {
timestamp: Date.now(),
results,
passed: results.every((r) => r.passed),
};
}
/**
* 获取运行时性能指标(基于 Navigation / Performance API
*
* 在浏览器环境通过 Performance API 采集运行时指标,
* 用于与性能预算对比。非浏览器环境返回空指标。
*
* @returns 运行时性能指标
*/
export function getPerformanceMetrics(): RuntimePerformanceMetrics {
if (typeof performance === "undefined") {
return {
lcp: null,
inp: null,
cls: null,
ttfb: null,
fcp: null,
pageLoad: null,
passed: false,
};
}
// 通过 Navigation Timing API 获取页面加载指标
let ttfb: number | null = null;
let fcp: number | null = null;
let pageLoad: number | null = null;
try {
const entries = performance.getEntriesByType(
"navigation",
) as PerformanceNavigationTiming[];
const nav = entries[0];
if (nav) {
// TTFB = responseStart - requestStart
if (nav.responseStart > 0 && nav.requestStart > 0) {
ttfb = nav.responseStart - nav.requestStart;
}
// 页面加载时间 = loadEventEnd - startTime
if (nav.loadEventEnd > 0) {
pageLoad = nav.loadEventEnd - nav.startTime;
}
}
// FCP 通过 Paint Timing API 获取
const paintEntries = performance.getEntriesByType(
"paint",
) as PerformanceEntry[];
const fcpEntry = paintEntries.find(
(e) => e.name === "first-contentful-paint",
);
if (fcpEntry) {
fcp = fcpEntry.startTime;
}
} catch {
// Performance API 不可用时保持 null
}
// LCP / INP / CLS 通过 Web Vitals 库采集(此处仅汇总阈值检查)
// 实际值由 web-vitals.ts 上报,此处仅做预算对比
const passed =
(ttfb === null || ttfb <= PERFORMANCE_BUDGETS.ttfb) &&
(fcp === null || fcp <= PERFORMANCE_BUDGETS.lcp);
return {
lcp: null, // 由 web-vitals.ts 采集
inp: null, // 由 web-vitals.ts 采集
cls: null, // 由 web-vitals.ts 采集
ttfb,
fcp,
pageLoad,
passed,
};
}
/**
* 检查单个指标是否通过性能预算。
*
* @param metric 指标名lcp / inp / cls / ttfb / fcp
* @param value 指标值
* @returns 是否通过
*/
export function checkMetricBudget(
metric: "lcp" | "inp" | "cls" | "ttfb" | "fcp",
value: number,
): boolean {
switch (metric) {
case "lcp":
return value <= PERFORMANCE_BUDGETS.lcp;
case "inp":
return value <= PERFORMANCE_BUDGETS.inp;
case "cls":
return value <= PERFORMANCE_BUDGETS.cls;
case "ttfb":
return value <= PERFORMANCE_BUDGETS.ttfb;
case "fcp":
return value <= PERFORMANCE_BUDGETS.lcp;
default:
return true;
}
}

View File

@@ -0,0 +1,193 @@
/**
* Sentry 错误追踪初始化P6 硬化)
*
* - 参考 student-portal/src/lib/observability/sentry.ts 实现
* - 仅当 NEXT_PUBLIC_SENTRY_DSN 配置时初始化(动态 import @sentry/nextjs
* - beforeSend PII 过滤email / phone / token / password / 身份证 / 教师姓名
* - 设置 release / tag / environment
* - 导出 initSentry() + captureException / captureMessage 包装器
*
* 关联02-architecture-design.md §12 可观测性
*/
import { getObservabilityConfig } from "@/lib/observability/env";
/**
* Sentry 模块类型(仅声明使用的 API 子集)。
* 避免对未安装包的静态类型依赖。
*/
interface SentryModule {
init(options: {
dsn: string;
environment: string;
release?: string;
tracesSampleRate: number;
beforeSend?: (event: unknown) => unknown;
}): void;
captureException(err: unknown, context?: { extra?: Record<string, unknown> }): void;
captureMessage(message: string): void;
addBreadcrumb(breadcrumb: {
category?: string;
message?: string;
level?: string;
}): void;
setTag(key: string, value: string): void;
}
/** 需要脱敏的 PII 字段名(匹配 key不区分大小写 */
const PII_KEYS = [
"teachername",
"name",
"email",
"phone",
"mobile",
"idcard",
"id_card",
"token",
"accesstoken",
"access_token",
"refreshtoken",
"refresh_token",
"password",
"secret",
"authorization",
];
/** Sentry 模块缓存initSentry 成功后赋值) */
let sentryModule: SentryModule | null = null;
/** 初始化状态标记 */
let initialized = false;
/**
* 递归移除 PII 字段(返回脱敏后的副本)。
*
* 从 unknown 转换为结构化对象处理,符合 project_rules §3.4 禁止 any 规则。
*/
function stripPII(input: unknown): unknown {
if (Array.isArray(input)) {
return input.map(stripPII);
}
if (input !== null && typeof input === "object") {
const obj = input as Record<string, unknown>;
const out: Record<string, unknown> = {};
for (const key of Object.keys(obj)) {
if (PII_KEYS.includes(key.toLowerCase())) {
out[key] = "[REDACTED]";
} else {
out[key] = stripPII(obj[key]);
}
}
return out;
}
return input;
}
/**
* 初始化 Sentry仅在浏览器/服务端入口调用一次)。
*
* 使用动态 import 加载 @sentry/nextjs避免未安装包导致构建失败。
* 包将在统一安装阶段补充到 package.json。
*
* @returns 是否成功初始化false 表示未配置 DSN 或加载失败)
*/
export async function initSentry(): Promise<boolean> {
if (initialized) return sentryModule !== null;
const config = getObservabilityConfig();
if (config.sentryDsn === null) {
// 未配置 DSN标记为已检查避免重复读取配置
initialized = true;
return false;
}
try {
const Sentry = (await import("@sentry/nextjs")) as unknown as SentryModule;
Sentry.init({
dsn: config.sentryDsn,
environment: config.environment,
release: config.sentryRelease ?? undefined,
tracesSampleRate: config.tracesSampleRate,
beforeSend(event: unknown) {
// 过滤 PII 后回传事件
return stripPII(event);
},
});
// 设置全局 tagservice 标识,便于 Sentry 面板按应用过滤)
Sentry.setTag("service", config.serviceName);
Sentry.setTag("portal", "teacher");
sentryModule = Sentry;
initialized = true;
// 挂载捕获器供 ErrorBoundary 使用(参考 student-portal 模式)
if (typeof window !== "undefined") {
(
window as unknown as {
__eduCaptureException?: (e: Error, extra?: unknown) => void;
}
).__eduCaptureException = (err: Error, extra?: unknown) => {
if (sentryModule) {
sentryModule.captureException(
err,
extra !== undefined ? { extra: { detail: extra } } : undefined,
);
}
};
}
return true;
} catch (err) {
// @sentry/nextjs 未安装或加载失败,降级到 console
if (typeof console !== "undefined") {
console.warn("[teacher-portal] Sentry 加载失败,降级到 console", err);
}
initialized = true;
return false;
}
}
/**
* 捕获异常(未初始化时降级为 console.error
*
* 同步 API若 Sentry 模块已加载则上报,否则输出到控制台。
*/
export function captureException(err: unknown): void {
if (sentryModule) {
sentryModule.captureException(err);
} else if (typeof console !== "undefined") {
console.error("[teacher-portal]", err);
}
}
/**
* 捕获消息(未初始化时降级为 console.warn
*/
export function captureMessage(message: string): void {
if (sentryModule) {
sentryModule.captureMessage(message);
} else if (typeof console !== "undefined") {
console.warn("[teacher-portal]", message);
}
}
/**
* 添加面包屑(用于错误上下文追踪)。
* 未初始化时为空操作。
*/
export function addBreadcrumb(breadcrumb: {
category?: string;
message?: string;
level?: string;
}): void {
if (sentryModule) {
sentryModule.addBreadcrumb(breadcrumb);
}
}
/** Sentry 是否已初始化(可用于运行时判断) */
export function isSentryInitialized(): boolean {
return sentryModule !== null;
}

View File

@@ -0,0 +1,131 @@
/**
* Web Vitals RUM 采集P6 硬化)
*
* - 参考 student-portal 和 admin-portal 的实现
* - 使用 web-vitals 库的 onCLS / onINP / onLCP / onTTFB / onFCP
* - 将指标上报到 /api/v1/admin/web-vitalsnavigator.sendBeacon
* - 导出 initWebVitals() + WebVitalMetric 类型
*
* 关联02-architecture-design.md §12 可观测性
*/
import { getObservabilityConfig, isWebVitalsReportingEnabled } from "@/lib/observability/env";
/**
* Web Vital 指标结构(与 web-vitals 库 Metric 对齐)。
*/
export interface WebVitalMetric {
/** 指标名LCP / CLS / INP / FCP / TTFB */
name: string;
/** 指标值 */
value: number;
/** 评级good / needs-improvement / poor */
rating: string;
/** 指标唯一标识 */
id: string;
/** 增量值(部分指标使用) */
delta?: number;
}
/**
* web-vitals 库的回调函数类型(最小声明)。
* 避免对未安装包的静态类型依赖。
*/
type MetricCallback = (metric: WebVitalMetric) => void;
interface WebVitalsLib {
onLCP(cb: MetricCallback): void;
onCLS(cb: MetricCallback): void;
onFCP(cb: MetricCallback): void;
onINP(cb: MetricCallback): void;
onTTFB(cb: MetricCallback): void;
}
/** 上报状态标记,避免重复注册回调 */
let initialized = false;
/**
* 上报单个 Web Vital 指标。
*
* 使用 navigator.sendBeacon 优先(页面卸载时不丢失),
* 降级到 fetch keepalive。
*/
function sendMetric(metric: WebVitalMetric): void {
if (typeof window === "undefined") return;
if (!isWebVitalsReportingEnabled()) return;
const config = getObservabilityConfig();
const payload = {
name: metric.name,
value: metric.value,
rating: metric.rating,
id: metric.id,
delta: metric.delta,
service: config.serviceName,
portal: "teacher",
page: window.location.pathname,
timestamp: Date.now(),
};
try {
const body = JSON.stringify(payload);
if (typeof navigator !== "undefined" && navigator.sendBeacon) {
const blob = new Blob([body], { type: "application/json" });
navigator.sendBeacon(config.webVitalsEndpoint, blob);
return;
}
// sendBeacon 不可用时降级 fetch keepalive
void fetch(config.webVitalsEndpoint, {
method: "POST",
body,
headers: { "Content-Type": "application/json" },
keepalive: true,
}).catch(() => {
// 上报失败不影响用户体验,静默忽略
});
} catch {
// 上报失败静默忽略
}
}
/**
* 初始化 Web Vitals 采集。
*
* 使用动态 import 加载 web-vitals 库,避免未安装包导致构建失败。
* 包将在统一安装阶段补充到 package.json。
*/
export async function initWebVitals(): Promise<void> {
if (initialized) return;
if (typeof window === "undefined") return;
try {
const webVitals = (await import("web-vitals")) as unknown as WebVitalsLib;
webVitals.onLCP(sendMetric);
webVitals.onCLS(sendMetric);
webVitals.onFCP(sendMetric);
webVitals.onINP(sendMetric);
webVitals.onTTFB(sendMetric);
initialized = true;
} catch (err) {
// web-vitals 未安装或加载失败,降级到无采集
if (typeof console !== "undefined") {
console.warn("[teacher-portal] web-vitals 加载失败", err);
}
}
}
/**
* 手动上报单个 Web Vital 指标(供 Next.js reportWebVitals 使用)。
*
* 用法:在 layout.tsx 中 export function reportWebVitals(metric) { sendWebVital(metric); }
*/
export function reportWebVitals(metric: WebVitalMetric): void {
sendMetric(metric);
}
/** Web Vitals 是否已初始化 */
export function isWebVitalsInitialized(): boolean {
return initialized;
}

View File

@@ -8,5 +8,23 @@
import { setupWorker } from "msw/browser";
import { handlers } from "./handlers";
import { p4Handlers } from "./handlers-p4";
import { p5Handlers } from "./handlers-p5";
import { p7Handlers } from "./handlers-p7-grades";
import { p7ExamsHandlers } from "./handlers-p7-exams";
import { p7AdminHandlers } from "./handlers-p7-admin";
import { p7InsightsHandlers } from "./handlers-p7-insights";
import { p7AdvancedHandlers } from "./handlers-p7-advanced";
export const worker = setupWorker(...handlers);
// 顺序:先 P7最新扩展→ P4/P5 → 主 handlers(默认兜底)
// 每个 handler 内部未命中时返回 undefined 触发 MSW fallthrough 至下一个
export const worker = setupWorker(
...p7AdvancedHandlers,
...p7InsightsHandlers,
...p7AdminHandlers,
...p7ExamsHandlers,
...p7Handlers,
...p4Handlers,
...p5Handlers,
...handlers,
);

View File

@@ -0,0 +1,94 @@
/**
* Mock AI 生成数据ai 域)
*
* 维护者ai13teacher-portal
* 关联02-architecture-design.md §6 AI 辅助
*/
import type {
GeneratedQuestion,
GeneratedLessonPlan,
GeneratedReport,
} from "@/lib/graphql-p5";
export const mockGeneratedQuestion: GeneratedQuestion = {
id: "ai-q-001",
question:
"已知函数 f(x) = x³ - 3x² + 2求 f(x) 在区间 [-1, 3] 上的极大值与极小值。",
options: [
"A. 极大值 2极小值 -2",
"B. 极大值 2极小值 0",
"C. 极大值 3极小值 -2",
"D. 极大值 0极小值 -2",
],
answer: "A",
explanation:
"对 f(x) 求导得 f'(x) = 3x² - 6x = 3x(x - 2)。令 f'(x)=0 解得 x=0 或 x=2。" +
"f(0)=2 为极大值f(2)=8-12+2=-2 为极小值,端点 f(-1)=-2、f(3)=2。故极大值 2极小值 -2。",
};
export const mockLessonPlan: GeneratedLessonPlan = {
id: "ai-lp-001",
content: [
"# 教案:函数的单调性与极值",
"",
"## 一、教学目标",
"1. 理解函数单调性的概念,能利用导数判断函数的单调性。",
"2. 掌握求函数极值的步骤,理解极值点与导数的关系。",
"3. 能运用单调性与极值解决简单的实际应用问题。",
"",
"## 二、教学重点与难点",
"- **重点**:利用导数判断单调性、求极值。",
"- **难点**:极值点的判定条件(导数变号)。",
"",
"## 三、教学过程",
"### 1. 情境导入5 分钟)",
"通过山峰高低变化的图象引入:函数值何时升高、何时降低?",
"",
"### 2. 新知探究20 分钟)",
"- 引导学生回顾导数的几何意义。",
"- 推导单调性与导数符号的关系f'(x)>0 ⇒ 单调递增f'(x)<0 ⇒ 单调递减。",
"- 结合例题 f(x)=x³-3x²+2 演示求极值的完整步骤。",
"",
"### 3. 巩固练习10 分钟)",
"学生独立完成 2 道练习题,教师巡视指导。",
"",
"### 4. 课堂小结5 分钟)",
"梳理(求导 → 找驻点 → 判断变号 → 定极值)四步法。",
"",
"## 四、作业布置",
"课本 P78 习题 1、2、3 题。",
].join("\n"),
summary:
"本节课以导数为工具,引导学生建立单调性与极值的判定方法,重点突破极值点的变号条件。",
};
export const mockReport: GeneratedReport = {
id: "ai-rpt-001",
content: [
"# 班级学情报告(周报)",
"",
"## 一、总体概况",
"本周高三(1)班数学共完成 3 次作业、1 次单元测试,整体表现稳中有升。",
"作业平均完成率 94%,单元测试平均分 108.5(满分 150较上周提升 3.2 分。",
"",
"## 二、薄弱知识点分析",
"1. **导数应用**:失分集中在于极值判定,约 35% 学生混淆极值点与驻点。",
"2. **三角函数**:周期与最值的综合应用错误率 28%。",
"3. **立体几何**:体积计算公式记忆不牢,错误率 22%。",
"",
"## 三、优秀表现",
"- 李明、王芳连续两周作业满分。",
"- 陈强本次单元测试进步 18 分,进入班级前 10。",
"",
"## 四、后续关注",
"重点关注 3 名连续两次测试低于平均线的学生,建议安排课后辅导。",
].join("\n"),
summary:
"班级整体稳中有升,导数应用与三角函数综合为本周薄弱点,需在下周教学中重点强化。",
recommendations: [
"下周安排 1 课时专项练习(导数极值判定),配合错题精讲。",
"对 3 名后进学生安排周二/周四课后 30 分钟辅导。",
"引入分层作业,为前 10 名学生布置拓展题,保持学习动力。",
],
};

View File

@@ -0,0 +1,105 @@
/**
* Mock 学情分析数据data-ana / ai 域P4 扩展)
*
* 维护者ai13teacher-portal
*/
import type {
ClassAnalytics,
StudentAnalytics,
} from "@/lib/graphql-p4";
/** Top 学生 mock5 名,含趋势 / 弱项 / 强项) */
const topStudents: StudentAnalytics[] = [
{
studentId: "stu-001",
studentName: "学生01",
avgScore: 92,
trend: [88, 90, 85, 91, 94],
weakPoints: ["导数", "书面表达"],
strongPoints: ["实数与运算", "词汇基础"],
masteryRate: 90,
},
{
studentId: "stu-002",
studentName: "学生02",
avgScore: 89,
trend: [85, 87, 86, 90, 92],
weakPoints: ["函数", "议论文写作"],
strongPoints: ["代数式", "语法结构"],
masteryRate: 84,
},
{
studentId: "stu-003",
studentName: "学生03",
avgScore: 86,
trend: [80, 83, 85, 88, 90],
weakPoints: ["方程与不等式", "阅读理解"],
strongPoints: ["文言文基础", "词汇基础"],
masteryRate: 78,
},
{
studentId: "stu-004",
studentName: "学生04",
avgScore: 84,
trend: [78, 82, 80, 86, 88],
weakPoints: ["函数", "现代文阅读"],
strongPoints: ["实数与运算", "古诗词鉴赏"],
masteryRate: 72,
},
{
studentId: "stu-005",
studentName: "学生05",
avgScore: 81,
trend: [75, 79, 82, 84, 85],
weakPoints: ["导数", "书面表达", "阅读理解"],
strongPoints: ["词汇基础", "代数式"],
masteryRate: 68,
},
];
/** 班级学情分析 mock */
export const mockClassAnalytics: ClassAnalytics = {
classId: "550e8400-e29b-41d4-a716-446655440010",
className: "高三(1)班",
avgScore: 78.5,
passRate: 87.5,
avgTrend: [72, 74, 76, 77, 79],
topStudents,
weakPoints: ["导数", "函数", "书面表达", "阅读理解"],
};
/** 单生学情分析 mock默认返回 Top1 学生数据) */
export const mockStudentAnalytics: StudentAnalytics = {
studentId: "stu-001",
studentName: "学生01",
avgScore: 92,
trend: [88, 90, 85, 91, 94],
weakPoints: ["导数", "书面表达"],
strongPoints: ["实数与运算", "词汇基础"],
masteryRate: 90,
};
/**
* 按学生 ID 查找学情数据
* 未命中时返回 mockStudentAnalytics 兜底
*/
export function findStudentAnalytics(
studentId: string,
): StudentAnalytics {
const found = topStudents.find((s) => s.studentId === studentId);
if (found) {
return found;
}
// 未命中时基于 studentId 构造一份变化数据,避免页面空态
return {
...mockStudentAnalytics,
studentId,
studentName: `学生${studentId.slice(-2)}`,
avgScore: 75,
trend: [70, 72, 74, 73, 76],
weakPoints: ["函数", "现代文阅读"],
strongPoints: ["词汇基础"],
masteryRate: 65,
};
}

View File

@@ -0,0 +1,501 @@
/**
* Mock 考勤数据P7-admin考勤子模块
*
* 维护者ai13teacher-portal
*
* 覆盖:
* - 5 个班级 × 30 学生 × 30 天 = 4500 条考勤记录(确定性伪随机)
* - 班级考勤统计(出勤率/迟到率/早退率/请假率)
* - 30 天每日趋势数据
* - 学生考勤预警(出勤率 < 90%
* - 考勤报告数据(周报/月报)
*/
import type {
AttendanceRecord,
AttendanceStatus,
AttendanceListFilter,
AttendanceListResult,
AttendanceSheetItem,
SaveAttendanceSheetInput,
SaveAttendanceSheetResult,
AttendanceStats,
AttendanceTrendPoint,
AttendanceClassComparison,
AttendanceWarning,
AttendanceReport,
AttendanceReportType,
AttendanceReportDetail,
} from "@/lib/graphql-p7-admin";
import { EXTENDED_CLASSES } from "./grades-extended";
/** 5 个班级 ID 列表 */
const CLASS_IDS = EXTENDED_CLASSES.map((c) => c.id);
/** 30 天日期列表(从今天往前回溯 29 天) */
function buildDateRange(days: number): string[] {
const dates: string[] = [];
const today = new Date();
today.setHours(0, 0, 0, 0);
for (let i = days - 1; i >= 0; i--) {
const d = new Date(today);
d.setDate(d.getDate() - i);
dates.push(formatDate(d));
}
return dates;
}
/** 格式化日期 YYYY-MM-DD */
function formatDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
/** 30 天日期列表 */
export const ATTENDANCE_DATES = buildDateRange(30);
/** 确定性伪随机 */
function seededRandom(seed: number): number {
const x = Math.sin(seed) * 10000;
return x - Math.floor(x);
}
/** 基于字符串生成 seed */
function strToSeed(s: string): number {
let h = 0;
for (let i = 0; i < s.length; i++) {
h = (h * 31 + s.charCodeAt(i)) | 0;
}
return Math.abs(h) + 1;
}
/** 生成单生学号 */
function studentNoFor(classIdx: number, studentIdx: number): string {
return `2026${String(classIdx + 1).padStart(2, "0")}${String(studentIdx).padStart(3, "0")}`;
}
/** 生成单生 ID */
function studentIdFor(classId: string, studentIdx: number): string {
return `stu-${classId.slice(-4)}-${String(studentIdx).padStart(3, "0")}`;
}
/** 学生姓名(确定性) */
function studentNameFor(studentIdx: number): string {
return `学生${String(studentIdx).padStart(2, "0")}`;
}
/** 班级出勤率基准(与 grades-extended 对齐) */
const CLASS_PRESENT_RATE_BASE: Record<string, number> = {
"550e8400-e29b-41d4-a716-446655440010": 95,
"550e8400-e29b-41d4-a716-446655440011": 92,
"550e8400-e29b-41d4-a716-446655440012": 90,
"550e8400-e29b-41d4-a716-446655440013": 93,
"550e8400-e29b-41d4-a716-446655440014": 96,
};
/** 根据班级 + 学生 + 日期生成确定性考勤状态 */
function pickStatus(
classId: string,
studentIdx: number,
dateStr: string,
): AttendanceStatus {
const base = CLASS_PRESENT_RATE_BASE[classId] ?? 92;
const seed = strToSeed(`${classId}-${studentIdx}-${dateStr}`);
const r = seededRandom(seed);
// 调整:班级越强,出勤率越高
const presentThreshold = base / 100;
const lateThreshold = presentThreshold + 0.03;
const leaveThreshold = lateThreshold + 0.02;
const earlyLeaveThreshold = leaveThreshold + 0.015;
if (r < presentThreshold) return "PRESENT";
if (r < lateThreshold) return "LATE";
if (r < leaveThreshold) return "LEAVE";
if (r < earlyLeaveThreshold) return "EARLY_LEAVE";
return "ABSENT";
}
/** 生成全量考勤记录4500 条) */
function generateAllRecords(): AttendanceRecord[] {
const records: AttendanceRecord[] = [];
CLASS_IDS.forEach((classId, classIdx) => {
const cls = EXTENDED_CLASSES[classIdx];
if (!cls) return;
for (const date of ATTENDANCE_DATES) {
// 周末(周六/周日)跳过:基于日期字符串判断
const dayOfWeek = new Date(date).getDay();
if (dayOfWeek === 0 || dayOfWeek === 6) continue;
for (let s = 1; s <= 30; s++) {
const status = pickStatus(classId, s, date);
const note =
status === "ABSENT"
? "未到校,需跟进"
: status === "LEAVE"
? "已提交请假条"
: status === "LATE"
? "迟到 5 分钟"
: null;
records.push({
id: `att-${cls.id.slice(-4)}-${date}-${String(s).padStart(3, "0")}`,
classId,
className: cls.name,
studentId: studentIdFor(classId, s),
studentName: studentNameFor(s),
studentNo: studentNoFor(classIdx, s),
date,
status,
note,
});
}
}
});
return records;
}
/** 全量考勤记录缓存 */
const ALL_RECORDS: AttendanceRecord[] = generateAllRecords();
/** 保存的考勤覆盖key = `${classId}|${date}|${studentId}`),用于 SaveAttendanceSheet 后即时反映 */
const SAVED_OVERRIDES = new Map<string, AttendanceStatus>();
function makeOverrideKey(
classId: string,
date: string,
studentId: string,
): string {
return `${classId}|${date}|${studentId}`;
}
/** 应用覆盖后的状态读取 */
function resolveStatus(
classId: string,
date: string,
studentId: string,
fallback: AttendanceStatus,
): AttendanceStatus {
const override = SAVED_OVERRIDES.get(
makeOverrideKey(classId, date, studentId),
);
return override ?? fallback;
}
/** 考勤列表查询(按筛选 + 分页) */
export function queryAttendanceList(
filter: AttendanceListFilter,
): AttendanceListResult {
const { classId, startDate, endDate, statuses, page, pageSize } = filter;
const safePage = page ?? 1;
const safePageSize = pageSize ?? 30;
const statusSet = statuses && statuses.length > 0 ? new Set(statuses) : null;
const filtered = ALL_RECORDS.filter((r) => {
if (classId && r.classId !== classId) return false;
if (startDate && r.date < startDate) return false;
if (endDate && r.date > endDate) return false;
if (statusSet && !statusSet.has(r.status)) return false;
return true;
});
const total = filtered.length;
const totalPages = Math.max(1, Math.ceil(total / safePageSize));
const currentPage = Math.min(Math.max(1, safePage), totalPages);
const start = (currentPage - 1) * safePageSize;
const items = filtered.slice(start, start + safePageSize);
return {
items,
total,
page: currentPage,
pageSize: safePageSize,
totalPages,
};
}
/** 考勤录入查询(按 classId + date 拉取学生 + 当天状态) */
export function queryAttendanceSheet(
classId: string,
date: string,
): AttendanceSheetItem[] {
const cls = EXTENDED_CLASSES.find((c) => c.id === classId);
if (!cls) return [];
const classIdx = EXTENDED_CLASSES.indexOf(cls);
const items: AttendanceSheetItem[] = [];
for (let s = 1; s <= 30; s++) {
const studentId = studentIdFor(classId, s);
const baseStatus = pickStatus(classId, s, date);
const status = resolveStatus(classId, date, studentId, baseStatus);
items.push({
studentId,
studentName: studentNameFor(s),
studentNo: studentNoFor(classIdx, s),
date,
status,
note:
status === "ABSENT"
? "未到校,需跟进"
: status === "LEAVE"
? "已提交请假条"
: null,
});
}
return items;
}
/** 批量保存考勤录入 */
export function saveAttendanceSheet(
input: SaveAttendanceSheetInput,
): SaveAttendanceSheetResult {
for (const rec of input.records) {
SAVED_OVERRIDES.set(
makeOverrideKey(input.classId, input.date, rec.studentId),
rec.status,
);
}
return {
classId: input.classId,
date: input.date,
savedCount: input.records.length,
savedAt: new Date().toISOString(),
};
}
/** 计算班级考勤统计 */
export function queryAttendanceStats(
classId: string,
startDate: string,
endDate: string,
): AttendanceStats {
const cls = EXTENDED_CLASSES.find((c) => c.id === classId);
const className = cls?.name ?? "未知班级";
const classRecords = ALL_RECORDS.filter((r) => {
if (r.classId !== classId) return false;
if (r.date < startDate || r.date > endDate) return false;
return true;
});
const total = classRecords.length;
const counts = { present: 0, absent: 0, late: 0, earlyLeave: 0, leave: 0 };
for (const r of classRecords) {
if (r.status === "PRESENT") counts.present++;
else if (r.status === "ABSENT") counts.absent++;
else if (r.status === "LATE") counts.late++;
else if (r.status === "EARLY_LEAVE") counts.earlyLeave++;
else if (r.status === "LEAVE") counts.leave++;
}
const safeDiv = (n: number): number =>
total === 0 ? 0 : Math.round((n / total) * 1000) / 10;
const trend = buildTrend(classId, startDate, endDate);
const warningCount = computeWarnings(classId, startDate, endDate).length;
return {
classId,
className,
startDate,
endDate,
totalRecords: total,
presentCount: counts.present,
absentCount: counts.absent,
lateCount: counts.late,
earlyLeaveCount: counts.earlyLeave,
leaveCount: counts.leave,
presentRate: safeDiv(counts.present),
lateRate: safeDiv(counts.late),
earlyLeaveRate: safeDiv(counts.earlyLeave),
leaveRate: safeDiv(counts.leave),
warningCount,
trend,
};
}
/** 构建趋势(按日聚合) */
function buildTrend(
classId: string,
startDate: string,
endDate: string,
): AttendanceTrendPoint[] {
const dates = ATTENDANCE_DATES.filter((d) => d >= startDate && d <= endDate);
return dates.map((date) => {
const dayRecords = ALL_RECORDS.filter(
(r) => r.classId === classId && r.date === date,
);
const total = dayRecords.length;
const safeDiv = (n: number): number =>
total === 0 ? 0 : Math.round((n / total) * 1000) / 10;
const present = dayRecords.filter((r) => r.status === "PRESENT").length;
const late = dayRecords.filter((r) => r.status === "LATE").length;
const absent = dayRecords.filter((r) => r.status === "ABSENT").length;
return {
date,
presentRate: safeDiv(present),
lateRate: safeDiv(late),
absentRate: safeDiv(absent),
};
});
}
/** 班级考勤对比 */
export function queryAttendanceClassComparison(
startDate: string,
endDate: string,
): AttendanceClassComparison[] {
return CLASS_IDS.map((classId) => {
const stats = queryAttendanceStats(classId, startDate, endDate);
const cls = EXTENDED_CLASSES.find((c) => c.id === classId);
return {
classId,
className: cls?.name ?? "未知班级",
presentRate: stats.presentRate,
lateRate: stats.lateRate,
absentRate: Math.round(
(stats.absentCount / Math.max(1, stats.totalRecords)) * 1000,
) / 10,
};
});
}
/** 学生考勤预警(出勤率 < 90% */
function computeWarnings(
classId: string,
startDate: string,
endDate: string,
): AttendanceWarning[] {
const cls = EXTENDED_CLASSES.find((c) => c.id === classId);
if (!cls) return [];
const classIdx = EXTENDED_CLASSES.indexOf(cls);
const warnings: AttendanceWarning[] = [];
for (let s = 1; s <= 30; s++) {
const studentId = studentIdFor(classId, s);
const studentRecords = ALL_RECORDS.filter((r) => {
if (r.classId !== classId) return false;
if (r.studentId !== studentId) return false;
if (r.date < startDate || r.date > endDate) return false;
return true;
});
const total = studentRecords.length;
if (total === 0) continue;
const present = studentRecords.filter((r) => r.status === "PRESENT").length;
const rate = Math.round((present / total) * 1000) / 10;
if (rate < 90) {
warnings.push({
studentId,
studentName: studentNameFor(s),
studentNo: studentNoFor(classIdx, s),
className: cls.name,
presentRate: rate,
absentCount: studentRecords.filter((r) => r.status === "ABSENT").length,
lateCount: studentRecords.filter((r) => r.status === "LATE").length,
});
}
}
// 按出勤率升序
warnings.sort((a, b) => a.presentRate - b.presentRate);
return warnings;
}
/** 学生考勤预警查询入口 */
export function queryAttendanceWarnings(
classId: string,
startDate: string,
endDate: string,
): AttendanceWarning[] {
return computeWarnings(classId, startDate, endDate);
}
/** 考勤报告查询 */
export function queryAttendanceReport(
classId: string,
reportType: AttendanceReportType,
startDate: string,
endDate: string,
): AttendanceReport {
const cls = EXTENDED_CLASSES.find((c) => c.id === classId);
const className = cls?.name ?? "未知班级";
const stats = queryAttendanceStats(classId, startDate, endDate);
const details = buildReportDetails(classId, startDate, endDate);
return {
classId,
className,
reportType,
startDate,
endDate,
generatedAt: new Date().toISOString(),
summary: {
totalRecords: stats.totalRecords,
presentRate: stats.presentRate,
lateRate: stats.lateRate,
earlyLeaveRate: stats.earlyLeaveRate,
leaveRate: stats.leaveRate,
warningCount: stats.warningCount,
},
details,
};
}
/** 构建报告明细(按学生聚合) */
function buildReportDetails(
classId: string,
startDate: string,
endDate: string,
): AttendanceReportDetail[] {
const cls = EXTENDED_CLASSES.find((c) => c.id === classId);
if (!cls) return [];
const classIdx = EXTENDED_CLASSES.indexOf(cls);
const details: AttendanceReportDetail[] = [];
for (let s = 1; s <= 30; s++) {
const studentId = studentIdFor(classId, s);
const studentRecords = ALL_RECORDS.filter((r) => {
if (r.classId !== classId) return false;
if (r.studentId !== studentId) return false;
if (r.date < startDate || r.date > endDate) return false;
return true;
});
const total = studentRecords.length;
if (total === 0) continue;
const present = studentRecords.filter((r) => r.status === "PRESENT").length;
const absent = studentRecords.filter((r) => r.status === "ABSENT").length;
const late = studentRecords.filter((r) => r.status === "LATE").length;
const earlyLeave = studentRecords.filter(
(r) => r.status === "EARLY_LEAVE",
).length;
const leave = studentRecords.filter((r) => r.status === "LEAVE").length;
details.push({
studentId,
studentName: studentNameFor(s),
studentNo: studentNoFor(classIdx, s),
presentCount: present,
absentCount: absent,
lateCount: late,
earlyLeaveCount: earlyLeave,
leaveCount: leave,
presentRate: Math.round((present / total) * 1000) / 10,
});
}
// 按出勤率降序
details.sort((a, b) => b.presentRate - a.presentRate);
return details;
}
/** 考勤列表分页类型重导出(供 handlers 使用) */
export type { AttendanceListResult };
/** 默认日期范围(近 7 天) */
export function defaultDateRange(): { startDate: string; endDate: string } {
const dates = ATTENDANCE_DATES;
const end = dates[dates.length - 1] ?? formatDate(new Date());
const startIdx = Math.max(0, dates.length - 7);
const start = dates[startIdx] ?? end;
return { startDate: start, endDate: end };
}
/** 近 30 天日期范围 */
export function fullDateRange(): { startDate: string; endDate: string } {
const dates = ATTENDANCE_DATES;
return {
startDate: dates[0] ?? formatDate(new Date()),
endDate: dates[dates.length - 1] ?? formatDate(new Date()),
};
}

View File

@@ -0,0 +1,265 @@
/**
* Mock 班级详情 + 课表数据P7-admin班级详情聚合
*
* 维护者ai13teacher-portal
*
* 覆盖:
* - 5 个班级详情(基本信息 + 学生 + 课表 + 近期作业 + 概览统计 + 成绩趋势)
* - 课表数据5 班 × 周一到周五 × 8 节 = 200 节)
*/
import type {
ClassDetail,
ClassDetailAggregate,
ClassDetailStudent,
ClassDetailHomework,
ClassOverviewStats,
ClassDetailTrendPoint,
ClassScheduleItem,
} from "@/lib/graphql-p7-admin";
import { EXTENDED_CLASSES, SUBJECTS } from "./grades-extended";
/** 班级任课教师(确定性生成) */
const CLASS_TEACHERS = [
"王老师",
"刘老师",
"陈老师",
"杨老师",
"周老师",
] as const;
/** 班主任名单 */
const HOMEROOM_TEACHERS = [
"王主任",
"李主任",
"张主任",
"赵主任",
"钱主任",
] as const;
/** 教室编号 */
const ROOMS = ["A101", "A102", "A103", "A104", "A105"] as const;
/** 学校名 */
const SCHOOL_NAME = "示范中学";
/** 8 节课的起止时间 */
const PERIOD_TIMES: Array<{ start: string; end: string }> = [
{ start: "08:00", end: "08:45" },
{ start: "08:55", end: "09:40" },
{ start: "10:00", end: "10:45" },
{ start: "10:55", end: "11:40" },
{ start: "13:30", end: "14:15" },
{ start: "14:25", end: "15:10" },
{ start: "15:25", end: "16:10" },
{ start: "16:20", end: "17:05" },
];
/** 班级学科(班主任学科) */
const CLASS_SUBJECTS = ["数学", "语文", "英语", "物理", "化学"] as const;
/** 学生姓名(确定性) */
function studentNameFor(studentIdx: number): string {
return `学生${String(studentIdx).padStart(2, "0")}`;
}
/** 学生学号 */
function studentNoFor(classIdx: number, studentIdx: number): string {
return `2026${String(classIdx + 1).padStart(2, "0")}${String(studentIdx).padStart(3, "0")}`;
}
/** 学生邮箱 */
function emailFor(classIdx: number, studentIdx: number): string {
return `student${classIdx + 1}_${studentIdx}@edu.test`;
}
/** 班级学生列表生成 */
function buildStudents(
classId: string,
classIdx: number,
count: number,
): ClassDetailStudent[] {
const items: ClassDetailStudent[] = [];
for (let i = 1; i <= count; i++) {
items.push({
id: `stu-${classId.slice(-4)}-${String(i).padStart(3, "0")}`,
name: studentNameFor(i),
studentNo: studentNoFor(classIdx, i),
email: emailFor(classIdx, i),
});
}
return items;
}
/** 班级详情生成 */
function buildClassDetail(
classId: string,
classIdx: number,
): ClassDetail {
const cls = EXTENDED_CLASSES[classIdx];
const subject = CLASS_SUBJECTS[classIdx] ?? "数学";
return {
id: classId,
name: cls?.name ?? `班级${classIdx + 1}`,
gradeId: cls?.gradeId ?? "grade-12",
subject,
studentCount: 30,
homeroomTeacher: HOMEROOM_TEACHERS[classIdx] ?? "王主任",
room: ROOMS[classIdx] ?? "A101",
schoolName: SCHOOL_NAME,
};
}
/** 课表生成5 班 × 周一到周五 × 8 节 = 200 节) */
function buildSchedule(
classId: string,
classIdx: number,
): ClassScheduleItem[] {
const cls = EXTENDED_CLASSES[classIdx];
const items: ClassScheduleItem[] = [];
// 每天最多 6 节(最后两节为自习或空)
const subjectRotation = SUBJECTS;
for (let day = 1; day <= 5; day++) {
for (let period = 1; period <= 8; period++) {
// 第 7、8 节多数为自习(保留少量科目)
const isSelfStudy = period >= 7 && (day + period) % 2 === 0;
const subjectIdx = (day + period + classIdx) % subjectRotation.length;
const subject = isSelfStudy
? "自习"
: (subjectRotation[subjectIdx] ?? "数学");
const teacherIdx = (day + period + classIdx) % CLASS_TEACHERS.length;
const times = PERIOD_TIMES[period - 1];
if (!times) continue;
items.push({
id: `sch-${classId.slice(-4)}-${day}-${period}`,
classId,
className: cls?.name ?? `班级${classIdx + 1}`,
dayOfWeek: day,
period,
subject,
teacherName: isSelfStudy
? "—"
: (CLASS_TEACHERS[teacherIdx] ?? "未知"),
room: ROOMS[classIdx] ?? "A101",
startTime: times.start,
endTime: times.end,
});
}
}
return items;
}
/** 近期作业生成5 个) */
function buildRecentHomework(classId: string): ClassDetailHomework[] {
const items: ClassDetailHomework[] = [];
const today = new Date();
today.setHours(0, 0, 0, 0);
for (let i = 0; i < 5; i++) {
const due = new Date(today);
due.setDate(due.getDate() + i - 1);
const dueStr = `${due.getFullYear()}-${String(due.getMonth() + 1).padStart(2, "0")}-${String(due.getDate()).padStart(2, "0")}`;
const subject = SUBJECTS[i % SUBJECTS.length] ?? "数学";
items.push({
id: `hw-${classId.slice(-4)}-${i}`,
title: `${subject} - 作业 ${i + 1}`,
dueDate: dueStr,
submissionRate: 70 + ((i * 7) % 25),
avgScore: i === 4 ? null : 75 + ((i * 5) % 18),
});
}
return items;
}
/** 概览统计 */
function buildOverview(
classId: string,
classIdx: number,
): ClassOverviewStats {
const base = 78 + classIdx * 2;
return {
attendanceRate: 90 + (classIdx % 5),
averageScore: base,
homeworkCompletionRate: 80 + (classIdx % 10),
recentExamTitle: `高三第${classIdx + 1}次月考`,
recentExamAvg: base - 4,
};
}
/** 成绩趋势6 个月) */
function buildTrend(classId: string): ClassDetailTrendPoint[] {
const months = ["1月", "2月", "3月", "4月", "5月", "6月"];
const base = 75 + (classId.charCodeAt(classId.length - 1) % 8);
return months.map((m, i) => ({
month: m,
avgScore: base + ((i * 3) % 8) - 2,
}));
}
/** 班级详情缓存(按 id 索引) */
const CLASS_DETAIL_CACHE: Record<string, ClassDetailAggregate> = {};
/** 初始化所有班级详情 */
function initClassDetails(): void {
EXTENDED_CLASSES.forEach((cls, idx) => {
const classId = cls.id;
CLASS_DETAIL_CACHE[classId] = {
class: buildClassDetail(classId, idx),
students: buildStudents(classId, idx, 30),
schedule: buildSchedule(classId, idx),
recentHomework: buildRecentHomework(classId),
overview: buildOverview(classId, idx),
trend: buildTrend(classId),
};
});
}
initClassDetails();
/** 查询班级详情聚合 */
export function queryClassDetail(
classId: string,
): ClassDetailAggregate | null {
return CLASS_DETAIL_CACHE[classId] ?? null;
}
/** 查询班级课表(支持 classId 过滤) */
export function queryClassSchedule(classId?: string): ClassScheduleItem[] {
const allItems: ClassScheduleItem[] = [];
for (const cls of EXTENDED_CLASSES) {
const detail = CLASS_DETAIL_CACHE[cls.id];
if (!detail) continue;
if (classId && cls.id !== classId) continue;
allItems.push(...detail.schedule);
}
// 按 dayOfWeek 升序,再按 period 升序
allItems.sort((a, b) => {
if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek;
return a.period - b.period;
});
return allItems;
}
/** 班级详情基本信息(独立访问) */
export function queryClassBasicInfo(classId: string): ClassDetail | null {
return CLASS_DETAIL_CACHE[classId]?.class ?? null;
}
/** 班级课表按 dayOfWeek 分组 */
export function groupScheduleByDay(
schedule: ClassScheduleItem[],
): Record<number, ClassScheduleItem[]> {
const grouped: Record<number, ClassScheduleItem[]> = {
1: [],
2: [],
3: [],
4: [],
5: [],
6: [],
7: [],
};
for (const item of schedule) {
const list = grouped[item.dayOfWeek];
if (list) list.push(item);
}
return grouped;
}

View File

@@ -0,0 +1,218 @@
/**
* Mock 课程计划数据course-plans 域P7-insights 扩展)
*
* 维护者ai13teacher-portal
*
* 覆盖:
* - 5 个课程计划draft/published/archived
* - 每个含 4-6 章节(含学时/目标/重难点/活动)
* - 关联教材 + 作业入口
*/
import type {
CoursePlanItem,
CoursePlanDetail,
CoursePlanChapter,
CreateCoursePlanInput,
UpdateCoursePlanInput,
} from "@/lib/graphql-p7-insights";
interface CoursePlanDef {
id: string;
title: string;
subject: string;
grade: string;
academicYear: string;
semester: string;
totalHours: number;
status: "DRAFT" | "PUBLISHED" | "ARCHIVED";
textbookId: string | null;
textbookTitle: string | null;
homeworkCount: number;
chapterCount: number;
updatedAt: string;
}
const COURSE_PLAN_DEFS: CoursePlanDef[] = [
{
id: "cp-001",
title: "高三数学上学期课程计划",
subject: "数学",
grade: "高中三年级",
academicYear: "2026-2027",
semester: "上学期",
totalHours: 64,
status: "PUBLISHED",
textbookId: null,
textbookTitle: null,
homeworkCount: 12,
chapterCount: 6,
updatedAt: "2026-07-01T09:00:00Z",
},
{
id: "cp-002",
title: "高一语文上学期课程计划",
subject: "语文",
grade: "高中一年级",
academicYear: "2026-2027",
semester: "上学期",
totalHours: 48,
status: "PUBLISHED",
textbookId: "tb-001",
textbookTitle: "小学语文三年级上册",
homeworkCount: 8,
chapterCount: 4,
updatedAt: "2026-07-02T14:30:00Z",
},
{
id: "cp-003",
title: "初中英语七年级下学期课程计划",
subject: "英语",
grade: "初中一年级",
academicYear: "2026-2027",
semester: "下学期",
totalHours: 36,
status: "DRAFT",
textbookId: "tb-003",
textbookTitle: "初中英语七年级上册",
homeworkCount: 6,
chapterCount: 5,
updatedAt: "2026-07-03T10:15:00Z",
},
{
id: "cp-004",
title: "小学数学六年级复习课程计划",
subject: "数学",
grade: "小学六年级",
academicYear: "2026-2027",
semester: "下学期",
totalHours: 24,
status: "ARCHIVED",
textbookId: "tb-002",
textbookTitle: "小学数学六年级上册",
homeworkCount: 4,
chapterCount: 4,
updatedAt: "2026-06-28T09:30:00Z",
},
{
id: "cp-005",
title: "高三数学下学期复习冲刺",
subject: "数学",
grade: "高中三年级",
academicYear: "2026-2027",
semester: "下学期",
totalHours: 48,
status: "DRAFT",
textbookId: null,
textbookTitle: null,
homeworkCount: 10,
chapterCount: 5,
updatedAt: "2026-07-08T13:45:00Z",
},
];
/** 课程计划列表项 */
export const mockCoursePlans: CoursePlanItem[] = COURSE_PLAN_DEFS.map((p) => ({
id: p.id,
title: p.title,
subject: p.subject,
grade: p.grade,
academicYear: p.academicYear,
semester: p.semester,
totalHours: p.totalHours,
status: p.status,
textbookId: p.textbookId,
updatedAt: p.updatedAt,
}));
/** 按状态筛选 */
export function filterCoursePlans(
status?: "DRAFT" | "PUBLISHED" | "ARCHIVED" | "ALL" | null,
): CoursePlanItem[] {
if (!status || status === "ALL") return mockCoursePlans;
return mockCoursePlans.filter((p) => p.status === status);
}
/** 生成章节(按章节模板) */
function generateChapters(planId: string, count: number): CoursePlanChapter[] {
const template = [
{ title: "第一章 导入与基础", obj: "建立学科认知框架", kp: "基础概念与运算规则" },
{ title: "第二章 核心知识", obj: "掌握核心知识与方法", kp: "核心定理与推导" },
{ title: "第三章 综合应用", obj: "能够解决综合问题", kp: "应用场景与解题策略" },
{ title: "第四章 拓展提升", obj: "拓展学科思维", kp: "高阶方法与变式" },
{ title: "第五章 复习巩固", obj: "系统化复习", kp: "知识网络与方法对比" },
{ title: "第六章 评估反馈", obj: "阶段性评估", kp: "易错点与补强" },
];
return Array.from({ length: count }, (_, i) => {
const t = template[i] ?? template[0]!;
return {
id: `${planId}-ch-${String(i + 1).padStart(2, "0")}`,
title: t.title,
hours: 8 + i * 2,
objectives: t.obj,
keyPoints: t.kp,
activities: `讲授 + 小组讨论 + 课堂练习 + 课后作业`,
};
});
}
/** 查询课程计划详情 */
export function findCoursePlanDetail(id: string): CoursePlanDetail | null {
const found = COURSE_PLAN_DEFS.find((p) => p.id === id);
if (!found) return null;
return {
id: found.id,
title: found.title,
subject: found.subject,
grade: found.grade,
academicYear: found.academicYear,
semester: found.semester,
totalHours: found.totalHours,
status: found.status,
textbookId: found.textbookId,
textbookTitle: found.textbookTitle,
homeworkCount: found.homeworkCount,
chapters: generateChapters(found.id, found.chapterCount),
updatedAt: found.updatedAt,
};
}
/** 创建课程计划 */
export function createMockCoursePlan(
input: CreateCoursePlanInput,
): CoursePlanItem {
const now = new Date().toISOString();
return {
id: `cp-${String(Date.now()).slice(-6)}`,
title: input.title,
subject: input.subject,
grade: input.grade,
academicYear: input.academicYear,
semester: input.semester,
totalHours: input.totalHours,
status: "DRAFT",
textbookId: input.textbookId ?? null,
updatedAt: now,
};
}
/** 更新课程计划 */
export function updateMockCoursePlan(
input: UpdateCoursePlanInput,
): CoursePlanItem | null {
const found = COURSE_PLAN_DEFS.find((p) => p.id === input.id);
if (!found) return null;
const now = new Date().toISOString();
return {
id: found.id,
title: input.title ?? found.title,
subject: found.subject,
grade: found.grade,
academicYear: found.academicYear,
semester: found.semester,
totalHours: found.totalHours,
status: input.status ?? found.status,
textbookId: found.textbookId,
updatedAt: now,
};
}

View File

@@ -0,0 +1,233 @@
/**
* Mock 诊断数据diagnostic 域P7-insights 扩展)
*
* 维护者ai13teacher-portal
*
* 覆盖:
* - 5 个班级诊断(每班 10 知识点掌握度 + 学生排名)
* - 5 个学生诊断(雷达图 + 班级对比 + 报告列表)
* - 诊断报告列表(按 type + status 筛选)
*/
import type {
DiagnosticReportItem,
DiagnosticReportType,
DiagnosticReportStatus,
ClassDiagnostic,
StudentDiagnostic,
DiagnosticKpMastery,
DiagnosticStudentRank,
DiagnosticRadarPoint,
} from "@/lib/graphql-p7-insights";
/** 5 个班级(复用 grades-extended 的 class id 保持一致) */
export const DIAGNOSTIC_CLASSES = [
{ id: "550e8400-e29b-41d4-a716-446655440010", name: "高三(1)班" },
{ id: "550e8400-e29b-41d4-a716-446655440011", name: "高三(2)班" },
{ id: "550e8400-e29b-41d4-a716-446655440012", name: "高三(3)班" },
{ id: "550e8400-e29b-41d4-a716-446655440013", name: "高三(4)班" },
{ id: "550e8400-e29b-41d4-a716-446655440014", name: "高三(5)班" },
] as const;
/** 10 个知识点(数学/语文混合) */
const KNOWLEDGE_POINTS = [
"函数与导数",
"数列",
"立体几何",
"解析几何",
"概率统计",
"实数运算",
"文言文基础",
"现代文阅读",
"古诗词鉴赏",
"议论文写作",
] as const;
/** 班级平均基准(用于确定性 mock */
const CLASS_BASE: Record<string, number> = {
"550e8400-e29b-41d4-a716-446655440010": 78,
"550e8400-e29b-41d4-a716-446655440011": 82,
"550e8400-e29b-41d4-a716-446655440012": 75,
"550e8400-e29b-41d4-a716-446655440013": 80,
"550e8400-e29b-41d4-a716-446655440014": 85,
};
/** 确定性伪随机 */
function seededRandom(seed: number): number {
const x = Math.sin(seed) * 10000;
return x - Math.floor(x);
}
function strToSeed(s: string): number {
let h = 0;
for (let i = 0; i < s.length; i++) {
h = (h * 31 + s.charCodeAt(i)) | 0;
}
return Math.abs(h) + 1;
}
function median(values: number[]): number {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 !== 0
? (sorted[mid] ?? 0)
: ((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2;
}
/** 诊断报告列表 mock12 条) */
const REPORT_DEFS: Array<{
id: string;
title: string;
type: DiagnosticReportType;
targetType: string;
status: DiagnosticReportStatus;
createdAt: string;
}> = [
{ id: "dr-001", title: "高三(1)班 期中诊断报告", type: "CLASS", targetType: "高三(1)班", status: "PUBLISHED", createdAt: "2026-07-01T09:00:00Z" },
{ id: "dr-002", title: "学生01 个人诊断报告", type: "INDIVIDUAL", targetType: "学生01", status: "PUBLISHED", createdAt: "2026-07-02T10:30:00Z" },
{ id: "dr-003", title: "高三年级 期中诊断", type: "GRADE", targetType: "高三年级", status: "PUBLISHED", createdAt: "2026-07-03T14:15:00Z" },
{ id: "dr-004", title: "高三(2)班 月考诊断", type: "CLASS", targetType: "高三(2)班", status: "DRAFT", createdAt: "2026-07-04T11:00:00Z" },
{ id: "dr-005", title: "学生02 个人诊断", type: "INDIVIDUAL", targetType: "学生02", status: "DRAFT", createdAt: "2026-07-05T08:45:00Z" },
{ id: "dr-006", title: "高三(3)班 单元诊断", type: "CLASS", targetType: "高三(3)班", status: "ARCHIVED", createdAt: "2026-06-28T16:20:00Z" },
{ id: "dr-007", title: "学生03 个人诊断", type: "INDIVIDUAL", targetType: "学生03", status: "PUBLISHED", createdAt: "2026-07-06T09:00:00Z" },
{ id: "dr-008", title: "高三年级 期末诊断", type: "GRADE", targetType: "高三年级", status: "DRAFT", createdAt: "2026-07-07T13:30:00Z" },
{ id: "dr-009", title: "高三(4)班 月考诊断", type: "CLASS", targetType: "高三(4)班", status: "PUBLISHED", createdAt: "2026-07-08T10:15:00Z" },
{ id: "dr-010", title: "学生04 个人诊断", type: "INDIVIDUAL", targetType: "学生04", status: "ARCHIVED", createdAt: "2026-06-30T15:00:00Z" },
{ id: "dr-011", title: "高三(5)班 期中诊断", type: "CLASS", targetType: "高三(5)班", status: "PUBLISHED", createdAt: "2026-07-09T11:30:00Z" },
{ id: "dr-012", title: "学生05 个人诊断", type: "INDIVIDUAL", targetType: "学生05", status: "DRAFT", createdAt: "2026-07-10T14:00:00Z" },
];
/** 按类型 + 状态筛选诊断报告 */
export function filterDiagnosticReports(
type?: DiagnosticReportType | null,
status?: DiagnosticReportStatus | null,
): DiagnosticReportItem[] {
return REPORT_DEFS.filter((r) => {
if (type && r.type !== type) return false;
if (status && r.status !== status) return false;
return true;
});
}
/** 生成班级知识点掌握度10 知识点) */
function generateKpMastery(classId: string): DiagnosticKpMastery[] {
const base = CLASS_BASE[classId] ?? 78;
return KNOWLEDGE_POINTS.map((kp, i) => {
const seed = strToSeed(`${classId}-kp-${i}`);
const delta = Math.round((seededRandom(seed) - 0.5) * 30);
const avg = Math.max(30, Math.min(98, base + delta));
// 模拟 20 学生掌握度分布
const studentCount = 20;
const mastered = Math.round(studentCount * (avg / 100));
const weak = Math.max(0, Math.round(studentCount * ((100 - avg) / 100) * 0.4));
const partial = Math.max(0, studentCount - mastered - weak);
// 中位数:基于 avg 的稳定 mock
const med = Math.max(30, Math.min(98, avg - 2 + (i % 3) - 1));
return {
knowledgePoint: kp,
avg,
median: med,
studentCount,
distribution: { mastered, partial, weak },
};
});
}
/** 生成班级学生排名20 学生) */
function generateRankings(classId: string): DiagnosticStudentRank[] {
const base = CLASS_BASE[classId] ?? 78;
const ranks: DiagnosticStudentRank[] = [];
for (let i = 1; i <= 20; i++) {
const seed = strToSeed(`${classId}-rank-${i}`);
const delta = Math.round((seededRandom(seed) - 0.5) * 30);
const masteryAvg = Math.max(30, Math.min(98, base + delta));
ranks.push({
rank: i,
studentId: `stu-${classId.slice(-4)}-${String(i).padStart(3, "0")}`,
studentName: `学生${String(i).padStart(2, "0")}`,
masteryAvg,
});
}
ranks.sort((a, b) => b.masteryAvg - a.masteryAvg);
return ranks.map((r, i) => ({ ...r, rank: i + 1 }));
}
/** 按班级 ID 查询班级诊断 */
export function findClassDiagnostic(classId: string): ClassDiagnostic | null {
const cls = DIAGNOSTIC_CLASSES.find((c) => c.id === classId);
if (!cls) return null;
const kpMastery = generateKpMastery(classId);
// 取前 5 知识点作为顶部摘要
const summary = kpMastery.slice(0, 5).map((k) => ({
knowledgePoint: k.knowledgePoint,
avg: k.avg,
}));
return {
classId,
className: cls.name,
studentCount: 20,
summary,
kpMastery,
rankings: generateRankings(classId),
};
}
/** 5 个学生诊断 */
const STUDENT_DIAGNOSTIC_DEFS: Array<{ id: string; name: string }> = [
{ id: "stu-001", name: "学生01" },
{ id: "stu-002", name: "学生02" },
{ id: "stu-003", name: "学生03" },
{ id: "stu-004", name: "学生04" },
{ id: "stu-005", name: "学生05" },
];
/** 生成学生雷达图6 轴) */
function generateRadar(studentId: string): DiagnosticRadarPoint[] {
const axes = ["函数", "数列", "几何", "概率", "统计", "导数"];
const classId = "550e8400-e29b-41d4-a716-446655440010";
const classBase = CLASS_BASE[classId] ?? 78;
return axes.map((axis, i) => {
const seed = strToSeed(`${studentId}-radar-${i}`);
const studentDelta = Math.round((seededRandom(seed) - 0.5) * 40);
const classDelta = Math.round((seededRandom(seed + 1) - 0.5) * 10);
return {
axis,
value: Math.max(20, Math.min(98, classBase + studentDelta)),
classAvg: Math.max(40, Math.min(95, classBase + classDelta)),
};
});
}
/** 按学生 ID 查询学生诊断 */
export function findStudentDiagnostic(
studentId: string,
): StudentDiagnostic | null {
const found = STUDENT_DIAGNOSTIC_DEFS.find((s) => s.id === studentId);
const name = found?.name ?? `学生${studentId.slice(-2)}`;
return {
studentId,
studentName: name,
radar: generateRadar(studentId),
reports: [
{ id: `dr-${studentId}-1`, title: `${name} 期中诊断`, createdAt: "2026-07-01T09:00:00Z", status: "PUBLISHED" },
{ id: `dr-${studentId}-2`, title: `${name} 月考诊断`, createdAt: "2026-06-15T10:30:00Z", status: "ARCHIVED" },
{ id: `dr-${studentId}-3`, title: `${name} 单元诊断`, createdAt: "2026-05-20T14:00:00Z", status: "ARCHIVED" },
],
};
}
/** 班级对比5 班级 × 平均掌握度) */
export function getClassDiagnosticComparison(): Array<{
classId: string;
className: string;
avgMastery: number;
}> {
return DIAGNOSTIC_CLASSES.map((c) => {
const base = CLASS_BASE[c.id] ?? 78;
return { classId: c.id, className: c.name, avgMastery: base };
});
}
/** 中位数工具导出(便于 handler 使用) */
export { median };

View File

@@ -0,0 +1,298 @@
/**
* Mock 选修课数据P7-advanced选修课模块
*
* 维护者ai13teacher-portal
*
* 覆盖:
* - 10 个选修课(覆盖 draft/open/closed/cancelled 状态)
* - 每个含学生选课列表
* - 按 ID 查找详情 + CRUD mock 函数
*/
import type {
ElectiveCourseItem,
ElectiveCourseDetail,
ElectiveStudent,
ElectiveCourseStatus,
CreateElectiveCourseInput,
UpdateElectiveCourseInput,
} from "@/lib/graphql-p7-advanced";
/** 当前教师 IDmock登录用户 */
export const CURRENT_TEACHER_ID = "tch-001";
export const CURRENT_TEACHER_NAME = "王老师";
/** 10 个选修课定义(覆盖 4 种状态) */
const ELECTIVE_COURSE_DEFS: Array<{
id: string;
title: string;
subject: string;
grade: string;
capacity: number;
teacherId: string;
teacherName: string;
semester: string;
status: ElectiveCourseStatus;
description: string | null;
enrolledCount: number;
}> = [
{
id: "ele-001",
title: "趣味数学建模",
subject: "数学",
grade: "高一",
capacity: 40,
teacherId: CURRENT_TEACHER_ID,
teacherName: CURRENT_TEACHER_NAME,
semester: "2026春季",
status: "open",
description: "通过实际案例学习数学建模方法",
enrolledCount: 32,
},
{
id: "ele-002",
title: "现代文学赏析",
subject: "语文",
grade: "高二",
capacity: 35,
teacherId: CURRENT_TEACHER_ID,
teacherName: CURRENT_TEACHER_NAME,
semester: "2026春季",
status: "open",
description: "阅读分析当代经典文学作品",
enrolledCount: 28,
},
{
id: "ele-003",
title: "物理实验探究",
subject: "物理",
grade: "高一",
capacity: 30,
teacherId: CURRENT_TEACHER_ID,
teacherName: CURRENT_TEACHER_NAME,
semester: "2026春季",
status: "closed",
description: "动手实验,探究物理规律",
enrolledCount: 30,
},
{
id: "ele-004",
title: "英语口语训练",
subject: "英语",
grade: "高二",
capacity: 25,
teacherId: CURRENT_TEACHER_ID,
teacherName: CURRENT_TEACHER_NAME,
semester: "2026春季",
status: "open",
description: null,
enrolledCount: 20,
},
{
id: "ele-005",
title: "化学与生活",
subject: "化学",
grade: "高一",
capacity: 40,
teacherId: CURRENT_TEACHER_ID,
teacherName: CURRENT_TEACHER_NAME,
semester: "2026春季",
status: "draft",
description: "了解化学在日常生活中的应用",
enrolledCount: 0,
},
{
id: "ele-006",
title: "生物多样性",
subject: "生物",
grade: "高二",
capacity: 35,
teacherId: CURRENT_TEACHER_ID,
teacherName: CURRENT_TEACHER_NAME,
semester: "2026春季",
status: "draft",
description: "探索生态系统与物种保护",
enrolledCount: 0,
},
{
id: "ele-007",
title: "历史人物评说",
subject: "历史",
grade: "高二",
capacity: 50,
teacherId: CURRENT_TEACHER_ID,
teacherName: CURRENT_TEACHER_NAME,
semester: "2025秋季",
status: "closed",
description: "评析历史人物的功过是非",
enrolledCount: 48,
},
{
id: "ele-008",
title: "地理与环境",
subject: "地理",
grade: "高一",
capacity: 40,
teacherId: CURRENT_TEACHER_ID,
teacherName: CURRENT_TEACHER_NAME,
semester: "2025秋季",
status: "cancelled",
description: "已取消:教师调动",
enrolledCount: 0,
},
{
id: "ele-009",
title: "信息技术基础",
subject: "信息技术",
grade: "高一",
capacity: 60,
teacherId: CURRENT_TEACHER_ID,
teacherName: CURRENT_TEACHER_NAME,
semester: "2026春季",
status: "open",
description: "学习编程基础与算法思维",
enrolledCount: 55,
},
{
id: "ele-010",
title: "美术鉴赏",
subject: "美术",
grade: "高二",
capacity: 30,
teacherId: CURRENT_TEACHER_ID,
teacherName: CURRENT_TEACHER_NAME,
semester: "2026春季",
status: "closed",
description: "鉴赏中外美术名作",
enrolledCount: 30,
},
];
/** 确定性伪随机 */
function seededRandom(seed: number): number {
const x = Math.sin(seed) * 10000;
return x - Math.floor(x);
}
function strToSeed(s: string): number {
let h = 0;
for (let i = 0; i < s.length; i++) {
h = (h * 31 + s.charCodeAt(i)) | 0;
}
return Math.abs(h) + 1;
}
/** 生成单个选修课的学生列表(按 enrolledCount */
function generateStudents(
courseId: string,
enrolledCount: number,
): ElectiveStudent[] {
const list: ElectiveStudent[] = [];
const now = Date.now();
for (let i = 1; i <= enrolledCount; i++) {
const seed = strToSeed(`${courseId}-stu-${i}`);
const isCancelled = seededRandom(seed) > 0.9;
list.push({
studentId: `stu-${courseId}-${String(i).padStart(3, "0")}`,
studentName: `学生${String(i).padStart(2, "0")}`,
studentNo: `2026${String(i).padStart(4, "0")}`,
enrolledAt: new Date(now - i * 24 * 60 * 60 * 1000).toISOString(),
status: isCancelled ? "cancelled" : "enrolled",
});
}
return list;
}
/** 生成选修课列表(可按 status / subject 过滤) */
export function generateElectiveCourses(
status?: string,
subject?: string,
): ElectiveCourseItem[] {
return ELECTIVE_COURSE_DEFS.filter((c) => {
if (status && c.status !== status) return false;
if (subject && c.subject !== subject) return false;
return true;
}).map((c) => ({
id: c.id,
title: c.title,
subject: c.subject,
grade: c.grade,
capacity: c.capacity,
enrolledCount: c.enrolledCount,
teacherId: c.teacherId,
teacherName: c.teacherName,
semester: c.semester,
status: c.status,
description: c.description,
}));
}
/** 按 ID 查找选修课详情(含学生列表) */
export function findElectiveCourseDetail(
id: string,
): ElectiveCourseDetail | null {
const def = ELECTIVE_COURSE_DEFS.find((c) => c.id === id);
if (!def) return null;
const now = Date.now();
return {
id: def.id,
title: def.title,
subject: def.subject,
grade: def.grade,
capacity: def.capacity,
enrolledCount: def.enrolledCount,
teacherId: def.teacherId,
teacherName: def.teacherName,
semester: def.semester,
status: def.status,
description: def.description,
students: generateStudents(def.id, def.enrolledCount),
createdAt: new Date(now - 30 * 24 * 60 * 60 * 1000).toISOString(),
updatedAt: new Date(now - 1 * 24 * 60 * 60 * 1000).toISOString(),
};
}
/** 创建选修课mock返回新 ID + draft 状态) */
export function createMockElectiveCourse(
_input: CreateElectiveCourseInput,
): { id: string; status: ElectiveCourseStatus; updatedAt: string } {
const randomId = `ele-${Math.random().toString(36).slice(2, 8)}`;
return {
id: randomId,
status: "draft",
updatedAt: new Date().toISOString(),
};
}
/** 更新选修课mock返回更新时间 */
export function updateMockElectiveCourse(
input: UpdateElectiveCourseInput,
): { id: string; status: ElectiveCourseStatus; updatedAt: string } {
return {
id: input.id,
status: "draft",
updatedAt: new Date().toISOString(),
};
}
/** 发布选修课mock返回 open 状态) */
export function publishMockElectiveCourse(
id: string,
): { id: string; status: ElectiveCourseStatus; updatedAt: string } {
return {
id,
status: "open",
updatedAt: new Date().toISOString(),
};
}
/** 取消选修课mock返回 cancelled 状态) */
export function cancelMockElectiveCourse(
id: string,
): { id: string; status: ElectiveCourseStatus; updatedAt: string } {
return {
id,
status: "cancelled",
updatedAt: new Date().toISOString(),
};
}

View File

@@ -0,0 +1,182 @@
/**
* Mock 错题本数据error-book 域P7-insights 扩展)
*
* 维护者ai13teacher-portal
*
* 覆盖:
* - 5 班级 × 6 学科 错题统计
* - 章节薄弱度5 学科 × 10 章节)
* - 知识点薄弱度5 学科 × 20 知识点)
* - Top10 高频错题
* - 学生错题分组(前 5 学生)
*/
import type {
ErrorBook,
ErrorBookSummary,
ErrorBookClassComparison,
ErrorBookChapterWeakness,
ErrorBookKpWeakness,
ErrorBookStudentGroup,
ErrorBookTopQuestion,
} from "@/lib/graphql-p7-insights";
/** 5 个班级 */
const CLASSES = [
{ id: "550e8400-e29b-41d4-a716-446655440010", name: "高三(1)班", base: 120 },
{ id: "550e8400-e29b-41d4-a716-446655440011", name: "高三(2)班", base: 110 },
{ id: "550e8400-e29b-41d4-a716-446655440012", name: "高三(3)班", base: 135 },
{ id: "550e8400-e29b-41d4-a716-446655440013", name: "高三(4)班", base: 105 },
{ id: "550e8400-e29b-41d4-a716-446655440014", name: "高三(5)班", base: 95 },
] as const;
/** 6 学科(错题本用) */
export const ERROR_BOOK_SUBJECTS = [
"语文",
"数学",
"英语",
"物理",
"化学",
"生物",
] as const;
/** 章节模板(每学科 10 章节) */
const CHAPTER_TEMPLATES: Record<string, string[]> = {
: ["现代文阅读", "古诗词鉴赏", "文言文阅读", "成语运用", "病句辨析", "论述类文本", "实用类文本", "语言运用", "议论文写作", "记叙文写作"],
: ["集合与逻辑", "函数与导数", "三角函数", "数列", "立体几何", "解析几何", "概率统计", "不等式", "复数", "向量"],
: ["听力", "阅读理解", "完形填空", "语法填空", "短文改错", "书面表达", "七选五", "词汇运用", "句型转换", "翻译"],
: ["力学", "运动学", "动力学", "能量守恒", "动量", "电学", "磁学", "光学", "热学", "近代物理"],
: ["化学键", "化学反应", "化学平衡", "电离", "水解", "氧化还原", "元素周期", "有机化学", "实验化学", "计算化学"],
: ["细胞", "遗传", "变异", "进化", "生态", "植物激素", "人体生理", "免疫", "微生物", "生物技术"],
};
/** 知识点薄弱度模板(每学科 10 知识点,雷达图用) */
const KP_TEMPLATES: Record<string, string[]> = {
: ["主旨概括", "细节理解", "推理判断", "词义猜测", "论证分析", "情感把握", "手法鉴赏", "结构梳理", "语言品味", "文化常识"],
: ["函数定义域", "单调性", "奇偶性", "导数运算", "极值最值", "数列求和", "向量运算", "概率计算", "几何证明", "参数方程"],
: ["主旨题", "细节题", "推理题", "词义题", "作者态度", "完形填空", "语法结构", "词汇辨析", "句型转换", "书面表达"],
: ["受力分析", "运动方程", "能量守恒", "动量守恒", "电场", "磁场", "电磁感应", "光学成像", "热学定律", "原子结构"],
: ["化学键类型", "反应速率", "化学平衡", "电离平衡", "水解平衡", "氧化剂", "还原剂", "元素周期", "官能团", "实验操作"],
: ["细胞结构", "光合作用", "细胞呼吸", "有丝分裂", "减数分裂", "遗传规律", "基因突变", "生态系统", "激素调节", "免疫机制"],
};
/** 班级对比 */
export function getClassErrorComparison(
subject: string,
): ErrorBookClassComparison[] {
return CLASSES.map((c) => {
// 学科偏移:理科偏多错题
const subjOffset = subject === "数学" || subject === "物理" ? 20 : 0;
const subjOffset2 = subject === "语文" || subject === "英语" ? -10 : 0;
return {
classId: c.id,
className: c.name,
errorCount: c.base + subjOffset + subjOffset2,
};
});
}
/** 章节薄弱度10 章节) */
export function getChapterWeakness(
subject: string,
): ErrorBookChapterWeakness[] {
const chapters = CHAPTER_TEMPLATES[subject] ?? CHAPTER_TEMPLATES["数学"]!;
return chapters.map((ch, i) => ({
chapter: ch,
errorCount: 20 + i * 5 + ((subject.length + i) % 7) * 3,
}));
}
/** 知识点薄弱度10 知识点,雷达图用) */
export function getKpWeakness(subject: string): ErrorBookKpWeakness[] {
const kps = KP_TEMPLATES[subject] ?? KP_TEMPLATES["数学"]!;
return kps.map((kp, i) => ({
knowledgePoint: kp,
errorRate: Math.max(20, Math.min(85, 30 + i * 4 + ((subject.length + i) % 5) * 3)),
}));
}
/** 学生错题分组(前 5 学生) */
export function getStudentGroups(
subject: string,
): ErrorBookStudentGroup[] {
const students = [
{ id: "stu-001", name: "学生01", base: 28 },
{ id: "stu-002", name: "学生02", base: 25 },
{ id: "stu-003", name: "学生03", base: 22 },
{ id: "stu-004", name: "学生04", base: 18 },
{ id: "stu-005", name: "学生05", base: 15 },
];
return students.map((s) => {
const subjOffset = subject === "数学" ? 5 : 0;
return {
studentId: s.id,
studentName: s.name,
errorCount: s.base + subjOffset,
topErrors: [
{
questionId: `q-${s.id}-1`,
content: `${subject}高频错题示例:${subject}基础知识应用题(题干较长时显示前 100 字)`,
errorRate: 0.75,
},
{
questionId: `q-${s.id}-2`,
content: `${subject}中档题:${subject}综合题分析题(含图形或长文本描述)`,
errorRate: 0.62,
},
{
questionId: `q-${s.id}-3`,
content: `${subject}概念辨析题:${subject}易错概念对比题`,
errorRate: 0.55,
},
],
};
});
}
/** Top10 高频错题 */
export function getTopQuestions(subject: string): ErrorBookTopQuestion[] {
const items: ErrorBookTopQuestion[] = [];
for (let i = 1; i <= 10; i++) {
items.push({
questionId: `q-top-${subject}-${i}`,
content: `${subject}·高频】第 ${i} 题:${subject}综合应用题示例(题干内容较长,此处显示前 100 字以便预览,完整题干在详情页展示)`,
errorRate: 0.85 - i * 0.05,
subject,
});
}
return items;
}
/** 完整错题本(聚合查询) */
export function getErrorBook(
subject: string,
classId?: string | null,
): ErrorBook {
const classComparison = getClassErrorComparison(subject);
const targetClass = classId
? classComparison.find((c) => c.classId === classId)
: null;
const totalErrors = targetClass
? targetClass.errorCount
: classComparison.reduce((acc, c) => acc + c.errorCount, 0);
const summary: ErrorBookSummary = {
totalErrors,
highFreqErrors: Math.floor(totalErrors * 0.15),
weakKpCount: 6,
avgErrorRate: 0.42,
trendUp: subject === "数学" || subject === "物理",
};
return {
subject,
classId: classId ?? "",
summary,
classComparison,
chapterWeakness: getChapterWeakness(subject),
kpWeakness: getKpWeakness(subject),
studentGroups: getStudentGroups(subject),
topQuestions: getTopQuestions(subject),
};
}

View File

@@ -0,0 +1,353 @@
/**
* Mock 考试分析数据core-edu 域P7 扩展)
*
* 维护者ai13teacher-portal
*
* 覆盖:
* - 5 场考试 × 30 学生 × 20 题
* - 分数分布、及格率、每题正确率、知识点掌握度、班级对比、历次趋势、学生排名
*/
import type {
ExamAnalytics,
ExamAnalyticsSummary,
ExamScoreBand,
ExamQuestionAccuracy,
ExamKpMastery,
ExamClassComparison,
ExamHistoryTrend,
ExamStudentRank,
ExamBuildStructure,
ExamBuildNode,
} from "@/lib/graphql-p7-exams";
import type { QuestionItem } from "@/lib/graphql-p7-exams";
import type { ExamDetail } from "@/lib/graphql";
import { MOCK_QUESTIONS } from "./questions";
import { EXTENDED_CLASSES } from "./grades-extended";
/** 5 场考试定义 */
interface ExamDef {
examId: string;
title: string;
classId: string;
className: string;
examDate: string;
totalScore: number;
}
const NOW = Date.now();
const DAY = 24 * 60 * 60 * 1000;
const EXAM_DEFS: ExamDef[] = [
{
examId: "exam-ana-001",
title: "2026 春季期中考试",
classId: EXTENDED_CLASSES[0]?.id ?? "c-001",
className: EXTENDED_CLASSES[0]?.name ?? "高三(1)班",
examDate: new Date(NOW - 7 * DAY).toISOString(),
totalScore: 150,
},
{
examId: "exam-ana-002",
title: "单元测试 - 函数与导数",
classId: EXTENDED_CLASSES[0]?.id ?? "c-001",
className: EXTENDED_CLASSES[0]?.name ?? "高三(1)班",
examDate: new Date(NOW - 14 * DAY).toISOString(),
totalScore: 100,
},
{
examId: "exam-ana-003",
title: "月考 - 数学综合",
classId: EXTENDED_CLASSES[1]?.id ?? "c-002",
className: EXTENDED_CLASSES[1]?.name ?? "高三(2)班",
examDate: new Date(NOW - 21 * DAY).toISOString(),
totalScore: 120,
},
{
examId: "exam-ana-004",
title: "周测 - 解析几何",
classId: EXTENDED_CLASSES[2]?.id ?? "c-003",
className: EXTENDED_CLASSES[2]?.name ?? "高三(3)班",
examDate: new Date(NOW - 3 * DAY).toISOString(),
totalScore: 80,
},
{
examId: "exam-ana-005",
title: "阶段测验 - 概率统计",
classId: EXTENDED_CLASSES[3]?.id ?? "c-004",
className: EXTENDED_CLASSES[3]?.name ?? "高三(4)班",
examDate: new Date(NOW - 1 * DAY).toISOString(),
totalScore: 100,
},
];
/** 确定性伪随机(与 grades-extended 一致) */
function seededRandom(seed: number): number {
const x = Math.sin(seed) * 10000;
return x - Math.floor(x);
}
function strToSeed(s: string): number {
let h = 0;
for (let i = 0; i < s.length; i++) {
h = (h * 31 + s.charCodeAt(i)) | 0;
}
return Math.abs(h) + 1;
}
/** 计算等级 */
function scoreToLevel(score: number, total: number): ExamStudentRank["level"] {
const ratio = total > 0 ? score / total : 0;
if (ratio >= 0.9) return "A";
if (ratio >= 0.8) return "B";
if (ratio >= 0.7) return "C";
if (ratio >= 0.6) return "D";
return "E";
}
/** 生成 30 个学生成绩(按考试 ID 稳定) */
function generateStudentScores(
examId: string,
totalScore: number,
): Array<{
studentId: string;
studentName: string;
studentNo: string;
totalScore: number;
}> {
const base = totalScore * 0.7; // 均分约 70%
const result: Array<{
studentId: string;
studentName: string;
studentNo: string;
totalScore: number;
}> = [];
for (let i = 1; i <= 30; i++) {
const seed = strToSeed(`${examId}-stu-${i}`);
const delta = Math.round((seededRandom(seed) - 0.4) * totalScore * 0.4);
const score = Math.max(
0,
Math.min(totalScore, Math.round(base + delta)),
);
result.push({
studentId: `stu-${examId.slice(-3)}-${String(i).padStart(3, "0")}`,
studentName: `学生${String(i).padStart(2, "0")}`,
studentNo: `2026${String(i).padStart(3, "0")}`,
totalScore: score,
});
}
result.sort((a, b) => b.totalScore - a.totalScore);
return result;
}
/** 生成 5 段分数分布 */
function generateDistribution(
scores: number[],
total: number,
): ExamScoreBand[] {
const bands: ExamScoreBand[] = [
{ label: "0-59", min: 0, max: 59, count: 0 },
{ label: "60-69", min: 60, max: 69, count: 0 },
{ label: "70-79", min: 70, max: 79, count: 0 },
{ label: "80-89", min: 80, max: 89, count: 0 },
{ label: "90-100", min: 90, max: 100, count: 0 },
];
// 按 0-100 百分比归一化total != 100 时换算)
scores.forEach((s) => {
const pct = total > 0 ? (s / total) * 100 : 0;
if (pct < 60) bands[0]!.count += 1;
else if (pct < 70) bands[1]!.count += 1;
else if (pct < 80) bands[2]!.count += 1;
else if (pct < 90) bands[3]!.count += 1;
else bands[4]!.count += 1;
});
return bands;
}
/** 选取前 20 题(不足 20 则循环补齐) */
function pick20Questions(): QuestionItem[] {
const items: QuestionItem[] = [];
for (let i = 0; i < 20; i++) {
const q = MOCK_QUESTIONS[i % MOCK_QUESTIONS.length];
if (q) items.push(q);
}
return items;
}
const EXAM_QUESTIONS = pick20Questions();
/** 生成每题正确率 */
function generateQuestionAccuracy(
examId: string,
): ExamQuestionAccuracy[] {
return EXAM_QUESTIONS.map((q, i) => {
const seed = strToSeed(`${examId}-qa-${i}`);
const correctRate = Math.round(40 + seededRandom(seed) * 55); // 40-95
const avgScore = Math.round((q.score * correctRate) / 100);
return {
questionId: q.questionId,
questionTitle: q.content.slice(0, 40),
order: i + 1,
correctRate,
avgScore,
maxScore: q.score,
};
});
}
/** 5 个知识点(用于雷达图) */
const KP_NAMES = [
"函数与导数",
"数列",
"立体几何",
"解析几何",
"概率统计",
];
/** 生成知识点掌握度 */
function generateKpMastery(examId: string): ExamKpMastery[] {
return KP_NAMES.map((name, i) => {
const seed = strToSeed(`${examId}-kp-${i}`);
const mastery = Math.round(50 + seededRandom(seed) * 45); // 50-95
return { knowledgePoint: name, mastery };
});
}
/** 生成班级对比 */
function generateClassComparison(
targetClassId: string,
avg: number,
): ExamClassComparison[] {
return EXTENDED_CLASSES.map((c) => {
const seed = strToSeed(`cc-${c.id}`);
const delta = Math.round((seededRandom(seed) - 0.5) * 16);
const classAvg = Math.max(0, Math.round(avg + delta));
return {
classId: c.id,
className: c.name,
avg: classAvg,
significant: c.id === targetClassId ? false : Math.abs(delta) >= 7,
};
});
}
/** 生成历次考试趋势 */
function generateHistoryTrend(): ExamHistoryTrend[] {
return EXAM_DEFS.map((e) => {
const seed = strToSeed(`ht-${e.examId}`);
const avg = Math.round(60 + seededRandom(seed) * 25); // 60-85
return {
examTitle: e.title,
examDate: e.examDate,
avg,
};
});
}
/** 生成考试分析完整数据 */
export function generateExamAnalytics(examId: string): ExamAnalytics | null {
const def = EXAM_DEFS.find((e) => e.examId === examId);
if (!def) return null;
const studentScores = generateStudentScores(examId, def.totalScore);
const scores = studentScores.map((s) => s.totalScore);
const sum = scores.reduce((acc, s) => acc + s, 0);
const avg = Math.round((sum / scores.length) * 10) / 10;
const max = Math.max(...scores);
const min = Math.min(...scores);
const passCount = scores.filter(
(s) => s / def.totalScore >= 0.6,
).length;
const passRate = Math.round((passCount / scores.length) * 1000) / 10;
const summary: ExamAnalyticsSummary = {
expectedCount: 30,
attendedCount: 30,
avgScore: avg,
maxScore: max,
minScore: min,
passRate,
};
const distribution = generateDistribution(scores, def.totalScore);
const questionAccuracy = generateQuestionAccuracy(examId);
const knowledgeMastery = generateKpMastery(examId);
const classComparison = generateClassComparison(def.classId, avg);
const historyTrend = generateHistoryTrend();
const rankings: ExamStudentRank[] = studentScores.map((s, i) => ({
rank: i + 1,
studentId: s.studentId,
studentName: s.studentName,
studentNo: s.studentNo,
totalScore: s.totalScore,
level: scoreToLevel(s.totalScore, def.totalScore),
}));
return {
examId,
examTitle: def.title,
summary,
distribution,
questionAccuracy,
knowledgeMastery,
classComparison,
historyTrend,
rankings,
};
}
/**
* 生成试卷结构(组卷页用)
*
* - 从题库取前 20 题作为候选分页
* - 已选题目:前 4 题(默认排序)
*/
export function generateExamBuildStructure(
examId: string,
examDetail: ExamDetail | null,
candidatePage: number,
candidatePageSize: number,
): ExamBuildStructure | null {
// 默认 4 题已选,分数按 examDetail.totalScore 平均分摊
const selectedCount = 4;
const totalScore = examDetail?.totalScore ?? 100;
const passScore = Math.round(totalScore * 0.6);
const duration = examDetail?.duration ?? 120;
const title = examDetail?.title ?? "未命名考试";
// 取前 4 道题作为已选
const picked = EXAM_QUESTIONS.slice(0, selectedCount);
const perScore = Math.floor(totalScore / selectedCount);
const remainder = totalScore - perScore * selectedCount;
const selected: ExamBuildNode[] = picked.map((q, i) => ({
questionId: q.questionId,
score: perScore + (i === 0 ? remainder : 0),
sortOrder: i + 1,
content: q.content,
type: q.type,
difficulty: q.difficulty,
}));
// 题库候选分页(前 20 题)
const start = (candidatePage - 1) * candidatePageSize;
const candidatesItems = EXAM_QUESTIONS.slice(start, start + candidatePageSize);
const candidates = {
items: candidatesItems,
total: EXAM_QUESTIONS.length,
page: candidatePage,
pageSize: candidatePageSize,
};
return {
examId,
title,
totalScore,
passScore,
duration,
selected,
candidates,
};
}
/** 所有 mock 考试定义(外部可消费) */
export const MOCK_EXAM_ANALYTICS_DEFS = EXAM_DEFS;

View File

@@ -4,7 +4,7 @@
* 维护者ai13teacher-portal
*/
import type { ExamItem } from "@/lib/graphql";
import type { ExamItem, ExamDetail, ExamQuestion, CreateExamInput } from "@/lib/graphql";
export function generateMockExams(classId: string): ExamItem[] {
const now = Date.now();
@@ -41,3 +41,70 @@ export function generateMockExams(classId: string): ExamItem[] {
},
];
}
/**
* 从 examId 反查 classIdmock 数据 ID 规约:${classId}-exam-XXX
* 兜底:未匹配时返回 "mock-class"。
*/
function extractClassIdFromExamId(examId: string): string {
const match = examId.match(/^(.+)-exam-\d+$/);
return match?.[1] ?? "mock-class";
}
/** 生成 mock 题目列表(固定 4 题) */
function generateMockQuestions(examId: string): ExamQuestion[] {
const defs: Array<{
type: ExamQuestion["type"];
title: string;
score: number;
}> = [
{ type: "SINGLE_CHOICE", title: "单项选择:函数的定义域", score: 20 },
{ type: "MULTIPLE_CHOICE", title: "多项选择:导数的几何意义", score: 20 },
{ type: "SHORT_ANSWER", title: "简答:求解方程的实根", score: 30 },
{ type: "ESSAY", title: "论述:函数与不等式的综合应用", score: 50 },
];
return defs.map((d, i) => ({
id: `${examId}-q-${String(i + 1).padStart(3, "0")}`,
examId,
title: d.title,
type: d.type,
score: d.score,
order: i + 1,
}));
}
/** 按考试 ID 查询详情含题目列表MSW mock */
export function findMockExamDetail(examId: string): ExamDetail {
const classId = extractClassIdFromExamId(examId);
const all = generateMockExams(classId);
const matched = all.find((e) => e.id === examId);
const base = matched ?? {
id: examId,
classId,
title: "考试详情mock 兜底)",
description: "Mock 数据兜底:未匹配到列表中的考试",
examDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
duration: 120,
totalScore: 150,
status: "DRAFT" as const,
};
return {
...base,
questions: generateMockQuestions(examId),
};
}
/** 创建新考试:基于 input 生成 ExamItem含随机 ID */
export function createMockExam(input: CreateExamInput): ExamItem {
const randomId = Math.random().toString(36).slice(2, 10);
return {
id: `${input.classId}-exam-${randomId}`,
classId: input.classId,
title: input.title,
description: input.description ?? null,
examDate: input.examDate,
duration: input.duration,
totalScore: input.totalScore,
status: "DRAFT",
};
}

View File

@@ -0,0 +1,403 @@
/**
* Mock 扩展成绩数据P7成绩完整子模块
*
* 维护者ai13teacher-portal
*
* 覆盖:
* - 班级成绩统计5 个班级 × 6 学科)
* - 学生成绩趋势6 个月3 个学生)
* - 学科对比、班级对比、知识点掌握度
* - 报告卡数据5 学生 × 4 学期 × 6 学科)
* - 成绩录入列表生成器
*/
import type {
GradeEntryItem,
GradeStats,
GradeRanking,
GradeAnalytics,
GradeTrendPoint,
GradeDistributionBand,
SubjectComparisonItem,
ClassComparisonItem,
KnowledgeMasteryItem,
ReportCard,
ReportCardSubject,
SaveGradeEntriesInput,
SaveGradeEntriesResult,
} from "@/lib/graphql-p7-grades";
/** 6 个学科 */
export const SUBJECTS = [
"语文",
"数学",
"英语",
"物理",
"化学",
"生物",
] as const;
/** 5 个班级(含 classes.ts 现有 3 个 + 扩展 2 个) */
export const EXTENDED_CLASSES = [
{ id: "550e8400-e29b-41d4-a716-446655440010", name: "高三(1)班", gradeId: "grade-12" },
{ id: "550e8400-e29b-41d4-a716-446655440011", name: "高三(2)班", gradeId: "grade-12" },
{ id: "550e8400-e29b-41d4-a716-446655440012", name: "高三(3)班", gradeId: "grade-12" },
{ id: "550e8400-e29b-41d4-a716-446655440013", name: "高三(4)班", gradeId: "grade-12" },
{ id: "550e8400-e29b-41d4-a716-446655440014", name: "高三(5)班", gradeId: "grade-12" },
] as const;
/** 6 个月标签(趋势 X 轴) */
const MONTHS = ["1月", "2月", "3月", "4月", "5月", "6月"] as const;
/** 5 个知识点 */
const KNOWLEDGE_POINTS = [
"函数与导数",
"数列",
"立体几何",
"解析几何",
"概率统计",
] as const;
/** 班级平均分基准(用于生成稳定 mock 数据) */
const CLASS_AVG_BASE: Record<string, number> = {
"550e8400-e29b-41d4-a716-446655440010": 78,
"550e8400-e29b-41d4-a716-446655440011": 82,
"550e8400-e29b-41d4-a716-446655440012": 75,
"550e8400-e29b-41d4-a716-446655440013": 80,
"550e8400-e29b-41d4-a716-446655440014": 85,
};
/** 学科难度系数(用于在班级平均基础上调整学科均分) */
const SUBJECT_DIFFICULTY: Record<string, number> = {
语文: 0,
: -5,
英语: 2,
: -8,
: -3,
生物: 3,
};
/** 确定性伪随机(基于 seed保证同输入同输出 */
function seededRandom(seed: number): number {
const x = Math.sin(seed) * 10000;
return x - Math.floor(x);
}
/** 基于字符串生成 seed */
function strToSeed(s: string): number {
let h = 0;
for (let i = 0; i < s.length; i++) {
h = (h * 31 + s.charCodeAt(i)) | 0;
}
return Math.abs(h) + 1;
}
/** 计算等级 */
function scoreToLevel(score: number): GradeRanking["level"] {
if (score >= 90) return "A";
if (score >= 80) return "B";
if (score >= 70) return "C";
if (score >= 60) return "D";
return "E";
}
/** 计算中位数 */
function median(values: number[]): number {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 !== 0
? (sorted[mid] ?? 0)
: ((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2;
}
/** 计算标准差 */
function stdDev(values: number[]): number {
if (values.length === 0) return 0;
const avg = values.reduce((acc, v) => acc + v, 0) / values.length;
const variance =
values.reduce((acc, v) => acc + (v - avg) ** 2, 0) / values.length;
return Math.round(Math.sqrt(variance) * 10) / 10;
}
/** 生成班级学生成绩排名30 名学生) */
function generateClassRankings(
classId: string,
subject: string,
): Array<{ studentId: string; studentName: string; studentNo: string; totalScore: number }> {
const base = CLASS_AVG_BASE[classId] ?? 78;
const adj = SUBJECT_DIFFICULTY[subject] ?? 0;
const target = base + adj;
const students: Array<{
studentId: string;
studentName: string;
studentNo: string;
totalScore: number;
}> = [];
for (let i = 1; i <= 30; i++) {
const seed = strToSeed(`${classId}-${subject}-${i}`);
// 围绕 target 上下波动 ±15
const delta = Math.round((seededRandom(seed) - 0.5) * 30);
const score = Math.max(0, Math.min(100, target + delta));
const studentNo = `2026${String(i).padStart(3, "0")}`;
students.push({
studentId: `stu-${classId.slice(-4)}-${String(i).padStart(3, "0")}`,
studentName: `学生${String(i).padStart(2, "0")}`,
studentNo,
totalScore: score,
});
}
students.sort((a, b) => b.totalScore - a.totalScore);
return students;
}
/** 按班级 + 学科生成成绩统计 */
export function generateGradeStats(
classId: string,
subject: string,
): GradeStats {
const cls = EXTENDED_CLASSES.find((c) => c.id === classId);
const className = cls?.name ?? "未知班级";
const ranked = generateClassRankings(classId, subject);
const scores = ranked.map((s) => s.totalScore);
const sum = scores.reduce((acc, s) => acc + s, 0);
const avg = Math.round((sum / scores.length) * 10) / 10;
const passCount = scores.filter((s) => s >= 60).length;
const rankings: GradeRanking[] = ranked.map((s, i) => ({
rank: i + 1,
studentId: s.studentId,
studentName: s.studentName,
studentNo: s.studentNo,
totalScore: s.totalScore,
level: scoreToLevel(s.totalScore),
}));
return {
classId,
className,
subject,
avg,
median: Math.round(median(scores) * 10) / 10,
max: Math.max(...scores),
min: Math.min(...scores),
passRate: Math.round((passCount / scores.length) * 1000) / 10,
stdDev: stdDev(scores),
totalCount: scores.length,
rankings,
};
}
/** 生成成绩录入列表(按 examId + classId */
export function generateGradeEntries(
examId: string,
classId: string,
): GradeEntryItem[] {
const studentCount = 30;
const base = CLASS_AVG_BASE[classId] ?? 78;
const items: GradeEntryItem[] = [];
for (let i = 1; i <= studentCount; i++) {
const seed = strToSeed(`${examId}-${classId}-${i}`);
const delta = Math.round((seededRandom(seed) - 0.5) * 30);
const score = Math.max(0, Math.min(100, base + delta));
const feedback =
score < 60 ? "需加强基础" : score >= 90 ? "表现优秀" : null;
items.push({
studentId: `stu-${classId.slice(-4)}-${String(i).padStart(3, "0")}`,
studentName: `学生${String(i).padStart(2, "0")}`,
studentNo: `2026${String(i).padStart(3, "0")}`,
score: i <= 20 ? score : null, // 后 10 名未录入
feedback: i <= 20 ? feedback : null,
});
}
return items;
}
/** 批量保存成绩录入mock返回 savedCount */
export function saveGradeEntries(
input: SaveGradeEntriesInput,
): SaveGradeEntriesResult {
return {
examId: input.examId,
classId: input.classId,
savedCount: input.entries.length,
};
}
/** 生成趋势数据6 个月,含 avg/max/min */
function generateTrend(classId: string): GradeTrendPoint[] {
const base = CLASS_AVG_BASE[classId] ?? 78;
return MONTHS.map((m, i) => {
const seed = strToSeed(`${classId}-trend-${i}`);
const delta = Math.round((seededRandom(seed) - 0.5) * 6);
const avg = Math.max(0, Math.min(100, base + delta));
return {
month: m,
avg,
max: Math.min(100, avg + 12),
min: Math.max(0, avg - 15),
};
});
}
/** 生成分数分布5 段) */
function generateDistribution(classId: string): GradeDistributionBand[] {
const base = CLASS_AVG_BASE[classId] ?? 78;
const bands = [
{ label: "0-59", min: 0, max: 59 },
{ label: "60-69", min: 60, max: 69 },
{ label: "70-79", min: 70, max: 79 },
{ label: "80-89", min: 80, max: 89 },
{ label: "90-100", min: 90, max: 100 },
];
// 班级越强,高分段越多
const skew = (base - 75) / 10;
return bands.map((b, i) => {
const seed = strToSeed(`${classId}-dist-${i}`);
const baseCount = [4, 6, 9, 8, 3][i] ?? 5;
const adj = Math.round(skew * (i - 2) * 1.5);
const count = Math.max(0, baseCount + adj + Math.round((seededRandom(seed) - 0.5) * 2));
return { ...b, count };
});
}
/** 生成学科对比 */
function generateSubjectComparison(classId: string): SubjectComparisonItem[] {
const base = CLASS_AVG_BASE[classId] ?? 78;
return SUBJECTS.map((s) => {
const adj = SUBJECT_DIFFICULTY[s] ?? 0;
const seed = strToSeed(`${classId}-subj-${s}`);
const noise = Math.round((seededRandom(seed) - 0.5) * 4);
return { subject: s, avg: Math.max(0, Math.min(100, base + adj + noise)) };
});
}
/** 生成班级对比(含显著性) */
function generateClassComparison(classId: string): ClassComparisonItem[] {
const targetAvg = CLASS_AVG_BASE[classId] ?? 78;
return EXTENDED_CLASSES.map((c) => {
const clsAvg = CLASS_AVG_BASE[c.id] ?? 78;
const diff = Math.abs(clsAvg - targetAvg);
// 差距 ≥ 6 视为显著
const significant = diff >= 6;
return {
classId: c.id,
className: c.name,
avg: clsAvg,
significant,
};
});
}
/** 生成知识点掌握度 */
function generateKnowledgeMastery(classId: string): KnowledgeMasteryItem[] {
const base = CLASS_AVG_BASE[classId] ?? 78;
return KNOWLEDGE_POINTS.map((kp) => {
const seed = strToSeed(`${classId}-kp-${kp}`);
const delta = Math.round((seededRandom(seed) - 0.5) * 20);
return {
knowledgePoint: kp,
mastery: Math.max(30, Math.min(98, base + delta)),
};
});
}
/** 按班级生成成绩分析聚合 */
export function generateGradeAnalytics(classId: string): GradeAnalytics {
const cls = EXTENDED_CLASSES.find((c) => c.id === classId);
return {
classId,
className: cls?.name ?? "未知班级",
trend: generateTrend(classId),
distribution: generateDistribution(classId),
subjectComparison: generateSubjectComparison(classId),
classComparison: generateClassComparison(classId),
knowledgeMastery: generateKnowledgeMastery(classId),
};
}
// ============ 报告卡数据5 学生 × 4 学期 × 6 学科) ============
/** 5 名学生(默认从高三(1)班抽取) */
const REPORT_STUDENTS = [
{ id: "stu-0010-001", name: "张明", no: "2026001" },
{ id: "stu-0010-002", name: "李华", no: "2026002" },
{ id: "stu-0010-003", name: "王芳", no: "2026003" },
{ id: "stu-0010-004", name: "赵磊", no: "2026004" },
{ id: "stu-0010-005", name: "陈静", no: "2026005" },
] as const;
/** 4 个学期 */
const SEMESTERS = [
{ year: "2025-2026", semester: "1" },
{ year: "2025-2026", semester: "2" },
{ year: "2026-2027", semester: "1" },
{ year: "2026-2027", semester: "2" },
] as const;
const TEACHER_NAMES = ["王老师", "刘老师", "陈老师", "杨老师", "周老师", "吴老师"];
const COMMENTS = [
"学习态度端正,基础扎实,望继续保持。",
"本学期进步明显,望戒骄戒躁,再接再厉。",
"理科较强,语文需加强阅读积累。",
"全面发展,各科均衡,表现优异。",
"数理偏弱,建议加强逻辑训练。",
];
/** 按学生 ID + 学年 + 学期生成报告卡 */
export function generateReportCard(
studentId: string,
academicYear?: string,
semester?: string,
): ReportCard | null {
const stu = REPORT_STUDENTS.find((s) => s.id === studentId);
if (!stu) {
// 未匹配:用兜底学生数据
return null;
}
const term = SEMESTERS.find(
(t) =>
(academicYear ? t.year === academicYear : true) &&
(semester ? t.semester === semester : true),
) ?? SEMESTERS[0];
if (!term) return null;
const baseSeed = strToSeed(`${stu.id}-${term.year}-${term.semester}`);
const baseScore = 70 + Math.round(seededRandom(baseSeed) * 20);
const subjects: ReportCardSubject[] = SUBJECTS.map((s, i) => {
const seed = strToSeed(`${stu.id}-${s}-${term.year}`);
const delta = Math.round((seededRandom(seed) - 0.5) * 16);
const score = Math.max(0, Math.min(100, baseScore + delta));
return {
subject: s,
score,
rank: 1 + ((i + Math.floor(seededRandom(seed) * 10)) % 30),
teacherName: TEACHER_NAMES[i] ?? "未知",
};
});
const totalScore = subjects.reduce((acc, s) => acc + s.score, 0);
const commentIdx = Math.floor(seededRandom(baseSeed) * COMMENTS.length);
return {
studentId: stu.id,
studentName: stu.name,
studentNo: stu.no,
className: "高三(1)班",
academicYear: term.year,
semester: term.semester,
subjects,
totalScore,
classRank: 1 + (Math.floor(seededRandom(baseSeed + 1) * 30)),
classSize: 32,
gradeRank: 1 + (Math.floor(seededRandom(baseSeed + 2) * 150)),
gradeSize: 150,
teacherComment: COMMENTS[commentIdx] ?? COMMENTS[0] ?? "",
};
}
/** 报告卡可用学生列表 */
export const REPORT_CARD_STUDENTS = REPORT_STUDENTS;
/** 报告卡可用学期列表 */
export const REPORT_CARD_SEMESTERS = SEMESTERS;

View File

@@ -4,7 +4,7 @@
* 维护者ai13teacher-portal
*/
import type { GradeItem } from "@/lib/graphql";
import type { GradeItem, RecordGradeInput } from "@/lib/graphql";
export function generateMockGrades(examId: string): GradeItem[] {
const grades: GradeItem[] = [];
@@ -24,3 +24,18 @@ export function generateMockGrades(examId: string): GradeItem[] {
}
return grades;
}
/** 录入成绩:基于 input 生成 GradeItem含随机 ID + 学生名兜底) */
export function recordMockGrade(input: RecordGradeInput): GradeItem {
const randomId = Math.random().toString(36).slice(2, 10);
const idx = Math.floor(Math.random() * 30) + 1;
return {
id: `grade-${randomId}`,
studentId: input.studentId,
studentName: `学生${String(idx).padStart(2, "0")}`,
examId: input.examId ?? null,
homeworkId: input.homeworkId ?? null,
score: input.score,
feedback: input.feedback ?? null,
};
}

View File

@@ -0,0 +1,344 @@
/**
* Mock 作业提交数据P7作业批改链路
*
* 维护者ai13teacher-portal
*
* 覆盖:
* - 10 个作业 × 30 学生提交记录(统计维度)
* - 单份提交含题目作答 + AI 评分建议
* - 按 submissionId 查找详情(含上一份/下一份导航)
*/
import type {
HomeworkSubmissionStat,
HomeworkSubmissionDetail,
SubmissionAnswer,
GradeSubmissionInput,
GradeSubmissionResult,
} from "@/lib/graphql-p7-grades";
import { EXTENDED_CLASSES } from "./grades-extended";
/** 10 个作业定义(跨 5 个班级) */
const HOMEWORK_DEFS: Array<{
homeworkId: string;
title: string;
classId: string;
className: string;
status: HomeworkSubmissionStat["status"];
totalCount: number;
submittedCount: number;
gradedCount: number;
avgScore: number | null;
}> = [
{
homeworkId: "550e8400-e29b-41d4-a716-446655440010-hw-001",
title: "函数练习题",
classId: EXTENDED_CLASSES[0]?.id ?? "",
className: EXTENDED_CLASSES[0]?.name ?? "",
status: "published",
totalCount: 32,
submittedCount: 28,
gradedCount: 20,
avgScore: 82,
},
{
homeworkId: "550e8400-e29b-41d4-a716-446655440010-hw-002",
title: "导数应用题",
classId: EXTENDED_CLASSES[0]?.id ?? "",
className: EXTENDED_CLASSES[0]?.name ?? "",
status: "graded",
totalCount: 32,
submittedCount: 30,
gradedCount: 30,
avgScore: 78,
},
{
homeworkId: "550e8400-e29b-41d4-a716-446655440011-hw-001",
title: "数列综合练习",
classId: EXTENDED_CLASSES[1]?.id ?? "",
className: EXTENDED_CLASSES[1]?.name ?? "",
status: "published",
totalCount: 30,
submittedCount: 25,
gradedCount: 10,
avgScore: 85,
},
{
homeworkId: "550e8400-e29b-41d4-a716-446655440011-hw-002",
title: "立体几何作业",
classId: EXTENDED_CLASSES[1]?.id ?? "",
className: EXTENDED_CLASSES[1]?.name ?? "",
status: "published",
totalCount: 30,
submittedCount: 22,
gradedCount: 5,
avgScore: null,
},
{
homeworkId: "550e8400-e29b-41d4-a716-446655440012-hw-001",
title: "解析几何专题",
classId: EXTENDED_CLASSES[2]?.id ?? "",
className: EXTENDED_CLASSES[2]?.name ?? "",
status: "draft",
totalCount: 28,
submittedCount: 0,
gradedCount: 0,
avgScore: null,
},
{
homeworkId: "550e8400-e29b-41d4-a716-446655440012-hw-002",
title: "概率统计练习",
classId: EXTENDED_CLASSES[2]?.id ?? "",
className: EXTENDED_CLASSES[2]?.name ?? "",
status: "published",
totalCount: 28,
submittedCount: 26,
gradedCount: 26,
avgScore: 80,
},
{
homeworkId: "550e8400-e29b-41d4-a716-446655440013-hw-001",
title: "三角函数复习",
classId: EXTENDED_CLASSES[3]?.id ?? "",
className: EXTENDED_CLASSES[3]?.name ?? "",
status: "graded",
totalCount: 30,
submittedCount: 30,
gradedCount: 30,
avgScore: 88,
},
{
homeworkId: "550e8400-e29b-41d4-a716-446655440013-hw-002",
title: "向量与复数",
classId: EXTENDED_CLASSES[3]?.id ?? "",
className: EXTENDED_CLASSES[3]?.name ?? "",
status: "published",
totalCount: 30,
submittedCount: 18,
gradedCount: 8,
avgScore: 75,
},
{
homeworkId: "550e8400-e29b-41d4-a716-446655440014-hw-001",
title: "不等式练习",
classId: EXTENDED_CLASSES[4]?.id ?? "",
className: EXTENDED_CLASSES[4]?.name ?? "",
status: "published",
totalCount: 32,
submittedCount: 30,
gradedCount: 12,
avgScore: 90,
},
{
homeworkId: "550e8400-e29b-41d4-a716-446655440014-hw-002",
title: "圆锥曲线综合",
classId: EXTENDED_CLASSES[4]?.id ?? "",
className: EXTENDED_CLASSES[4]?.name ?? "",
status: "published",
totalCount: 32,
submittedCount: 27,
gradedCount: 3,
avgScore: null,
},
];
/** 按作业维度统计提交(可按 classId / status 过滤) */
export function generateHomeworkSubmissionStats(
classId?: string,
status?: string,
): HomeworkSubmissionStat[] {
const now = Date.now();
return HOMEWORK_DEFS.filter((h) => {
if (classId && h.classId !== classId) return false;
if (status && h.status !== status) return false;
return true;
}).map((h) => ({
homeworkId: h.homeworkId,
title: h.title,
classId: h.classId,
className: h.className,
status: h.status,
dueDate: new Date(now + 3 * 24 * 60 * 60 * 1000).toISOString(),
totalCount: h.totalCount,
submittedCount: h.submittedCount,
gradedCount: h.gradedCount,
avgScore: h.avgScore,
}));
}
/** 4 道题目模板(用于生成作答详情) */
const QUESTION_DEFS: Array<{
title: string;
type: SubmissionAnswer["questionType"];
maxScore: number;
correctAnswer: string;
}> = [
{
title: "求函数 f(x) = x² - 2x + 1 在 [0, 2] 上的极值",
type: "SHORT_ANSWER",
maxScore: 25,
correctAnswer: "极小值 f(1) = 0极大值 f(0) = f(2) = 1",
},
{
title: "选择题:函数 y = ln(x) 的导数是",
type: "SINGLE_CHOICE",
maxScore: 20,
correctAnswer: "B. y = 1/x",
},
{
title: "计算定积分 ∫₀¹ (2x + 1) dx",
type: "SHORT_ANSWER",
maxScore: 25,
correctAnswer: "∫₀¹ (2x + 1) dx = [x² + x]₀¹ = 1 + 1 = 2",
},
{
title: "论述:导数在实际问题中的应用",
type: "ESSAY",
maxScore: 30,
correctAnswer: "(参考答案)导数可用于求极值、最值、变化率分析等……",
},
];
const STUDENT_ANSWERS = [
"f(1) = 0 为极小值",
"B",
"积分结果为 2",
"导数可应用于最优化问题、物理学中的瞬时速度等",
];
const AI_SUGGESTIONS = [
"答案正确,步骤清晰。建议补充极值点验证过程。",
"选择题正确。",
"计算正确,但缺少积分上下限代入步骤。",
"论述较为简略,建议补充具体实例说明。",
];
/** 确定性伪随机 */
function seededRandom(seed: number): number {
const x = Math.sin(seed) * 10000;
return x - Math.floor(x);
}
function strToSeed(s: string): number {
let h = 0;
for (let i = 0; i < s.length; i++) {
h = (h * 31 + s.charCodeAt(i)) | 0;
}
return Math.abs(h) + 1;
}
/**
* 按作业 ID 生成所有提交详情列表
*
* 每份提交包含 4 道题作答 + AI 评分建议。
* 同一作业下的提交列表共享 prevSubmissionId / nextSubmissionId 链。
*/
export function generateAssignmentSubmissions(
homeworkId: string,
): HomeworkSubmissionDetail[] {
const def = HOMEWORK_DEFS.find((h) => h.homeworkId === homeworkId);
const totalCount = def?.submittedCount ?? 5;
const now = Date.now();
const list: HomeworkSubmissionDetail[] = [];
for (let i = 1; i <= totalCount; i++) {
const studentId = `stu-${homeworkId.slice(-4)}-${String(i).padStart(3, "0")}`;
const submissionId = `${homeworkId}-sub-${String(i).padStart(3, "0")}`;
const isGraded = def ? i <= def.gradedCount : false;
const answers: SubmissionAnswer[] = QUESTION_DEFS.map((q, qi) => {
const seed = strToSeed(`${submissionId}-${qi}`);
const isCorrect = seededRandom(seed) > 0.3;
const score = isCorrect ? q.maxScore : Math.floor(q.maxScore * 0.5);
return {
questionId: `${submissionId}-q-${String(qi + 1).padStart(3, "0")}`,
questionTitle: q.title,
questionType: q.type,
maxScore: q.maxScore,
studentAnswer: STUDENT_ANSWERS[qi] ?? "(未作答)",
correctAnswer: q.correctAnswer,
score: isGraded ? score : null,
feedback: isGraded ? AI_SUGGESTIONS[qi] ?? null : null,
aiSuggestion: AI_SUGGESTIONS[qi] ?? null,
};
});
const totalScore = isGraded
? answers.reduce((acc, a) => acc + (a.score ?? 0), 0)
: null;
list.push({
submissionId,
homeworkId,
homeworkTitle: def?.title ?? "作业详情",
classId: def?.classId ?? "",
className: def?.className ?? "",
studentId,
studentName: `学生${String(i).padStart(2, "0")}`,
studentNo: `2026${String(i).padStart(3, "0")}`,
status: isGraded ? "GRADED" : "SUBMITTED",
submittedAt: new Date(now - i * 60 * 60 * 1000).toISOString(),
totalScore,
answers,
prevSubmissionId: null,
nextSubmissionId: null,
});
}
// 建立 prev / next 链
list.forEach((item, idx) => {
item.prevSubmissionId = idx > 0 ? (list[idx - 1]?.submissionId ?? null) : null;
item.nextSubmissionId =
idx < list.length - 1 ? (list[idx + 1]?.submissionId ?? null) : null;
});
return list;
}
/** 按 submissionId 查找单份提交详情 */
export function findSubmissionDetail(
submissionId: string,
): HomeworkSubmissionDetail | null {
// submissionId 规约:${homeworkId}-sub-XXX
const match = submissionId.match(/^(.+)-sub-\d+$/);
if (!match) return null;
const homeworkId = match[1] ?? "";
const list = generateAssignmentSubmissions(homeworkId);
return list.find((s) => s.submissionId === submissionId) ?? null;
}
/** 批改单份提交mock返回 GRADED 状态 + totalScore */
export function gradeSubmission(
input: GradeSubmissionInput,
): GradeSubmissionResult {
// 若提供 questionScores汇总否则用 input.score
const totalScore = input.questionScores
? input.questionScores.reduce((acc, q) => acc + q.score, 0)
: input.score;
return {
submissionId: input.submissionId,
status: "GRADED",
totalScore,
feedback: input.feedback ?? null,
};
}
/** AI 批量评分建议mock对每份提交返回 AI 建议分数) */
export function generateAiBatchGrading(
homeworkId: string,
): Array<{ submissionId: string; suggestedScore: number; suggestion: string }> {
const list = generateAssignmentSubmissions(homeworkId);
return list
.filter((s) => s.status === "SUBMITTED")
.map((s) => {
const suggestedScore = s.answers.reduce(
(acc, a) => acc + Math.floor(a.maxScore * 0.8),
0,
);
return {
submissionId: s.submissionId,
suggestedScore,
suggestion: "AI 建议分数基于作答完整度评估,请教师复核。",
};
});
}

View File

@@ -4,7 +4,7 @@
* 维护者ai13teacher-portal
*/
import type { HomeworkItem } from "@/lib/graphql";
import type { HomeworkItem, HomeworkDetail, HomeworkSubmission, AssignHomeworkInput } from "@/lib/graphql";
export function generateMockHomework(classId: string): HomeworkItem[] {
const now = Date.now();
@@ -35,3 +35,84 @@ export function generateMockHomework(classId: string): HomeworkItem[] {
},
];
}
/**
* 从 homeworkId 反查 classIdmock 数据 ID 规约:${classId}-hw-XXX
* 兜底:未匹配时返回 "mock-class"。
*/
function extractClassIdFromHomeworkId(homeworkId: string): string {
const match = homeworkId.match(/^(.+)-hw-\d+$/);
return match?.[1] ?? "mock-class";
}
/** 生成 mock 提交列表(固定 5 个学生提交,混合状态) */
function generateMockSubmissions(homeworkId: string): HomeworkSubmission[] {
const now = Date.now();
const defs: Array<{
status: HomeworkSubmission["status"];
score: number | null;
feedback: string | null;
}> = [
{
status: "GRADED",
score: Math.floor(Math.random() * 20) + 80,
feedback: "思路清晰,过程完整",
},
{
status: "GRADED",
score: Math.floor(Math.random() * 20) + 80,
feedback: "注意步骤书写规范",
},
{ status: "SUBMITTED", score: null, feedback: null },
{ status: "SUBMITTED", score: null, feedback: null },
{ status: "NOT_SUBMITTED", score: null, feedback: null },
];
return defs.map((d, i) => {
const studentId = `${homeworkId.slice(0, -3)}${String(i + 1).padStart(3, "0")}`;
return {
id: `${homeworkId}-sub-${String(i + 1).padStart(3, "0")}`,
homeworkId,
studentId,
studentName: `学生${String(i + 1).padStart(2, "0")}`,
status: d.status,
score: d.score,
feedback: d.feedback,
submittedAt:
d.status !== "NOT_SUBMITTED"
? new Date(now - i * 60 * 60 * 1000).toISOString()
: null,
};
});
}
/** 按作业 ID 查询详情含提交列表MSW mock */
export function findMockHomeworkDetail(homeworkId: string): HomeworkDetail {
const classId = extractClassIdFromHomeworkId(homeworkId);
const all = generateMockHomework(classId);
const matched = all.find((h) => h.id === homeworkId);
const base = matched ?? {
id: homeworkId,
classId,
title: "作业详情mock 兜底)",
description: "Mock 数据兜底:未匹配到列表中的作业",
dueDate: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(),
status: "SUBMITTED" as const,
};
return {
...base,
submissions: generateMockSubmissions(homeworkId),
};
}
/** 创建新作业:基于 input 生成 HomeworkItem含随机 ID */
export function createMockHomework(input: AssignHomeworkInput): HomeworkItem {
const randomId = Math.random().toString(36).slice(2, 10);
return {
id: `${input.classId}-hw-${randomId}`,
classId: input.classId,
title: input.title,
description: input.description ?? null,
dueDate: input.dueDate,
status: "NOT_SUBMITTED",
};
}

View File

@@ -0,0 +1,146 @@
/**
* Mock 知识图谱数据core-edu / ai 域P4 扩展)
*
* 包含数学、语文、英语三科知识点节点 + 前置依赖边。
* 维护者ai13teacher-portal
*/
import type { KnowledgeGraphData } from "@/lib/graphql-p4";
/** 全量知识图谱 mock按科目分布13 节点 + 10 边) */
export const mockKnowledgeGraph: KnowledgeGraphData = {
nodes: [
// ---- 数学5 节点) ----
{
id: "math-01",
name: "实数与运算",
subject: "数学",
description: "有理数、无理数、实数的基本运算",
masteryLevel: 88,
},
{
id: "math-02",
name: "代数式",
subject: "数学",
description: "整式、分式、根式的化简与求值",
masteryLevel: 76,
},
{
id: "math-03",
name: "方程与不等式",
subject: "数学",
description: "一元二次方程、不等式组求解",
masteryLevel: 62,
},
{
id: "math-04",
name: "函数",
subject: "数学",
description: "一次函数、二次函数、指数对数函数",
masteryLevel: 45,
},
{
id: "math-05",
name: "导数",
subject: "数学",
description: "导数的几何意义与运算应用",
masteryLevel: 32,
},
// ---- 语文4 节点) ----
{
id: "chinese-01",
name: "文言文基础",
subject: "语文",
description: "常见实词虚词、句式辨析",
masteryLevel: 81,
},
{
id: "chinese-02",
name: "古诗词鉴赏",
subject: "语文",
description: "意象、手法、情感分析",
masteryLevel: 68,
},
{
id: "chinese-03",
name: "现代文阅读",
subject: "语文",
description: "论述类与文学类文本阅读策略",
masteryLevel: 55,
},
{
id: "chinese-04",
name: "议论文写作",
subject: "语文",
description: "论点提炼、论据组织、论证结构",
masteryLevel: 48,
},
// ---- 英语4 节点) ----
{
id: "english-01",
name: "词汇基础",
subject: "英语",
description: "核心 3500 词汇与词组搭配",
masteryLevel: 90,
},
{
id: "english-02",
name: "语法结构",
subject: "英语",
description: "从句、非谓语动词、虚拟语气",
masteryLevel: 72,
},
{
id: "english-03",
name: "阅读理解",
subject: "英语",
description: "主旨、细节、推理题型策略",
masteryLevel: 58,
},
{
id: "english-04",
name: "书面表达",
subject: "英语",
description: "应用文与议论文写作模板",
masteryLevel: 40,
},
],
edges: [
// 数学依赖链:实数 → 代数式 → 方程 → 函数 → 导数
{ from: "math-01", to: "math-02", type: "PREREQUISITE" },
{ from: "math-02", to: "math-03", type: "PREREQUISITE" },
{ from: "math-03", to: "math-04", type: "PREREQUISITE" },
{ from: "math-04", to: "math-05", type: "PREREQUISITE" },
// 语文依赖链:文言文 → 古诗词 → 现代文 → 写作
{ from: "chinese-01", to: "chinese-02", type: "PREREQUISITE" },
{ from: "chinese-02", to: "chinese-03", type: "RELATED" },
{ from: "chinese-03", to: "chinese-04", type: "PREREQUISITE" },
// 英语依赖链:词汇 → 语法 → 阅读 → 写作
{ from: "english-01", to: "english-02", type: "PREREQUISITE" },
{ from: "english-02", to: "english-03", type: "RELATED" },
{ from: "english-03", to: "english-04", type: "PREREQUISITE" },
],
};
/** 可选科目列表 */
export const KNOWLEDGE_SUBJECTS = ["全部", "数学", "语文", "英语"] as const;
/**
* 按科目过滤知识图谱
* subject 为空、"全部" 或 undefined 时返回全量
*/
export function filterKnowledgeGraph(
subject: string | null | undefined,
): KnowledgeGraphData {
if (!subject || subject === "全部") {
return mockKnowledgeGraph;
}
const nodes = mockKnowledgeGraph.nodes.filter(
(n) => n.subject === subject,
);
const nodeIds = new Set(nodes.map((n) => n.id));
const edges = mockKnowledgeGraph.edges.filter(
(e) => nodeIds.has(e.from) && nodeIds.has(e.to),
);
return { nodes, edges };
}

View File

@@ -0,0 +1,458 @@
/**
* Mock 请假申请 + 调课申请数据P7-admin请假/调课子模块)
*
* 维护者ai13teacher-portal
*
* 覆盖:
* - 15 条请假申请(含 pending/approved/rejected 三种状态)
* - 10 条调课申请(含 ADJUST/SUBSTITUTE 两种类型)
*/
import type {
LeaveRequest,
LeaveRequestsFilter,
LeaveRequestsResult,
LeaveStatus,
LeaveType,
ApproveLeaveRequestInput,
ApproveLeaveRequestResult,
SubmitLeaveRequestInput,
SubmitLeaveRequestResult,
ScheduleChange,
ScheduleChangeType,
ScheduleChangeStatus,
ScheduleChangesResult,
SubmitScheduleChangeInput,
SubmitScheduleChangeResult,
} from "@/lib/graphql-p7-admin";
import type { DataScope } from "@/lib/graphql";
import { EXTENDED_CLASSES, SUBJECTS } from "./grades-extended";
/** 教师 ID 列表 */
const TEACHERS = [
{ id: "u-001", name: "王老师", role: "teacher" },
{ id: "u-002", name: "刘老师", role: "teacher" },
{ id: "u-003", name: "陈老师", role: "teacher" },
{ id: "u-004", name: "杨老师", role: "teacher" },
{ id: "u-005", name: "周老师", role: "teacher" },
{ id: "u-006", name: "吴老师", role: "head_teacher" },
{ id: "u-007", name: "郑老师", role: "teacher" },
{ id: "u-008", name: "孙老师", role: "teacher" },
] as const;
/** 审批人 ID教务主任 */
const REVIEWER = { id: "u-admin-01", name: "教务主任" };
/** 请假原因模板 */
const LEAVE_REASONS = [
"感冒发烧,需在家休养",
"家中突发急事",
"年度体检",
"陪同家人就医",
"参加校外培训",
"处理个人事务",
"感冒咳嗽",
"事务处理",
"婚假",
"事假",
];
/** 生成日期 YYYY-MM-DD */
function dateOffset(daysAgo: number): string {
const d = new Date();
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() - daysAgo);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
/** 请假类型轮转 */
const LEAVE_TYPES: LeaveType[] = [
"SICK",
"PERSONAL",
"ANNUAL",
"SICK",
"PERSONAL",
"OTHER",
"SICK",
"ANNUAL",
"PERSONAL",
"OTHER",
"SICK",
"PERSONAL",
"ANNUAL",
"SICK",
"PERSONAL",
];
/** 请假状态轮转 */
const LEAVE_STATUSES: LeaveStatus[] = [
"PENDING",
"APPROVED",
"REJECTED",
"APPROVED",
"PENDING",
"APPROVED",
"REJECTED",
"PENDING",
"APPROVED",
"APPROVED",
"PENDING",
"APPROVED",
"REJECTED",
"PENDING",
"APPROVED",
];
/** 生成 15 条请假申请 */
function generateLeaveRequests(): LeaveRequest[] {
const list: LeaveRequest[] = [];
for (let i = 0; i < 15; i++) {
const teacher = TEACHERS[i % TEACHERS.length];
if (!teacher) continue;
const type = LEAVE_TYPES[i] ?? "PERSONAL";
const status = LEAVE_STATUSES[i] ?? "PENDING";
const submittedAt = dateOffset(15 - i);
const startDate = dateOffset(13 - i);
const endDate = dateOffset(12 - i);
const reason = LEAVE_REASONS[i % LEAVE_REASONS.length] ?? "个人事务";
const reviewed =
status === "PENDING"
? null
: {
reviewedAt: dateOffset(14 - i),
reviewerId: REVIEWER.id,
reviewerName: REVIEWER.name,
reviewComment:
status === "APPROVED" ? "已批准" : "理由不充分,请补充材料",
};
list.push({
id: `lr-2026-${String(i + 1).padStart(3, "0")}`,
applicantId: teacher.id,
applicantName: teacher.name,
applicantRole: teacher.role,
type,
startDate,
endDate,
reason,
status,
submittedAt,
reviewedAt: reviewed?.reviewedAt ?? null,
reviewerId: reviewed?.reviewerId ?? null,
reviewerName: reviewed?.reviewerName ?? null,
reviewComment: reviewed?.reviewComment ?? null,
});
}
return list;
}
/** 全量请假申请缓存 */
const ALL_LEAVE_REQUESTS: LeaveRequest[] = generateLeaveRequests();
/** 保存的请假覆盖(按 requestId 索引) */
const LEAVE_OVERRIDES = new Map<string, LeaveRequest>();
/** 应用覆盖后的请假申请读取 */
function resolveLeave(id: string): LeaveRequest | undefined {
const override = LEAVE_OVERRIDES.get(id);
if (override) return override;
return ALL_LEAVE_REQUESTS.find((r) => r.id === id);
}
/** 请假列表查询 */
export function queryLeaveRequests(
filter: LeaveRequestsFilter,
): LeaveRequestsResult {
const { dataScope, status, page, pageSize } = filter;
const safePage = page ?? 1;
const safePageSize = pageSize ?? 50;
// 应用覆盖后的列表
const merged = ALL_LEAVE_REQUESTS.map(
(r) => LEAVE_OVERRIDES.get(r.id) ?? r,
);
const filtered = merged.filter((r) => {
if (status && r.status !== status) return false;
// dataScope 过滤SELF 只看自己的mock 里全部 teacher 角色,模拟当前教师 = u-001
if (dataScope === "SELF" && r.applicantId !== "u-001") return false;
return true;
});
const total = filtered.length;
const totalPages = Math.max(1, Math.ceil(total / safePageSize));
const currentPage = Math.min(Math.max(1, safePage), totalPages);
const start = (currentPage - 1) * safePageSize;
const items = filtered.slice(start, start + safePageSize);
return { items, total, page: currentPage, pageSize: safePageSize };
}
/** 审批请假 */
export function approveLeaveRequest(
input: ApproveLeaveRequestInput,
): ApproveLeaveRequestResult {
const original = resolveLeave(input.requestId);
const newStatus: LeaveStatus =
input.action === "APPROVE" ? "APPROVED" : "REJECTED";
const now = new Date().toISOString();
const updated: LeaveRequest = {
...(original ?? {
id: input.requestId,
applicantId: "unknown",
applicantName: "未知",
applicantRole: "teacher",
type: "PERSONAL" as LeaveType,
startDate: "",
endDate: "",
reason: "",
status: "PENDING",
submittedAt: now,
reviewedAt: null,
reviewerId: null,
reviewerName: null,
reviewComment: null,
}),
status: newStatus,
reviewedAt: now,
reviewerId: REVIEWER.id,
reviewerName: REVIEWER.name,
reviewComment: input.comment ?? null,
};
LEAVE_OVERRIDES.set(input.requestId, updated);
return {
requestId: input.requestId,
status: newStatus,
reviewedAt: now,
reviewerId: REVIEWER.id,
reviewerName: REVIEWER.name,
reviewComment: input.comment ?? null,
};
}
/** 提交请假申请 */
export function submitLeaveRequest(
input: SubmitLeaveRequestInput,
): SubmitLeaveRequestResult {
const now = new Date().toISOString();
const seq = String(ALL_LEAVE_REQUESTS.length + LEAVE_OVERRIDES.size + 1).padStart(
3,
"0",
);
const requestId = `lr-2026-${seq}`;
const newRequest: LeaveRequest = {
id: requestId,
applicantId: "u-001",
applicantName: "王老师",
applicantRole: "teacher",
type: input.type,
startDate: input.startDate,
endDate: input.endDate,
reason: input.reason,
status: "PENDING",
submittedAt: now,
reviewedAt: null,
reviewerId: null,
reviewerName: null,
reviewComment: null,
};
LEAVE_OVERRIDES.set(requestId, newRequest);
return {
requestId,
status: "PENDING",
submittedAt: now,
};
}
// ============ 调课申请 ============
/** 调课原因模板 */
const CHANGE_REASONS = [
"出差需调课",
"请同事代课",
"教务临时调整",
"学科组活动",
"家中有事",
"培训冲突",
"会议冲突",
"外出学习",
"代同事上课",
"校际教研",
];
/** 调课类型轮转 */
const CHANGE_TYPES: ScheduleChangeType[] = [
"ADJUST",
"SUBSTITUTE",
"ADJUST",
"ADJUST",
"SUBSTITUTE",
"ADJUST",
"SUBSTITUTE",
"ADJUST",
"SUBSTITUTE",
"ADJUST",
];
/** 调课状态轮转 */
const CHANGE_STATUSES: ScheduleChangeStatus[] = [
"PENDING",
"APPROVED",
"REJECTED",
"APPROVED",
"PENDING",
"APPROVED",
"PENDING",
"APPROVED",
"REJECTED",
"PENDING",
];
/** 生成 10 条调课申请 */
function generateScheduleChanges(): ScheduleChange[] {
const list: ScheduleChange[] = [];
for (let i = 0; i < 10; i++) {
const teacher = TEACHERS[i % TEACHERS.length];
if (!teacher) continue;
const cls = EXTENDED_CLASSES[i % EXTENDED_CLASSES.length];
if (!cls) continue;
const subject = SUBJECTS[i % SUBJECTS.length] ?? "数学";
const type = CHANGE_TYPES[i] ?? "ADJUST";
const status = CHANGE_STATUSES[i] ?? "PENDING";
const submittedAt = dateOffset(10 - i);
const originalDate = dateOffset(8 - i);
const adjustedDate = dateOffset(6 - i);
const reason = CHANGE_REASONS[i % CHANGE_REASONS.length] ?? "调课";
const substitute =
type === "SUBSTITUTE"
? {
substituteTeacherId: TEACHERS[(i + 1) % TEACHERS.length]?.id ?? null,
substituteTeacherName:
TEACHERS[(i + 1) % TEACHERS.length]?.name ?? null,
}
: { substituteTeacherId: null, substituteTeacherName: null };
const reviewed =
status === "PENDING"
? null
: {
reviewedAt: dateOffset(9 - i),
reviewComment:
status === "APPROVED" ? "已批准" : "原因不充分,请重新提交",
};
list.push({
id: `sc-2026-${String(i + 1).padStart(3, "0")}`,
requesterId: teacher.id,
requesterName: teacher.name,
classId: cls.id,
className: cls.name,
originalDate,
originalPeriod: (i % 8) + 1,
originalSubject: subject,
adjustedDate,
adjustedPeriod: ((i + 2) % 8) + 1,
adjustedSubject: subject,
substituteTeacherId: substitute.substituteTeacherId,
substituteTeacherName: substitute.substituteTeacherName,
type,
reason,
status,
submittedAt,
reviewedAt: reviewed?.reviewedAt ?? null,
reviewComment: reviewed?.reviewComment ?? null,
});
}
return list;
}
/** 全量调课申请缓存 */
const ALL_SCHEDULE_CHANGES: ScheduleChange[] = generateScheduleChanges();
/** 保存的调课覆盖(按 requestId 索引) */
const CHANGE_OVERRIDES = new Map<string, ScheduleChange>();
/** 调课列表查询 */
export function queryScheduleChanges(
page = 1,
pageSize = 50,
): ScheduleChangesResult {
const merged = ALL_SCHEDULE_CHANGES.map(
(r) => CHANGE_OVERRIDES.get(r.id) ?? r,
);
const total = merged.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const currentPage = Math.min(Math.max(1, page), totalPages);
const start = (currentPage - 1) * pageSize;
const items = merged.slice(start, start + pageSize);
return { items, total, page: currentPage, pageSize };
}
/** 提交调课申请 */
export function submitScheduleChange(
input: SubmitScheduleChangeInput,
): SubmitScheduleChangeResult {
const now = new Date().toISOString();
const seq = String(
ALL_SCHEDULE_CHANGES.length + CHANGE_OVERRIDES.size + 1,
).padStart(3, "0");
const requestId = `sc-2026-${seq}`;
const cls = EXTENDED_CLASSES.find((c) => c.id === input.classId);
const newRequest: ScheduleChange = {
id: requestId,
requesterId: "u-001",
requesterName: "王老师",
classId: input.classId,
className: cls?.name ?? "未知班级",
originalDate: input.originalDate,
originalPeriod: input.originalPeriod,
originalSubject: input.originalSubject,
adjustedDate: input.adjustedDate,
adjustedPeriod: input.adjustedPeriod,
adjustedSubject: input.adjustedSubject,
substituteTeacherId: input.substituteTeacherId ?? null,
substituteTeacherName: null,
type: input.type,
reason: input.reason,
status: "PENDING",
submittedAt: now,
reviewedAt: null,
reviewComment: null,
};
CHANGE_OVERRIDES.set(requestId, newRequest);
return {
requestId,
status: "PENDING",
submittedAt: now,
};
}
/** 当前教师可访问的 dataScope 列表(用于 UI 下拉) */
export const DATA_SCOPES: Array<{ value: DataScope; label: string }> = [
{ value: "SELF", label: "我的申请" },
{ value: "CLASS", label: "本班" },
{ value: "GRADE", label: "本年级" },
{ value: "SCHOOL", label: "全校" },
];
/** 请假类型映射 */
export const LEAVE_TYPE_LABEL: Record<LeaveType, string> = {
SICK: "病假",
PERSONAL: "事假",
ANNUAL: "年假",
OTHER: "其他",
};
/** 请假状态映射 */
export const LEAVE_STATUS_LABEL: Record<LeaveStatus, string> = {
PENDING: "待审批",
APPROVED: "已批准",
REJECTED: "已拒绝",
};
/** 调课类型映射 */
export const SCHEDULE_CHANGE_TYPE_LABEL: Record<ScheduleChangeType, string> = {
ADJUST: "调课",
SUBSTITUTE: "代课",
};
/** 调课状态映射 */
export const SCHEDULE_CHANGE_STATUS_LABEL: Record<ScheduleChangeStatus, string> =
{
PENDING: "待审批",
APPROVED: "已批准",
REJECTED: "已拒绝",
};

View File

@@ -0,0 +1,157 @@
/**
* Mock 课标覆盖热力图数据P7-advanced热力图模块
*
* 维护者ai13teacher-portal
*
* 覆盖:
* - 5 教材 × 50 知识点 × 20 课案覆盖矩阵
* - 按 textbookId 查询热力图
*/
import type {
LessonPlanHeatmap,
HeatmapKnowledgePoint,
HeatmapLessonPlan,
HeatmapCell,
} from "@/lib/graphql-p7-advanced";
/** 5 个教材定义 */
const TEXTBOOK_DEFS: Array<{
textbookId: string;
textbookTitle: string;
subject: string;
}> = [
{ textbookId: "tb-001", textbookTitle: "高中数学必修一", subject: "数学" },
{ textbookId: "tb-002", textbookTitle: "高中数学必修二", subject: "数学" },
{ textbookId: "tb-003", textbookTitle: "高中物理必修一", subject: "物理" },
{ textbookId: "tb-004", textbookTitle: "高中化学必修一", subject: "化学" },
{ textbookId: "tb-005", textbookTitle: "高中生物必修一", subject: "生物" },
];
/** 章节名池 */
const CHAPTER_NAMES = [
"第一章 集合与函数",
"第二章 基本初等函数",
"第三章 函数的应用",
"第四章 空间几何",
"第五章 点线面位置关系",
];
/** 课案标题池20 个) */
const LESSON_PLAN_TITLES = [
"集合的概念",
"集合间的基本关系",
"函数及其表示",
"函数的单调性",
"函数的奇偶性",
"指数函数",
"对数函数",
"幂函数",
"函数与方程",
"函数模型及应用",
"空间几何体",
"空间点线面位置关系",
"直线方程",
"圆的方程",
"空间直角坐标系",
"函数综合复习",
"三角函数初步",
"数列初步",
"不等式",
"导数初步",
];
/** 知识点名池50 个,简化生成) */
const KP_NAME_POOL = [
"集合概念", "集合运算", "并集交集", "补集", "全集",
"函数定义", "定义域值域", "单调性", "奇偶性", "周期性",
"指数运算", "指数函数图象", "对数运算", "对数函数图象", "换底公式",
"幂函数", "幂函数性质", "函数零点", "二分法", "函数模型",
"几何体结构", "三视图", "直观图", "表面积", "体积",
"点线面关系", "线线平行", "线面平行", "面面平行", "线线垂直",
"线面垂直", "面面垂直", "直线倾斜角", "直线斜率", "直线方程形式",
"圆的定义", "圆的标准方程", "圆的一般方程", "直线与圆位置", "圆与圆位置",
"空间坐标", "距离公式", "三角函数定义", "正弦定理", "余弦定理",
"数列概念", "等差数列", "等比数列", "不等式性质", "导数定义",
];
/** 确定性伪随机 */
function seededRandom(seed: number): number {
const x = Math.sin(seed) * 10000;
return x - Math.floor(x);
}
function strToSeed(s: string): number {
let h = 0;
for (let i = 0; i < s.length; i++) {
h = (h * 31 + s.charCodeAt(i)) | 0;
}
return Math.abs(h) + 1;
}
/** 生成 50 个知识点 */
function generateKnowledgePoints(textbookId: string): HeatmapKnowledgePoint[] {
return KP_NAME_POOL.map((name, i) => ({
kpId: `kp-${textbookId}-${String(i + 1).padStart(3, "0")}`,
name,
chapterName: CHAPTER_NAMES[i % CHAPTER_NAMES.length] ?? "未分类",
}));
}
/** 生成 20 个课案 */
function generateLessonPlans(textbookId: string): HeatmapLessonPlan[] {
return LESSON_PLAN_TITLES.map((title, i) => ({
planId: `plan-${textbookId}-${String(i + 1).padStart(3, "0")}`,
title,
}));
}
/** 生成覆盖矩阵单元格50 × 20 = 1000 个) */
function generateCells(
textbookId: string,
knowledgePoints: HeatmapKnowledgePoint[],
lessonPlans: HeatmapLessonPlan[],
): HeatmapCell[] {
const cells: HeatmapCell[] = [];
for (const kp of knowledgePoints) {
for (const plan of lessonPlans) {
const seed = strToSeed(`${kp.kpId}-${plan.planId}`);
const r = seededRandom(seed);
// 0-3 覆盖数:约 40% 为 025% 为 120% 为 215% 为 3
let coverage: number;
if (r > 0.85) coverage = 3;
else if (r > 0.65) coverage = 2;
else if (r > 0.4) coverage = 1;
else coverage = 0;
cells.push({ kpId: kp.kpId, planId: plan.planId, coverage });
}
}
return cells;
}
/** 按 textbookId 查询热力图 */
export function findLessonPlanHeatmap(
textbookId: string,
): LessonPlanHeatmap | null {
const def = TEXTBOOK_DEFS.find((t) => t.textbookId === textbookId);
if (!def) return null;
const knowledgePoints = generateKnowledgePoints(textbookId);
const lessonPlans = generateLessonPlans(textbookId);
const cells = generateCells(textbookId, knowledgePoints, lessonPlans);
return {
textbookId: def.textbookId,
textbookTitle: def.textbookTitle,
knowledgePoints,
lessonPlans,
cells,
};
}
/** 获取所有教材列表(供页面 select 使用) */
export function listHeatmapTextbooks(): Array<{
textbookId: string;
textbookTitle: string;
subject: string;
}> {
return TEXTBOOK_DEFS;
}

View File

@@ -0,0 +1,448 @@
/**
* Mock 课案数据lesson-preparation 域P7-insights 扩展)
*
* 维护者ai13teacher-portal
*
* 覆盖:
* - 10 个我自己的课案(语文/数学/英语,覆盖小学/初中/高中)
* - 20 个校内库课案(他人发布)
* - 5 课案模板
* - 课案日历 / 热力图
* - CRUD创建 / 更新 / Fork
*/
import type {
LessonPlanItem,
LessonPlanDetail,
LessonPlanLibraryItem,
LessonPlanCalendarMonth,
LessonPlanCalendarDay,
LessonPlanHeatmap,
LessonPlanTemplate,
CreateLessonPlanInput,
UpdateLessonPlanInput,
AIGenerateLessonPlanInput,
AIGenerateLessonPlanResult,
} from "@/lib/graphql-p7-insights";
/** 学科 */
const SUBJECTS = ["语文", "数学", "英语"] as const;
/** 年级 */
const GRADES = ["小学三年级", "小学六年级", "初中一年级", "高中三年级"] as const;
/** 模板元信息 */
export const LESSON_PLAN_TEMPLATES: {
id: LessonPlanTemplate;
name: string;
description: string;
}[] = [
{ id: "REGULAR", name: "常规教案", description: "标准课时教案:教学目标→重难点→教学过程→作业布置" },
{ id: "UNIT", name: "单元教案", description: "单元整体设计:单元目标→课时分配→阶段评估" },
{ id: "REVIEW", name: "复习教案", description: "复习课教案:知识梳理→典型例题→巩固练习" },
{ id: "ACTIVITY", name: "活动教案", description: "活动课教案:情境导入→合作探究→成果展示" },
{ id: "PERSONALIZED", name: "个性化教案", description: "分层教学:基础目标→进阶目标→差异化辅导" },
];
/** 课案内容模板(按学科生成 markdown */
function planContent(
title: string,
subject: string,
grade: string,
chapter: string,
): string {
return `# ${title}
## 基本信息
- 学科:${subject}
- 年级:${grade}
- 章节:${chapter}
- 课时1 课时40 分钟)
## 教学目标
1. 知识与技能:掌握 ${chapter} 的核心概念与基本运算
2. 过程与方法:通过情境导入与例题分析,培养逻辑推理能力
3. 情感态度价值观:激发学习兴趣,建立学科自信
## 教学重难点
- 重点:${chapter} 的定义与基本性质
- 难点:综合应用与变式训练
## 教学过程
### 一、情境导入5 分钟)
通过生活实例引出 ${chapter} 的学习必要性。
### 二、新知讲解15 分钟)
1. 概念引入:给出 ${chapter} 的规范定义
2. 性质剖析:列表对比关键性质
3. 典型例题:精选 3 道分层例题
### 三、巩固练习12 分钟)
- 基础题5 道
- 提升题3 道
- 拓展题1 道
### 四、课堂小结5 分钟)
学生口述本节收获,教师补充完善。
### 五、作业布置3 分钟)
- 必做:教材 P12 第 1-5 题
- 选做:探究题 1 道
## 板书设计
左侧:${chapter} 定义 | 中间:例题区 | 右侧:学生板演区
## 教学反思
课后填写:达成度 / 改进点 / 后续衔接`;
}
/** 10 个我的课案(基础定义) */
const MY_PLANS: Array<{
id: string;
title: string;
subject: string;
grade: string;
chapter: string;
status: "DRAFT" | "PUBLISHED" | "ARCHIVED";
textbookId: string | null;
textbookTitle: string | null;
classIds: string[];
updatedAt: string;
}> = [
{
id: "lp-001",
title: "《岳阳楼记》第一课时",
subject: "语文",
grade: "初中一年级",
chapter: "第二单元·岳阳楼记",
status: "PUBLISHED",
textbookId: "tb-001",
textbookTitle: "小学语文三年级上册",
classIds: ["550e8400-e29b-41d4-a716-446655440010"],
updatedAt: "2026-07-01T09:00:00Z",
},
{
id: "lp-002",
title: "分数的初步认识",
subject: "数学",
grade: "小学三年级",
chapter: "第七单元·分数的初步认识",
status: "PUBLISHED",
textbookId: "tb-002",
textbookTitle: "小学数学六年级上册",
classIds: ["550e8400-e29b-41d4-a716-446655440010"],
updatedAt: "2026-07-02T14:30:00Z",
},
{
id: "lp-003",
title: "My Family 单元导入",
subject: "英语",
grade: "初中一年级",
chapter: "Unit 1·My Family",
status: "DRAFT",
textbookId: "tb-003",
textbookTitle: "初中英语七年级上册",
classIds: ["550e8400-e29b-41d4-a716-446655440011"],
updatedAt: "2026-07-03T10:15:00Z",
},
{
id: "lp-004",
title: "函数的单调性与极值",
subject: "数学",
grade: "高中三年级",
chapter: "选修 2-2·导数应用",
status: "PUBLISHED",
textbookId: null,
textbookTitle: null,
classIds: ["550e8400-e29b-41d4-a716-446655440012"],
updatedAt: "2026-07-05T08:45:00Z",
},
{
id: "lp-005",
title: "现代文阅读·论述类文本",
subject: "语文",
grade: "高中三年级",
chapter: "第一轮复习·现代文阅读",
status: "DRAFT",
textbookId: null,
textbookTitle: null,
classIds: ["550e8400-e29b-41d4-a716-446655440012"],
updatedAt: "2026-07-06T16:20:00Z",
},
{
id: "lp-006",
title: "Reading Strategies for Main Idea",
subject: "英语",
grade: "高中三年级",
chapter: "高三复习·阅读理解",
status: "PUBLISHED",
textbookId: null,
textbookTitle: null,
classIds: ["550e8400-e29b-41d4-a716-446655440012"],
updatedAt: "2026-07-07T11:00:00Z",
},
{
id: "lp-007",
title: "古诗词鉴赏·意象分析",
subject: "语文",
grade: "初中一年级",
chapter: "第三单元·古诗词",
status: "ARCHIVED",
textbookId: "tb-001",
textbookTitle: "小学语文三年级上册",
classIds: ["550e8400-e29b-41d4-a716-446655440010"],
updatedAt: "2026-06-28T09:30:00Z",
},
{
id: "lp-008",
title: "方程与不等式复习课",
subject: "数学",
grade: "小学六年级",
chapter: "总复习·方程与不等式",
status: "PUBLISHED",
textbookId: "tb-002",
textbookTitle: "小学数学六年级上册",
classIds: ["550e8400-e29b-41d4-a716-446655440013"],
updatedAt: "2026-07-08T13:45:00Z",
},
{
id: "lp-009",
title: "书面表达·议论文结构",
subject: "英语",
grade: "高中三年级",
chapter: "高三复习·书面表达",
status: "DRAFT",
textbookId: null,
textbookTitle: null,
classIds: ["550e8400-e29b-41d4-a716-446655440012"],
updatedAt: "2026-07-09T15:10:00Z",
},
{
id: "lp-010",
title: "实数与运算·总复习",
subject: "数学",
grade: "初中一年级",
chapter: "第一章·实数",
status: "PUBLISHED",
textbookId: null,
textbookTitle: null,
classIds: ["550e8400-e29b-41d4-a716-446655440011"],
updatedAt: "2026-07-10T10:00:00Z",
},
];
/** 转 List item */
function toItem(p: typeof MY_PLANS[number]): LessonPlanItem {
return { ...p };
}
/** 我的课案列表 */
export const mockLessonPlans: LessonPlanItem[] = MY_PLANS.map(toItem);
/** 按 ID 查询课案详情 */
export function findLessonPlanDetail(planId: string): LessonPlanDetail | null {
const found = MY_PLANS.find((p) => p.id === planId);
if (!found) return null;
return {
...found,
content: planContent(found.title, found.subject, found.grade, found.chapter),
aiHistory: [
{
id: `ai-${found.id}-1`,
prompt: `为「${found.title}」生成教学过程`,
generatedAt: found.updatedAt,
model: "edu-llm-p7-v1",
},
],
createdAt: "2026-06-20T08:00:00Z",
};
}
/** 按学科筛选我的课案 */
export function filterLessonPlans(subject?: string | null): LessonPlanItem[] {
if (!subject || subject === "全部") return mockLessonPlans;
return mockLessonPlans.filter((p) => p.subject === subject);
}
/** 创建课案 */
export function createMockLessonPlan(
input: CreateLessonPlanInput,
): LessonPlanItem {
const now = new Date().toISOString();
return {
id: `lp-${String(Date.now()).slice(-6)}`,
title: input.title,
subject: input.subject,
grade: input.grade,
chapter: input.chapter,
status: "DRAFT",
textbookId: input.textbookId ?? null,
textbookTitle: null,
classIds: [],
updatedAt: now,
};
}
/** 更新课案mock返回更新后的最小信息 */
export function updateMockLessonPlan(
input: UpdateLessonPlanInput,
): LessonPlanItem | null {
const found = MY_PLANS.find((p) => p.id === input.planId);
if (!found) return null;
const now = new Date().toISOString();
return {
...toItem(found),
title: input.title ?? found.title,
status: input.status ?? found.status,
textbookId: input.textbookId !== undefined ? input.textbookId : found.textbookId,
chapter: input.chapter ?? found.chapter,
classIds: input.classIds ?? found.classIds,
updatedAt: now,
};
}
/** AI 生成课案mock返回一段 markdown */
export function generateMockLessonPlan(
input: AIGenerateLessonPlanInput,
): AIGenerateLessonPlanResult {
const now = new Date().toISOString();
const seq = String(Date.now()).slice(-6);
const content = `# AI 生成教案:${input.chapter}
## 学科 ${input.subject} · 年级 ${input.grade}
## 教学目标
1. 掌握 ${input.chapter} 的核心概念
2. 能够运用相关知识解决典型问题
3. 培养学科思维与迁移能力
## 教学过程
### 一、导入5 分钟)
${input.prompt}
### 二、新知讲解15 分钟)
- 概念定义与示例
- 关键性质剖析
- 典型例题分层讲解
### 三、巩固练习12 分钟)
- 基础题 / 提升题 / 拓展题
### 四、课堂小结5 分钟)
### 五、作业布置3 分钟)`;
return {
id: `ai-lp-${seq}`,
content,
summary: `围绕「${input.chapter}」生成的 ${input.subject} 教案,含教学目标、教学过程、作业布置。`,
model: "edu-llm-p7-v1",
generatedAt: now,
};
}
/** 20 个校内库课案(他人发布) */
const AUTHORS = [
"王老师", "李老师", "张老师", "陈老师", "刘老师",
"赵老师", "黄老师", "周老师", "吴老师", "徐老师",
];
function buildLibraryItem(idx: number): LessonPlanLibraryItem {
const subject = SUBJECTS[idx % SUBJECTS.length] ?? "数学";
const grade = GRADES[idx % GRADES.length] ?? "初中一年级";
const author = AUTHORS[idx % AUTHORS.length] ?? "佚名";
const titles: Record<string, string[]> = {
: ["《荷塘月色》赏析", "《背影》情感分析", "议论文写作入门", "古诗词意象专题"],
: ["二次函数图像", "概率统计初步", "立体几何证明", "导数综合应用"],
: ["Reading for Theme", "Writing a Narrative", "Grammar in Context", "Listening Strategies"],
};
const subjectTitles = titles[subject] ?? ["通用教案"];
const title = subjectTitles[idx % subjectTitles.length] ?? `${subject} 教案 ${idx}`;
return {
id: `lp-lib-${String(idx).padStart(3, "0")}`,
title,
author,
subject,
grade,
chapter: `${idx + 1}`,
rating: Math.round((3.5 + (idx % 1.5)) * 10) / 10,
forkCount: Math.floor(((idx * 7) % 50) + 5),
updatedAt: `2026-06-${String(28 - (idx % 27)).padStart(2, "0")}T09:00:00Z`,
};
}
export const mockLessonPlanLibrary: LessonPlanLibraryItem[] = Array.from(
{ length: 20 },
(_, i) => buildLibraryItem(i),
);
/** 按学科 + 年级筛选校内库 */
export function filterLessonPlanLibrary(
subject?: string | null,
grade?: string | null,
): LessonPlanLibraryItem[] {
return mockLessonPlanLibrary.filter((p) => {
if (subject && subject !== "全部" && p.subject !== subject) return false;
if (grade && grade !== "全部" && p.grade !== grade) return false;
return true;
});
}
/** Fork 课案mock复制为新课案 */
export function forkMockLessonPlan(planId: string): LessonPlanItem | null {
const lib = mockLessonPlanLibrary.find((p) => p.id === planId);
if (!lib) return null;
const now = new Date().toISOString();
return {
id: `lp-${String(Date.now()).slice(-6)}`,
title: `${lib.title}Fork`,
subject: lib.subject,
grade: lib.grade,
chapter: lib.chapter,
status: "DRAFT",
textbookId: null,
textbookTitle: null,
classIds: [],
updatedAt: now,
};
}
/** 课案日历视图(按月份) */
export function getLessonPlanCalendar(
year: number,
month: number,
): LessonPlanCalendarMonth {
// 当月每天有 0-3 个课案(确定性 mock
const daysInMonth = new Date(year, month, 0).getDate();
const days: LessonPlanCalendarDay[] = [];
for (let d = 1; d <= daysInMonth; d++) {
const seed = (year * 100 + month) * 31 + d;
const planCount = seed % 4 === 0 ? 2 : seed % 3 === 0 ? 1 : 0;
if (planCount === 0) {
days.push({ date: `${year}-${String(month).padStart(2, "0")}-${String(d).padStart(2, "0")}`, planCount: 0, planTitles: [] });
} else {
const titles: string[] = [];
for (let i = 0; i < planCount; i++) {
titles.push(`课案 ${d}-${i + 1}`);
}
days.push({
date: `${year}-${String(month).padStart(2, "0")}-${String(d).padStart(2, "0")}`,
planCount,
planTitles: titles,
});
}
}
return { year, month, days };
}
/** 课标覆盖热力图(教材知识点 × 课案覆盖矩阵) */
export function getLessonPlanHeatmap(): LessonPlanHeatmap {
const rows = ["函数", "方程", "几何", "概率", "统计", "数列", "导数", "实数"];
const cols = ["lp-001", "lp-002", "lp-004", "lp-008", "lp-010"];
const cells = rows.map((r) =>
cols.map((c) => ({
textbookKp: r,
planIds: (r.charCodeAt(0) + c.charCodeAt(3)) % 3 === 0 ? [c] : [],
covered: (r.charCodeAt(0) + c.charCodeAt(3)) % 3 === 0,
})),
).flat();
return { rows, cols, cells };
}

View File

@@ -0,0 +1,77 @@
/**
* Mock 通知数据push-gateway + msg 域)
*
* 维护者ai13teacher-portal
* 关联02-architecture-design.md §5 实时推送
*/
import type { NotificationItem } from "@/lib/graphql-p5";
const now = Date.now();
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
export const mockNotifications: NotificationItem[] = [
{
id: "ntf-001",
type: "HOMEWORK_SUBMITTED",
title: "新作业提交",
message: "高三(1)班 李明 提交了《函数与导数练习》",
read: false,
createdAt: new Date(now - 5 * minute).toISOString(),
},
{
id: "ntf-002",
type: "EXAM_GRADED",
title: "考试已评分",
message: "高三(2)班《2026 春季期中考试》成绩已发布,平均分 112.5",
read: false,
createdAt: new Date(now - 35 * minute).toISOString(),
},
{
id: "ntf-003",
type: "BROADCAST",
title: "学校公告",
message: "本周五下午 14:00 召开教研组会议,请准时参加",
read: false,
createdAt: new Date(now - 2 * hour).toISOString(),
},
{
id: "ntf-004",
type: "HOMEWORK_SUBMITTED",
title: "新作业提交",
message: "高三(1)班 王芳 提交了《三角函数复习》",
read: true,
createdAt: new Date(now - 5 * hour).toISOString(),
},
{
id: "ntf-005",
type: "ROLE_CHANGED",
title: "角色变更",
message: "您的角色已更新为年级组长GRADE 数据范围)",
read: true,
createdAt: new Date(now - 1 * day).toISOString(),
},
{
id: "ntf-006",
type: "EXAM_GRADED",
title: "考试已评分",
message: "高三(3)班《月考 - 数学综合》成绩已发布,平均分 98.2",
read: false,
createdAt: new Date(now - 1 * day - 3 * hour).toISOString(),
},
{
id: "ntf-007",
type: "BROADCAST",
title: "系统通知",
message: "成绩录入截止日期为本周日 23:59请及时完成",
read: true,
createdAt: new Date(now - 2 * day).toISOString(),
},
];
/** 按 id 查找 mock 通知 */
export function findNotification(id: string): NotificationItem | null {
return mockNotifications.find((n) => n.id === id) ?? null;
}

Some files were not shown because too many files have changed in this diff Show More