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:
348
apps/teacher-portal/src/app/(app)/ai-assist/page.tsx
Normal file
348
apps/teacher-portal/src/app/(app)/ai-assist/page.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* AI 辅助出题页面(P5)
|
||||
*
|
||||
* 数据来源(02-architecture-design.md §6 AI 辅助):
|
||||
* - GraphQL GenerateQuestionMutation
|
||||
*
|
||||
* 功能:
|
||||
* - 表单:科目 / 题型 / 难度 / 知识点 / 题目数量
|
||||
* - 生成题目(mutation)+ SSE 流式效果模拟(setInterval 逐字显示)
|
||||
* - 完整题目展示(含选项、答案、解析)
|
||||
* - 复制到剪贴板 / 再生成一组
|
||||
* - 生成历史(本次会话内)
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
259
apps/teacher-portal/src/app/(app)/ai-lesson-plan/page.tsx
Normal file
259
apps/teacher-portal/src/app/(app)/ai-lesson-plan/page.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* AI 教案生成页面(P5)
|
||||
*
|
||||
* 数据来源(02-architecture-design.md §6 AI 辅助):
|
||||
* - GraphQL GenerateLessonPlanMutation
|
||||
*
|
||||
* 功能:
|
||||
* - 表单:班级 / 科目 / 课时主题 / 教学目标
|
||||
* - 生成教案(mutation)+ 流式输出模拟(setInterval 逐字显示)
|
||||
* - 完整教案展示
|
||||
* - 导出为 .md 文件
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
289
apps/teacher-portal/src/app/(app)/ai-report/page.tsx
Normal file
289
apps/teacher-portal/src/app/(app)/ai-report/page.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* AI 学情报告页面(P5)
|
||||
*
|
||||
* 数据来源(02-architecture-design.md §6 AI 辅助):
|
||||
* - GraphQL GenerateReportMutation
|
||||
*
|
||||
* 功能:
|
||||
* - 表单:班级 / 报告类型(班级周报/月报/单生报告)/ 学生选择(单生报告时)
|
||||
* - 生成报告(mutation)+ 流式输出模拟(setInterval 逐字显示)
|
||||
* - 完整报告展示(含建议)
|
||||
* - 导出为 .md 文件
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
162
apps/teacher-portal/src/app/(app)/analytics/TrendChart.tsx
Normal file
162
apps/teacher-portal/src/app/(app)/analytics/TrendChart.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* TrendChart - 纯 SVG 折线图组件
|
||||
*
|
||||
* 用于展示成绩趋势(最近 N 次考试/测验)。
|
||||
* 不依赖 recharts/d3,纯 SVG 手绘(project_rules:不安装新 npm 包)。
|
||||
* 颜色使用 var(--color-accent) 语义令牌(project_rules §3.10)。
|
||||
*
|
||||
* 维护者:ai13(teacher-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 已在前面校验 > 0,points 至少 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>
|
||||
);
|
||||
}
|
||||
170
apps/teacher-portal/src/app/(app)/analytics/[studentId]/page.tsx
Normal file
170
apps/teacher-portal/src/app/(app)/analytics/[studentId]/page.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* StudentAnalytics 详情页 - 单生学情分析
|
||||
*
|
||||
* 数据来源(P4 扩展,MSW mock):GraphQL StudentAnalyticsQuery
|
||||
* - 成绩趋势图(纯 SVG 折线图)
|
||||
* - 弱项 / 强项知识点列表
|
||||
* - 掌握度进度条
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
287
apps/teacher-portal/src/app/(app)/analytics/page.tsx
Normal file
287
apps/teacher-portal/src/app/(app)/analytics/page.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Analytics 页面 - 班级学情分析仪表盘
|
||||
*
|
||||
* 数据来源(P4 扩展,MSW mock):
|
||||
* - GraphQL ClassAnalyticsQuery:班级均分/及格率/趋势/Top 学生/弱项
|
||||
* - GraphQL ClassesQuery:班级下拉选项
|
||||
*
|
||||
* 班级选择:URL ?classId=xxx 或下拉选择(同步 URL)
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
341
apps/teacher-portal/src/app/(app)/attendance/page.tsx
Normal file
341
apps/teacher-portal/src/app/(app)/attendance/page.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Attendance 列表页 - 考勤记录
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL AttendanceListQuery:按 classId/日期范围/状态筛选 + 分页
|
||||
* - GraphQL ClassesQuery:班级下拉选项
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
337
apps/teacher-portal/src/app/(app)/attendance/report/page.tsx
Normal file
337
apps/teacher-portal/src/app/(app)/attendance/report/page.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Attendance 报告页 - 周报/月报 + 打印
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL AttendanceReportQuery:按 classId + reportType + 日期范围生成报告数据
|
||||
* - GraphQL ClassesQuery:班级下拉选项
|
||||
*
|
||||
* A4 纸质风格 + window.print()
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
344
apps/teacher-portal/src/app/(app)/attendance/sheet/page.tsx
Normal file
344
apps/teacher-portal/src/app/(app)/attendance/sheet/page.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Attendance 录入页 - 批量录入考勤
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL AttendanceSheetQuery:按 classId + date 拉取学生列表 + 当天状态
|
||||
* - GraphQL SaveAttendanceSheetMutation:批量保存考勤
|
||||
* - GraphQL ClassesQuery:班级下拉选项
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
551
apps/teacher-portal/src/app/(app)/attendance/stats/page.tsx
Normal file
551
apps/teacher-portal/src/app/(app)/attendance/stats/page.tsx
Normal file
@@ -0,0 +1,551 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Attendance 统计页 - 班级考勤统计 + 趋势图 + 班级对比 + 学生预警
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL AttendanceStatsQuery:班级考勤统计(含 30 天趋势)
|
||||
* - GraphQL AttendanceClassComparisonQuery:5 班级出勤率对比
|
||||
* - GraphQL AttendanceWarningsQuery:学生考勤预警(出勤率 < 90%)
|
||||
* - GraphQL ClassesQuery:班级下拉选项
|
||||
*
|
||||
* SVG 图表参考 grades/analytics/charts.tsx 模式(var(--color-*) 语义令牌)
|
||||
*
|
||||
* 维护者:ai13(teacher-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">
|
||||
出勤率 < 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>
|
||||
);
|
||||
}
|
||||
492
apps/teacher-portal/src/app/(app)/classes/[id]/page.tsx
Normal file
492
apps/teacher-portal/src/app/(app)/classes/[id]/page.tsx
Normal file
@@ -0,0 +1,492 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 班级详情聚合页 - 单班级完整信息
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL ClassDetailQuery:聚合班级信息 + 学生 + 课表 + 近期作业 + 概览统计 + 成绩趋势
|
||||
*
|
||||
* 布局:多 widget 用 ErrorBoundary 隔离,单个 widget 异常不影响整页
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
250
apps/teacher-portal/src/app/(app)/classes/schedule/page.tsx
Normal file
250
apps/teacher-portal/src/app/(app)/classes/schedule/page.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 班级课表页 - 周课表网格
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL ClassScheduleQuery:按 classId 拉取周课表
|
||||
* - GraphQL ClassesQuery:班级下拉选项
|
||||
*
|
||||
* 布局:CSS Grid(周一到周五 × 8 节),每个卡片显示 学科/教师/教室
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
190
apps/teacher-portal/src/app/(app)/course-plans/[id]/page.tsx
Normal file
190
apps/teacher-portal/src/app/(app)/course-plans/[id]/page.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 课程计划详情页(P7-insights)
|
||||
*
|
||||
* 数据来源:GraphQL CoursePlanDetailQuery
|
||||
*
|
||||
* 功能:
|
||||
* - 基本信息:标题/学科/年级/学年/学期/学时
|
||||
* - 章节列表(每章:标题/学时/教学目标/重难点/教学活动)
|
||||
* - 关联教材入口(→ /textbooks/[id])
|
||||
* - 关联作业入口(→ /homework?coursePlanId=xxx)
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
366
apps/teacher-portal/src/app/(app)/course-plans/page.tsx
Normal file
366
apps/teacher-portal/src/app/(app)/course-plans/page.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 课程计划列表页(P7-insights)
|
||||
*
|
||||
* 数据来源:GraphQL CoursePlansQuery + CreateCoursePlanMutation
|
||||
*
|
||||
* 功能:
|
||||
* - 状态筛选(draft/published/archived)
|
||||
* - 列表:标题/学科/年级/学年/状态/操作
|
||||
* - "新建课程计划" 弹窗(模态框)
|
||||
*
|
||||
* 维护者:ai13(teacher-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-insights,MSW 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 班级诊断视图(P7-insights)
|
||||
*
|
||||
* 数据来源:GraphQL ClassDiagnosticQuery
|
||||
*
|
||||
* 功能:
|
||||
* - 顶部:班级基本信息 + 掌握度摘要(5 知识点 avg)
|
||||
* - 知识点掌握度列表(10 知识点,每点 avg/中位数/学生数/掌握分布)
|
||||
* - 学生掌握度排名表
|
||||
* - 监控埋点(mock console.log)
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
195
apps/teacher-portal/src/app/(app)/diagnostic/page.tsx
Normal file
195
apps/teacher-portal/src/app/(app)/diagnostic/page.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 诊断报告列表页(P7-insights)
|
||||
*
|
||||
* 数据来源:GraphQL DiagnosticReportsQuery
|
||||
*
|
||||
* 功能:
|
||||
* - 类型筛选(individual/class/grade)
|
||||
* - 状态筛选(draft/published/archived)
|
||||
* - 列表:报告标题/类型/对象/状态/创建时间/操作
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
397
apps/teacher-portal/src/app/(app)/elective/[id]/edit/page.tsx
Normal file
397
apps/teacher-portal/src/app/(app)/elective/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,397 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 编辑选修课页
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL ElectiveCourseDetailQuery:按 id 拉取详情(含学生列表)
|
||||
* - GraphQL UpdateElectiveCourseMutation / PublishElectiveCourseMutation / CancelElectiveCourseMutation
|
||||
*
|
||||
* 布局:
|
||||
* - 顶部:返回链接 + 标题
|
||||
* - 表单:课程名/学科/年级/容量/学期/描述
|
||||
* - 学生选课列表
|
||||
* - 越权校验:判断 teacherId 是否为当前用户
|
||||
* - "保存"/"发布"/"取消课程" 按钮
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
240
apps/teacher-portal/src/app/(app)/elective/create/page.tsx
Normal file
240
apps/teacher-portal/src/app/(app)/elective/create/page.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 创建选修课表单页
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL CreateElectiveCourseMutation:创建选修课(草稿/发布)
|
||||
*
|
||||
* 布局:
|
||||
* - 表单字段:课程名/学科 select/年级 select/容量 input/学期 select/描述 textarea
|
||||
* - "保存草稿"/"发布" 按钮
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
244
apps/teacher-portal/src/app/(app)/elective/page.tsx
Normal file
244
apps/teacher-portal/src/app/(app)/elective/page.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 选修课列表页
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL ElectiveCoursesQuery:按状态 + 学科筛选选修课列表
|
||||
*
|
||||
* 布局:
|
||||
* - 顶部:标题 + "创建选修课" 按钮
|
||||
* - 筛选:状态 select + 学科 select
|
||||
* - 列表:课程名/学科/年级/容量/已选数/状态/操作
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
519
apps/teacher-portal/src/app/(app)/error-book/page.tsx
Normal file
519
apps/teacher-portal/src/app/(app)/error-book/page.tsx
Normal 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 字/错误率/学科)
|
||||
*
|
||||
* 维护者:ai13(teacher-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-insights,MSW 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>
|
||||
);
|
||||
}
|
||||
486
apps/teacher-portal/src/app/(app)/exams/[id]/analytics/page.tsx
Normal file
486
apps/teacher-portal/src/app/(app)/exams/[id]/analytics/page.tsx
Normal 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)
|
||||
*
|
||||
* 维护者:ai13(teacher-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">
|
||||
红色 <50% · 黄色 50-70% · 蓝色 >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>
|
||||
);
|
||||
}
|
||||
328
apps/teacher-portal/src/app/(app)/exams/[id]/edit-rich/page.tsx
Normal file
328
apps/teacher-portal/src/app/(app)/exams/[id]/edit-rich/page.tsx
Normal 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()
|
||||
*
|
||||
* 维护者:ai13(teacher-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.execCommand,mock 实现) */
|
||||
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 序列化为 JSON(mock:保存为简化结构)
|
||||
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;
|
||||
}
|
||||
228
apps/teacher-portal/src/app/(app)/exams/[id]/page.tsx
Normal file
228
apps/teacher-portal/src/app/(app)/exams/[id]/page.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 考试详情页 - 显示考试基本信息 + 题目列表
|
||||
*
|
||||
* 数据来源(contract.md §2.4):GraphQL ExamDetailQuery(P3 扩展,MSW mock)
|
||||
* - 动态路由 /exams/[id],id 为考试 UUID
|
||||
* - DRAFT 状态展示"发布考试"按钮(调用 CreateExamMutation 复用为发布动作占位)
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
388
apps/teacher-portal/src/app/(app)/exams/[id]/proctoring/page.tsx
Normal file
388
apps/teacher-portal/src/app/(app)/exams/[id]/proctoring/page.tsx
Normal file
@@ -0,0 +1,388 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 监考面板页
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL ProctoringStatusQuery:按 examId 拉取监考状态(摘要 + 学生列表 + 近期事件)
|
||||
* - GraphQL ProctoringEventMutation:提交监考事件(WARNING/FLAG/NOTE)
|
||||
*
|
||||
* 布局:
|
||||
* - 顶部:标题 + "开始监考"/"结束监考" 按钮 + 手动刷新按钮
|
||||
* - 摘要卡片:应考数/在线数/已交卷数/异常数
|
||||
* - 左侧:学生状态列表(按状态筛选)
|
||||
* - 右侧:近期事件流(含操作按钮"标记"/"备注")
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
249
apps/teacher-portal/src/app/(app)/exams/new/page.tsx
Normal file
249
apps/teacher-portal/src/app/(app)/exams/new/page.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 新建考试表单页
|
||||
*
|
||||
* 数据来源(contract.md §2.4):GraphQL CreateExamMutation(P3 扩展,MSW mock)
|
||||
* - 通过 URL ?classId=xxx 预填班级 ID
|
||||
* - 表单字段:标题、描述、考试日期、时长、满分
|
||||
* - 乐观更新:本地先插入临时项,失败后回滚(基于 urql mutation cache 更新)
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
@@ -6,12 +6,14 @@
|
||||
* 数据来源(contract.md §2.4):GraphQL ClassExamsQuery(P3 扩展,MSW mock)
|
||||
* - 通过 URL ?classId=xxx 指定班级
|
||||
* - P2 阶段用 MSW mock 数据,待 teacher-bff P3 启用 core-edu 聚合
|
||||
* - P3:列表项可点击进入详情页,顶部提供"新建考试"入口
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
))}
|
||||
|
||||
598
apps/teacher-portal/src/app/(app)/grades/analytics/charts.tsx
Normal file
598
apps/teacher-portal/src/app/(app)/grades/analytics/charts.tsx
Normal 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)
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
217
apps/teacher-portal/src/app/(app)/grades/analytics/page.tsx
Normal file
217
apps/teacher-portal/src/app/(app)/grades/analytics/page.tsx
Normal 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 轴)
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
325
apps/teacher-portal/src/app/(app)/grades/entry/page.tsx
Normal file
325
apps/teacher-portal/src/app/(app)/grades/entry/page.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 成绩批量录入页
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL GradeEntryQuery:按 examId + classId 拉取学生成绩录入列表
|
||||
* - GraphQL SaveGradeEntriesMutation:批量保存成绩录入
|
||||
* - GraphQL ClassesQuery + ClassExamsQuery:班级 + 试卷下拉选项
|
||||
*
|
||||
* 试卷-班级年级匹配:仅展示同年级的试卷/班级(按 gradeId 过滤)
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +1,286 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Grades 页面 - 成绩查询
|
||||
* Grades 页面 - 成绩查询 + 录入 + 分析图表
|
||||
*
|
||||
* 数据来源(contract.md §2.4):GraphQL ExamGradesQuery(P3 扩展,MSW mock)
|
||||
* 数据来源(contract.md §2.4):
|
||||
* - GraphQL ExamGradesQuery(P3 扩展,MSW mock)拉取成绩列表
|
||||
* - GraphQL RecordGradeMutation(P3 扩展,MSW mock)录入成绩
|
||||
* - 通过 URL ?examId=xxx 指定考试
|
||||
* - P2 阶段用 MSW mock 数据,待 teacher-bff P3 启用 core-edu 聚合
|
||||
* - 成绩分析:平均分/最高分/最低分/及格率 + 纯 SVG 柱状图(不引入 recharts,P4 再迁移)
|
||||
*
|
||||
* 维护者:ai13(teacher-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);
|
||||
// P7:Excel 导入状态
|
||||
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);
|
||||
};
|
||||
|
||||
// P7:Excel 导入处理(解析 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>
|
||||
);
|
||||
|
||||
280
apps/teacher-portal/src/app/(app)/grades/report-card/page.tsx
Normal file
280
apps/teacher-portal/src/app/(app)/grades/report-card/page.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 学生成绩报告卡
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL ReportCardQuery:学生成绩报告卡(学年/学期/科目成绩 + 排名)
|
||||
*
|
||||
* 打印:window.print()
|
||||
* A4 纸质风格:bg-paper/text-ink 字体
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
285
apps/teacher-portal/src/app/(app)/grades/stats/page.tsx
Normal file
285
apps/teacher-portal/src/app/(app)/grades/stats/page.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 班级成绩统计页
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL GradeStatsQuery:班级成绩统计(avg/median/max/min/passRate/stdDev/rankings)
|
||||
* - GraphQL ClassesQuery:班级下拉选项
|
||||
*
|
||||
* 导出 CSV:Blob + URL.createObjectURL
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
319
apps/teacher-portal/src/app/(app)/homework/[id]/page.tsx
Normal file
319
apps/teacher-portal/src/app/(app)/homework/[id]/page.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 作业详情页 + 批改界面
|
||||
*
|
||||
* 数据来源(contract.md §2.4):
|
||||
* - GraphQL HomeworkDetailQuery(P3 扩展,MSW mock)获取作业详情 + 提交列表
|
||||
* - GraphQL RecordGradeMutation(P3 扩展,MSW mock)录入批改分数
|
||||
* - 动态路由 /homework/[id]
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 作业批量批改视图
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL HomeworkAssignmentSubmissionsQuery:按作业 ID 拉取所有提交列表
|
||||
* - GraphQL AiBatchGradingQuery:批量自动评分建议
|
||||
*
|
||||
* 顶部统计:已批/已提交/未提交
|
||||
* 表格:学生 | 提交时间 | 分数 | 状态 | "批改" 链接
|
||||
* "批量自动评分" 按钮触发 AI 评分建议
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
210
apps/teacher-portal/src/app/(app)/homework/new/page.tsx
Normal file
210
apps/teacher-portal/src/app/(app)/homework/new/page.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 布置作业表单页
|
||||
*
|
||||
* 数据来源(contract.md §2.4):GraphQL AssignHomeworkMutation(P3 扩展,MSW mock)
|
||||
* - 通过 URL ?classId=xxx 预填班级 ID
|
||||
* - 表单字段:班级选择、标题、描述、截止日期
|
||||
* - 乐观更新:成功后跳回列表;失败展示错误并保留输入
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
@@ -6,12 +6,14 @@
|
||||
* 数据来源(contract.md §2.4):GraphQL ClassHomeworkQuery(P3 扩展,MSW mock)
|
||||
* - 通过 URL ?classId=xxx 指定班级
|
||||
* - P2 阶段用 MSW mock 数据,待 teacher-bff P3 启用 core-edu 聚合
|
||||
* - P3:列表项可点击进入详情页(批改界面),顶部提供"布置作业"入口
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 单份提交批改页(核心)
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL HomeworkAssignmentSubmissionsQuery:按作业 ID 拉取所有提交(用于定位当前 + 上一份/下一份)
|
||||
* - GraphQL GradeSubmissionMutation:保存批改
|
||||
*
|
||||
* 布局:
|
||||
* - 顶部:学生信息 + 提交时间 + 状态
|
||||
* - 左侧:题目作答展示(题干 + 学生作答 + 正确答案)
|
||||
* - 右侧:评分表单(每题分数 + 反馈 + AI 评分建议按钮)
|
||||
* - 底部:上一份/下一份导航
|
||||
*
|
||||
* AI 辅助按钮:mock 流式返回评分建议(逐字显示,setInterval 模拟 SSE)
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 扫描批改视图页
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL SubmissionScanGradingQuery:按 submissionId 拉取扫描批改视图
|
||||
* - GraphQL SaveScanGradingMutation:保存扫描批改
|
||||
*
|
||||
* 布局:
|
||||
* - 顶部:学生信息 + 提交时间
|
||||
* - 左侧:扫描图片预览(可缩放 zoom in/out)
|
||||
* - 右侧:识别作答 + 评分表单(每题:题号 + 题干 + 已识别作答 + AI 评分建议 + 分数 input + 反馈 textarea)
|
||||
* - 底部:"保存批改" 调用 SaveScanGradingMutation + "上一份"/"下一份" 导航
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
203
apps/teacher-portal/src/app/(app)/homework/submissions/page.tsx
Normal file
203
apps/teacher-portal/src/app/(app)/homework/submissions/page.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 作业提交评审列表
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL HomeworkSubmissionsQuery:按作业维度统计提交/已批数
|
||||
* - GraphQL ClassesQuery:班级下拉选项
|
||||
*
|
||||
* 列出所有作业的提交统计,"去批改" 链接跳转批量批改视图
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
@@ -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)。
|
||||
*
|
||||
* 维护者:ai13(teacher-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 ≤ 掌握度 < 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">掌握度 < 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>
|
||||
);
|
||||
}
|
||||
90
apps/teacher-portal/src/app/(app)/knowledge-graph/page.tsx
Normal file
90
apps/teacher-portal/src/app/(app)/knowledge-graph/page.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* KnowledgeGraph 页面 - 知识图谱可视化
|
||||
*
|
||||
* 数据来源(P4 扩展,MSW mock):GraphQL KnowledgeGraphQuery
|
||||
* - 支持按科目筛选(下拉选择)
|
||||
* - 纯 SVG 绘制节点关系图(不安装 d3/recharts)
|
||||
* - CSR + @next/dynamic 懒加载(ssr: false)
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
607
apps/teacher-portal/src/app/(app)/leave/page.tsx
Normal file
607
apps/teacher-portal/src/app/(app)/leave/page.tsx
Normal file
@@ -0,0 +1,607 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 请假审批页 - 教师请假申请管理
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL LeaveRequestsQuery:按 dataScope/status 筛选 + 分页
|
||||
* - GraphQL ApproveLeaveRequestMutation:批准/拒绝请假
|
||||
* - GraphQL SubmitLeaveRequestMutation:教师提交请假申请
|
||||
*
|
||||
* 功能:
|
||||
* - dataScope 筛选(我的/本班/本年级/全校)
|
||||
* - 状态筛选(全部/待审批/已批准/已拒绝)
|
||||
* - 审批对话框(批准/拒绝 + 评论)
|
||||
* - 提交请假申请模态框(类型/起止日期/原因)
|
||||
*
|
||||
* 维护者:ai13(teacher-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 +
|
||||
SubmitLeaveRequestMutation(P7 扩展,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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 课案编辑器(核心,P7-insights)
|
||||
*
|
||||
* 数据来源:
|
||||
* - GraphQL LessonPlanDetailQuery
|
||||
* - GraphQL UpdateLessonPlanMutation
|
||||
* - GraphQL AIGenerateLessonPlanMutation
|
||||
*
|
||||
* 三栏布局:
|
||||
* - 左侧:教材/章节选择 + 班级关联
|
||||
* - 中间:富文本编辑器(contenteditable,工具栏:加粗/斜体/标题/列表)
|
||||
* - 右侧:AI 助手面板(生成 + 历史记录)
|
||||
*
|
||||
* 顶部:保存/发布/导出 PDF(window.print())
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
217
apps/teacher-portal/src/app/(app)/lesson-plans/calendar/page.tsx
Normal file
217
apps/teacher-portal/src/app/(app)/lesson-plans/calendar/page.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 课案日历视图(P7-insights)
|
||||
*
|
||||
* 数据来源:GraphQL LessonPlanCalendarQuery
|
||||
*
|
||||
* 功能:
|
||||
* - 月份切换
|
||||
* - 日历网格(每天显示课案数,点击展开当天课案列表)
|
||||
* - CSS Grid 实现
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
377
apps/teacher-portal/src/app/(app)/lesson-plans/heatmap/page.tsx
Normal file
377
apps/teacher-portal/src/app/(app)/lesson-plans/heatmap/page.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 课标覆盖热力图页
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL LessonPlanHeatmapQuery:按教材 ID 拉取热力图(教材知识点 × 课案覆盖矩阵)
|
||||
*
|
||||
* 布局:
|
||||
* - 顶部:教材选择 select + 导出 CSV 按钮
|
||||
* - 热力图矩阵(X 轴:教材知识点,Y 轴:课案)
|
||||
* - 每个格子颜色深浅表示覆盖度(0-3:浅 → 深)
|
||||
* - SVG 实现,hover 显示 tooltip(知识点名/课案名/覆盖数)
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
|
||||
{/* Tooltip(hover 显示) */}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
185
apps/teacher-portal/src/app/(app)/lesson-plans/library/page.tsx
Normal file
185
apps/teacher-portal/src/app/(app)/lesson-plans/library/page.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 校内课案库(P7-insights)
|
||||
*
|
||||
* 数据来源:GraphQL LessonPlanLibraryQuery + ForkLessonPlanMutation
|
||||
*
|
||||
* 功能:
|
||||
* - 学科 + 年级筛选
|
||||
* - 列表:标题/作者/学科/年级/章节/评分/Fork 数/操作
|
||||
* - "Fork 到我的课案" 调用 ForkLessonPlanMutation
|
||||
*
|
||||
* 维护者:ai13(teacher-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 · 浏览校内同仁分享的课案并 Fork(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={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>
|
||||
);
|
||||
}
|
||||
220
apps/teacher-portal/src/app/(app)/lesson-plans/new/page.tsx
Normal file
220
apps/teacher-portal/src/app/(app)/lesson-plans/new/page.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 新建课案页(P7-insights)
|
||||
*
|
||||
* 数据来源:GraphQL CreateLessonPlanMutation
|
||||
*
|
||||
* 功能:
|
||||
* - 模板选择器(5 个模板)
|
||||
* - 表单:标题/学科/年级/章节/教材关联
|
||||
* - "选择模板后创建" 跳转到 /lesson-plans/[planId]/edit
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
162
apps/teacher-portal/src/app/(app)/lesson-plans/page.tsx
Normal file
162
apps/teacher-portal/src/app/(app)/lesson-plans/page.tsx
Normal 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)
|
||||
*
|
||||
* 维护者:ai13(teacher-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-insights,MSW 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>
|
||||
);
|
||||
}
|
||||
260
apps/teacher-portal/src/app/(app)/notifications/page.tsx
Normal file
260
apps/teacher-portal/src/app/(app)/notifications/page.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Notifications 页面 - 通知中心(P5)
|
||||
*
|
||||
* 数据来源(02-architecture-design.md §5):
|
||||
* - GraphQL MyNotificationsQuery(持久化通知列表)
|
||||
* - WebSocket 实时推送(push-gateway,use-notifications-websocket hook)
|
||||
*
|
||||
* 功能:
|
||||
* - 通知列表(标题、消息、时间、类型)
|
||||
* - 未读高亮 + 标记已读(乐观更新) + 全部标记已读
|
||||
* - 类型筛选(全部 / 未读 / 作业 / 考试 / 广播)
|
||||
* - WebSocket 连接状态指示器
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
451
apps/teacher-portal/src/app/(app)/practice/page.tsx
Normal file
451
apps/teacher-portal/src/app/(app)/practice/page.tsx
Normal 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. 学生排名表 + 未参与提醒
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
886
apps/teacher-portal/src/app/(app)/questions/page.tsx
Normal file
886
apps/teacher-portal/src/app/(app)/questions/page.tsx
Normal file
@@ -0,0 +1,886 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 题库主页 - 六维筛选 + CRUD + 批量导入导出
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL QuestionsLibraryQuery:题库列表(六维筛选 + 分页 + 总数)
|
||||
* - GraphQL QuestionCreate/Update/Delete:题目 CRUD
|
||||
* - GraphQL QuestionBatchImport:批量导入
|
||||
* - GraphQL QuestionBatchExport:批量导出(查询后用 Blob 下载 JSON)
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
629
apps/teacher-portal/src/app/(app)/schedule-changes/page.tsx
Normal file
629
apps/teacher-portal/src/app/(app)/schedule-changes/page.tsx
Normal file
@@ -0,0 +1,629 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 调课申请页 - 教师调课申请管理
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL ScheduleChangesQuery:调课申请列表 + 分页
|
||||
* - GraphQL SubmitScheduleChangeMutation:提交调课/代课申请
|
||||
*
|
||||
* 功能:
|
||||
* - 状态筛选(全部/待审批/已批准/已拒绝)
|
||||
* - 类型筛选(全部/调课/代课)
|
||||
* - 提交调课申请模态框(原班级/原日期/原节次/调整后日期/类型/代课教师/原因)
|
||||
* - 状态徽章(待审批黄/已批准绿/已拒绝红)
|
||||
*
|
||||
* 维护者:ai13(teacher-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 + SubmitScheduleChangeMutation(P7
|
||||
扩展,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>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +1,97 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Settings 页面 - 个人设置
|
||||
* Settings 页面 - 个人设置(P3 启用编辑模式)
|
||||
*
|
||||
* 数据来源(ARB-001 §1.2):GraphQL MeQuery(只读展示)
|
||||
* - P2 阶段仅展示当前用户信息(P2 纯读,无 Mutation)
|
||||
* - P3+ 启用 UpdateUserMutation 后开启编辑功能
|
||||
* 数据来源(ARB-001 §1.2):
|
||||
* - GraphQL MeQuery(读取当前用户信息)
|
||||
* - GraphQL UpdateUserMutation(P3 扩展,MSW mock)更新姓名/邮箱
|
||||
* - 编辑按钮切换只读/编辑模式
|
||||
* - 乐观更新:本地立即展示新值;失败回滚由错误提示
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
|
||||
454
apps/teacher-portal/src/app/(app)/textbooks/[id]/page.tsx
Normal file
454
apps/teacher-portal/src/app/(app)/textbooks/[id]/page.tsx
Normal file
@@ -0,0 +1,454 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 教材阅读器 - 章节树 + 内容 + 阅读设置
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL TextbookDetailQuery:教材详情(章节树 + 知识点列表)
|
||||
*
|
||||
* 布局:左侧章节树(可展开/折叠)+ 中间章节内容(简单 markdown 渲染)+ 右侧设置面板
|
||||
* 导航:"上一章"/"下一章" 按钮切换章节
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
478
apps/teacher-portal/src/app/(app)/textbooks/page.tsx
Normal file
478
apps/teacher-portal/src/app/(app)/textbooks/page.tsx
Normal file
@@ -0,0 +1,478 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 教材列表 - 筛选 + 新建 + 表格 + 分页
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL TextbooksQuery:教材列表(q/subject/grade 筛选 + 分页 + 总数)
|
||||
* - GraphQL TextbookCreateMutation:新建教材
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user