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,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>
);