docs(admin-portal): 新增 nextstep-v2.md 记录下游核查结果
v1 声称完成的下游工作经核查实际未完成: - api-gateway: /api/admin/graphql 路由未注册,go vet 编译失败 - teacher-bff: resolver 已完成但 schema 未同步(命名空间 vs 扁平) - iam: proto 缺 BatchGetUsers rpc 声明 v2 记录详细核查证据和修复要求
This commit is contained in:
@@ -11,12 +11,14 @@
|
||||
*/
|
||||
|
||||
import { useQuery } from "urql";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import { DashboardQuery } from "@/lib/graphql";
|
||||
import type { Class } from "@/lib/graphql";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const t = useTranslations("dashboard");
|
||||
const [result] = useQuery({ query: DashboardQuery });
|
||||
|
||||
if (result.fetching) {
|
||||
@@ -31,12 +33,12 @@ export default function DashboardPage() {
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl font-serif text-ink">欢迎</h1>
|
||||
<h1 className="text-3xl font-serif text-ink">{t("title")}</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}
|
||||
{t("error.loadFailed", { message: result.error.message })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -47,7 +49,7 @@ export default function DashboardPage() {
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<Empty title="暂无数据" description="仪表盘数据尚未就绪" />
|
||||
<Empty title={t("empty.title")} description={t("empty.description")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -57,10 +59,15 @@ export default function DashboardPage() {
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl font-serif text-ink">欢迎,{user.name}</h1>
|
||||
<h1 className="text-3xl font-serif text-ink">
|
||||
{t("greeting", { name: user.name })}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
{user.email} · 角色:{user.roles.join(", ") || "无"} · 数据范围:
|
||||
{user.dataScope}
|
||||
{t("subtitle", {
|
||||
email: user.email,
|
||||
roles: user.roles.join(", ") || t("subtitleNoRoles"),
|
||||
dataScope: user.dataScope,
|
||||
})}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -70,13 +77,13 @@ export default function DashboardPage() {
|
||||
<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">
|
||||
班级总数
|
||||
{t("stats.classes")}
|
||||
</p>
|
||||
<p className="mt-3 text-4xl font-serif text-ink">{classes.length}</p>
|
||||
</div>
|
||||
<div className="p-6 border border-rule rounded-card">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
考试总数
|
||||
{t("stats.exams")}
|
||||
</p>
|
||||
<p className="mt-3 text-4xl font-serif text-ink">
|
||||
{stats.totalExams}
|
||||
@@ -84,7 +91,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
<div className="p-6 border border-rule rounded-card">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
待批改
|
||||
{t("stats.pendingGrading")}
|
||||
</p>
|
||||
<p className="mt-3 text-4xl font-serif text-accent">
|
||||
{stats.pendingGrading}
|
||||
@@ -96,22 +103,25 @@ export default function DashboardPage() {
|
||||
<section>
|
||||
<div className="flex items-baseline justify-between mb-4">
|
||||
<h2 className="text-xl font-serif text-ink">
|
||||
我的班级
|
||||
{t("classes.title")}
|
||||
<span className="ml-2 text-sm font-sans text-ink-muted">
|
||||
{classes.length} 个
|
||||
{t("classes.count", { count: classes.length })}
|
||||
</span>
|
||||
</h2>
|
||||
<Link
|
||||
href="/classes"
|
||||
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
|
||||
>
|
||||
查看全部
|
||||
{t("classes.viewAll")}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="rule-thin mb-6" />
|
||||
|
||||
{classes.length === 0 ? (
|
||||
<Empty title="暂无班级" description="请联系管理员分配班级" />
|
||||
<Empty
|
||||
title={t("classes.emptyTitle")}
|
||||
description={t("classes.emptyDescription")}
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-0">
|
||||
{(classes as Class[]).slice(0, 5).map((cls) => (
|
||||
@@ -122,18 +132,18 @@ export default function DashboardPage() {
|
||||
<div className="col-span-8">
|
||||
<h3 className="text-lg font-serif text-ink">{cls.name}</h3>
|
||||
<p className="mt-1 text-tiny text-ink-muted">
|
||||
年级:{cls.gradeId}
|
||||
{t("classes.grade", { gradeId: cls.gradeId })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-2 text-sm text-ink-muted">
|
||||
{cls.studentCount} 名学生
|
||||
{t("classes.studentCount", { count: cls.studentCount })}
|
||||
</div>
|
||||
<div className="col-span-2 text-right">
|
||||
<Link
|
||||
href={`/students?classId=${cls.id}`}
|
||||
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
|
||||
>
|
||||
查看学生
|
||||
{t("classes.viewStudents")}
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useQuery } from "urql";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
@@ -25,13 +26,29 @@ 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";
|
||||
|
||||
// 懒加载 5 个 SVG 图表组件(CSR only,减少首屏 JS)
|
||||
// 页面骨架(header/筛选器/布局)立即渲染,图表按需加载
|
||||
const TrendLineChart = dynamic(
|
||||
() => import("./charts").then((m) => m.TrendLineChart),
|
||||
{ ssr: false, loading: () => <Loading lines={3} /> },
|
||||
);
|
||||
const DistributionBarChart = dynamic(
|
||||
() => import("./charts").then((m) => m.DistributionBarChart),
|
||||
{ ssr: false, loading: () => <Loading lines={3} /> },
|
||||
);
|
||||
const SubjectComparisonChart = dynamic(
|
||||
() => import("./charts").then((m) => m.SubjectComparisonChart),
|
||||
{ ssr: false, loading: () => <Loading lines={3} /> },
|
||||
);
|
||||
const ClassComparisonChart = dynamic(
|
||||
() => import("./charts").then((m) => m.ClassComparisonChart),
|
||||
{ ssr: false, loading: () => <Loading lines={3} /> },
|
||||
);
|
||||
const KnowledgeRadarChart = dynamic(
|
||||
() => import("./charts").then((m) => m.KnowledgeRadarChart),
|
||||
{ ssr: false, loading: () => <Loading lines={3} /> },
|
||||
);
|
||||
|
||||
/** 学期选项 */
|
||||
const SEMESTERS = [
|
||||
@@ -174,26 +191,20 @@ export default function GradeAnalyticsPage(): React.ReactNode {
|
||||
{/* 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>
|
||||
<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>
|
||||
<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>
|
||||
<h2 className="text-base font-serif text-ink mb-3">班级对比</h2>
|
||||
<p className="text-tiny text-ink-muted mb-3">
|
||||
* 标记表示与其他班级存在显著差异
|
||||
</p>
|
||||
@@ -202,9 +213,7 @@ export default function GradeAnalyticsPage(): React.ReactNode {
|
||||
|
||||
{/* 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>
|
||||
<h2 className="text-base font-serif text-ink mb-3">知识点掌握度</h2>
|
||||
<p className="text-tiny text-ink-muted mb-3">
|
||||
五大知识点掌握度雷达图
|
||||
</p>
|
||||
|
||||
@@ -15,12 +15,19 @@
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useQuery, useMutation } from "urql";
|
||||
import { useTranslations } from "next-intl";
|
||||
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";
|
||||
import {
|
||||
GradeEntryQuery,
|
||||
SaveGradeEntriesMutation,
|
||||
} from "@/lib/graphql-p7-grades";
|
||||
import type {
|
||||
GradeEntryItem,
|
||||
SaveGradeEntryInput,
|
||||
} from "@/lib/graphql-p7-grades";
|
||||
|
||||
/** 单行编辑状态 */
|
||||
interface EntryRow {
|
||||
@@ -30,6 +37,7 @@ interface EntryRow {
|
||||
}
|
||||
|
||||
export default function GradeEntryPage(): React.ReactNode {
|
||||
const t = useTranslations("grades");
|
||||
const [classId, setClassId] = useState("");
|
||||
const [examId, setExamId] = useState("");
|
||||
|
||||
@@ -88,7 +96,7 @@ export default function GradeEntryPage(): React.ReactNode {
|
||||
|
||||
const handleSaveAll = async () => {
|
||||
if (!examId || !classId) {
|
||||
setSaveError("请先选择班级和试卷");
|
||||
setSaveError(t("entry.error.classExamRequired"));
|
||||
return;
|
||||
}
|
||||
const payload: SaveGradeEntryInput[] = [];
|
||||
@@ -104,7 +112,7 @@ export default function GradeEntryPage(): React.ReactNode {
|
||||
});
|
||||
}
|
||||
if (payload.length === 0) {
|
||||
setSaveError("请至少填写一条有效成绩");
|
||||
setSaveError(t("entry.error.noValidEntries"));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
@@ -119,7 +127,11 @@ export default function GradeEntryPage(): React.ReactNode {
|
||||
setSaveError(res.error.message);
|
||||
return;
|
||||
}
|
||||
setSavedMsg(`已保存 ${res.data?.saveGradeEntries?.savedCount ?? payload.length} 条成绩`);
|
||||
setSavedMsg(
|
||||
t("entry.success.saved", {
|
||||
count: res.data?.saveGradeEntries?.savedCount ?? payload.length,
|
||||
}),
|
||||
);
|
||||
// 刷新录入列表
|
||||
reexecuteEntry({ requestPolicy: "network-only" });
|
||||
};
|
||||
@@ -132,13 +144,11 @@ export default function GradeEntryPage(): React.ReactNode {
|
||||
href="/grades"
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回成绩查询
|
||||
{t("entry.back")}
|
||||
</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>
|
||||
<h1 className="text-3xl font-serif text-ink">{t("entry.title")}</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">{t("entry.subtitle")}</p>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
@@ -147,10 +157,14 @@ export default function GradeEntryPage(): React.ReactNode {
|
||||
<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
|
||||
htmlFor="entry-class-select"
|
||||
className="block text-tiny uppercase tracking-wide text-ink-muted mb-1"
|
||||
>
|
||||
{t("entry.label.class")}
|
||||
</label>
|
||||
<select
|
||||
id="entry-class-select"
|
||||
value={classId}
|
||||
onChange={(e) => {
|
||||
setClassId(e.target.value);
|
||||
@@ -158,7 +172,7 @@ export default function GradeEntryPage(): React.ReactNode {
|
||||
}}
|
||||
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="">{t("entry.placeholder.selectClass")}</option>
|
||||
{classes.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
@@ -167,16 +181,20 @@ export default function GradeEntryPage(): React.ReactNode {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
试卷
|
||||
<label
|
||||
htmlFor="entry-exam-select"
|
||||
className="block text-tiny uppercase tracking-wide text-ink-muted mb-1"
|
||||
>
|
||||
{t("entry.label.exam")}
|
||||
</label>
|
||||
<select
|
||||
id="entry-exam-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>
|
||||
<option value="">{t("entry.placeholder.selectExam")}</option>
|
||||
{exams.map((ex) => (
|
||||
<option key={ex.id} value={ex.id}>
|
||||
{ex.title}({ex.totalScore} 分)
|
||||
@@ -187,38 +205,48 @@ export default function GradeEntryPage(): React.ReactNode {
|
||||
</div>
|
||||
{selectedClass && sameGradeClassNames.length > 0 && (
|
||||
<p className="text-tiny text-ink-muted">
|
||||
同年级班级:{sameGradeClassNames.join("、")}(年级 {selectedClass.gradeId})
|
||||
{t("entry.info.sameGrade", {
|
||||
names: sameGradeClassNames.join("、"),
|
||||
gradeId: selectedClass.gradeId,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{!classId || !examId ? (
|
||||
<Empty
|
||||
title="请选择班级和试卷"
|
||||
description="选择班级后选择试卷,再拉取学生成绩录入列表"
|
||||
title={t("entry.empty.title")}
|
||||
description={t("entry.empty.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}
|
||||
{t("entry.error.loadFailed", {
|
||||
message: entryResult.error.message,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<Empty title="暂无学生" description="该班级尚未导入学生名单" />
|
||||
<Empty
|
||||
title={t("entry.emptyStudents.title")}
|
||||
description={t("entry.emptyStudents.description")}
|
||||
/>
|
||||
) : (
|
||||
<section>
|
||||
<div className="flex items-baseline justify-between mb-4">
|
||||
<h2 className="text-xl font-serif text-ink">
|
||||
学生成绩录入
|
||||
{t("entry.table.title")}
|
||||
<span className="ml-2 text-sm font-sans text-ink-muted">
|
||||
{entries.length} 名学生
|
||||
{t("entry.table.studentCount", { count: entries.length })}
|
||||
</span>
|
||||
</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
{saveError && (
|
||||
<span className="text-tiny text-danger">{saveError}</span>
|
||||
<span role="alert" className="text-tiny text-danger">
|
||||
{saveError}
|
||||
</span>
|
||||
)}
|
||||
{savedMsg && !submitting && (
|
||||
<span className="text-tiny text-success">{savedMsg}</span>
|
||||
@@ -229,7 +257,9 @@ export default function GradeEntryPage(): React.ReactNode {
|
||||
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 ? "保存中..." : "保存全部"}
|
||||
{submitting
|
||||
? t("entry.button.submitting")
|
||||
: t("entry.button.saveAll")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -241,16 +271,16 @@ export default function GradeEntryPage(): React.ReactNode {
|
||||
<thead className="bg-subtle">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
学生
|
||||
{t("entry.table.header.student")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
学号
|
||||
{t("entry.table.header.studentNo")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted w-32">
|
||||
分数
|
||||
{t("entry.table.header.score")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
反馈
|
||||
{t("entry.table.header.feedback")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -258,10 +288,7 @@ export default function GradeEntryPage(): React.ReactNode {
|
||||
{entries.map((e) => {
|
||||
const row = rows[e.studentId];
|
||||
return (
|
||||
<tr
|
||||
key={e.studentId}
|
||||
className="border-t border-rule"
|
||||
>
|
||||
<tr key={e.studentId} className="border-t border-rule">
|
||||
<td className="px-4 py-3 font-serif text-ink">
|
||||
{e.studentName}
|
||||
</td>
|
||||
@@ -284,6 +311,8 @@ export default function GradeEntryPage(): React.ReactNode {
|
||||
},
|
||||
}))
|
||||
}
|
||||
aria-label={`${e.studentName} ${t("entry.table.header.score")}`}
|
||||
aria-invalid={Boolean(saveError)}
|
||||
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"
|
||||
/>
|
||||
@@ -302,8 +331,9 @@ export default function GradeEntryPage(): React.ReactNode {
|
||||
},
|
||||
}))
|
||||
}
|
||||
aria-label={`${e.studentName} ${t("entry.table.header.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="可选:批改意见"
|
||||
placeholder={t("entry.placeholder.feedback")}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -315,7 +345,9 @@ export default function GradeEntryPage(): React.ReactNode {
|
||||
|
||||
{saveResult.data && !saveError && !submitting && (
|
||||
<p className="mt-4 text-tiny text-success">
|
||||
已保存 {saveResult.data.saveGradeEntries?.savedCount ?? 0} 条成绩记录
|
||||
{t("entry.success.savedRecords", {
|
||||
count: saveResult.data.saveGradeEntries?.savedCount ?? 0,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { useState, useMemo, useRef, Suspense } from "react";
|
||||
import { useQuery, useMutation } from "urql";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import { ExamGradesQuery, RecordGradeMutation } from "@/lib/graphql";
|
||||
@@ -66,6 +67,7 @@ function computeStats(grades: GradeItem[]) {
|
||||
}
|
||||
|
||||
function GradesContent() {
|
||||
const t = useTranslations("grades");
|
||||
const searchParams = useSearchParams();
|
||||
const initialExamId = searchParams.get("examId") ?? "";
|
||||
const [examId, setExamId] = useState(initialExamId);
|
||||
@@ -102,16 +104,16 @@ function GradesContent() {
|
||||
setFormError(null);
|
||||
|
||||
if (!studentId.trim()) {
|
||||
setFormError("请填写学生 ID");
|
||||
setFormError(t("error.studentIdRequired"));
|
||||
return;
|
||||
}
|
||||
if (!examId) {
|
||||
setFormError("请先指定考试 ID");
|
||||
setFormError(t("error.examIdRequired"));
|
||||
return;
|
||||
}
|
||||
const scoreNum = Number(score);
|
||||
if (Number.isNaN(scoreNum)) {
|
||||
setFormError("分数必须为数字");
|
||||
setFormError(t("error.scoreInvalid"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -140,12 +142,12 @@ function GradesContent() {
|
||||
};
|
||||
|
||||
// P7:Excel 导入处理(解析 CSV/文本为 entries,调用 SaveGradeEntriesMutation)
|
||||
const handleExcelImport = async (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const handleExcelImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !examId) {
|
||||
setImportMsg(examId ? "请选择文件" : "请先指定考试 ID");
|
||||
setImportMsg(
|
||||
examId ? t("error.fileRequired") : t("error.examIdRequired"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setImportMsg(null);
|
||||
@@ -163,20 +165,20 @@ function GradesContent() {
|
||||
entries.push({ studentId: sid, score: scoreNum });
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
setImportMsg("未解析到有效数据(格式:学号,分数)");
|
||||
setImportMsg(t("error.noValidData"));
|
||||
return;
|
||||
}
|
||||
const res = await saveEntries({
|
||||
input: { examId, classId: examId, entries },
|
||||
});
|
||||
if (res.error) {
|
||||
setImportMsg(`导入失败:${res.error.message}`);
|
||||
setImportMsg(t("error.importFailed", { message: res.error.message }));
|
||||
return;
|
||||
}
|
||||
setImportMsg(`已导入 ${entries.length} 条成绩`);
|
||||
setImportMsg(t("success.imported", { count: entries.length }));
|
||||
reexecuteQuery({ requestPolicy: "network-only" });
|
||||
} catch {
|
||||
setImportMsg("文件读取失败");
|
||||
setImportMsg(t("error.fileReadFailed"));
|
||||
}
|
||||
// 清空 file input 以便重复选择同一文件
|
||||
if (fileInputRef.current) {
|
||||
@@ -190,8 +192,7 @@ function GradesContent() {
|
||||
const header = "学生ID,学生姓名,分数,反馈\n";
|
||||
const rows = grades
|
||||
.map(
|
||||
(g) =>
|
||||
`${g.studentId},${g.studentName},${g.score},${g.feedback ?? ""}`,
|
||||
(g) => `${g.studentId},${g.studentName},${g.score},${g.feedback ?? ""}`,
|
||||
)
|
||||
.join("\n");
|
||||
const csv = "\uFEFF" + header + rows;
|
||||
@@ -208,10 +209,8 @@ function GradesContent() {
|
||||
<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 ExamGradesQuery + RecordGradeMutation · core-edu 域(P3 扩展,MSW mock)
|
||||
</p>
|
||||
<h1 className="text-3xl font-serif text-ink">{t("title")}</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">{t("subtitle")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
@@ -220,7 +219,7 @@ function GradesContent() {
|
||||
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
|
||||
{t("button.exportCsv")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -228,13 +227,14 @@ function GradesContent() {
|
||||
disabled={!examId}
|
||||
className="px-3 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle disabled:opacity-50"
|
||||
>
|
||||
Excel 导入
|
||||
{t("button.excelImport")}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.csv"
|
||||
onChange={handleExcelImport}
|
||||
aria-label={t("button.excelImport")}
|
||||
className="hidden"
|
||||
/>
|
||||
{examId && (
|
||||
@@ -243,7 +243,7 @@ function GradesContent() {
|
||||
onClick={() => setShowForm((v) => !v)}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
|
||||
>
|
||||
{showForm ? "收起录入" : "录入成绩"}
|
||||
{showForm ? t("button.hideForm") : t("button.showForm")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -255,25 +255,25 @@ function GradesContent() {
|
||||
href="/grades/entry"
|
||||
className="text-sm text-accent hover:opacity-70"
|
||||
>
|
||||
→ 录入成绩
|
||||
→ {t("nav.entry")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/grades/stats"
|
||||
className="text-sm text-accent hover:opacity-70"
|
||||
>
|
||||
→ 统计
|
||||
→ {t("nav.stats")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/grades/analytics"
|
||||
className="text-sm text-accent hover:opacity-70"
|
||||
>
|
||||
→ 分析
|
||||
→ {t("nav.analytics")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/grades/report-card"
|
||||
className="text-sm text-accent hover:opacity-70"
|
||||
>
|
||||
→ 报告卡
|
||||
→ {t("nav.reportCard")}
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
@@ -284,61 +284,88 @@ function GradesContent() {
|
||||
<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">
|
||||
考试 ID
|
||||
<label
|
||||
htmlFor="exam-id-input"
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted"
|
||||
>
|
||||
{t("label.examId")}
|
||||
</label>
|
||||
<input
|
||||
id="exam-id-input"
|
||||
type="text"
|
||||
value={examId}
|
||||
onChange={(e) => setExamId(e.target.value)}
|
||||
aria-label={t("label.examId")}
|
||||
className="flex-1 max-w-md px-3 py-2 bg-transparent border-b border-rule text-sm font-mono"
|
||||
placeholder="输入考试 UUID"
|
||||
placeholder={t("placeholder.examId")}
|
||||
/>
|
||||
</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>
|
||||
<h2 className="text-base font-serif text-ink mb-3">
|
||||
{t("form.title")}
|
||||
</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
|
||||
htmlFor="grade-student-id"
|
||||
className="block text-tiny uppercase tracking-wide text-ink-muted mb-1"
|
||||
>
|
||||
{t("form.label.studentId")}{" "}
|
||||
<span className="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="grade-student-id"
|
||||
type="text"
|
||||
value={studentId}
|
||||
onChange={(e) => setStudentId(e.target.value)}
|
||||
aria-label={t("form.label.studentId")}
|
||||
aria-invalid={Boolean(formError)}
|
||||
aria-describedby={formError ? "grade-form-error" : undefined}
|
||||
className="w-full px-3 py-2 bg-paper border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
placeholder="学生 UUID"
|
||||
placeholder={t("form.placeholder.studentId")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
分数 <span className="text-danger">*</span>
|
||||
<label
|
||||
htmlFor="grade-score"
|
||||
className="block text-tiny uppercase tracking-wide text-ink-muted mb-1"
|
||||
>
|
||||
{t("form.label.score")} <span className="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="grade-score"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={score}
|
||||
onChange={(e) => setScore(e.target.value)}
|
||||
aria-label={t("form.label.score")}
|
||||
aria-invalid={Boolean(formError)}
|
||||
aria-describedby={formError ? "grade-form-error" : undefined}
|
||||
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
|
||||
htmlFor="grade-feedback"
|
||||
className="block text-tiny uppercase tracking-wide text-ink-muted mb-1"
|
||||
>
|
||||
{t("form.label.feedback")}
|
||||
</label>
|
||||
<input
|
||||
id="grade-feedback"
|
||||
type="text"
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
aria-label={t("form.label.feedback")}
|
||||
className="w-full px-3 py-2 bg-paper border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
placeholder="可选:批改意见"
|
||||
placeholder={t("form.placeholder.feedback")}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-3 flex items-center gap-3">
|
||||
@@ -347,20 +374,30 @@ function GradesContent() {
|
||||
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 ? "录入中..." : "保存成绩"}
|
||||
{submitting
|
||||
? t("form.button.submitting")
|
||||
: t("form.button.save")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowForm(false)}
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
取消
|
||||
{t("form.button.cancel")}
|
||||
</button>
|
||||
{formError && (
|
||||
<span className="text-tiny text-danger">{formError}</span>
|
||||
<span
|
||||
id="grade-form-error"
|
||||
role="alert"
|
||||
className="text-tiny text-danger"
|
||||
>
|
||||
{formError}
|
||||
</span>
|
||||
)}
|
||||
{recordResult.data && !formError && !submitting && (
|
||||
<span className="text-tiny text-success">已保存</span>
|
||||
<span className="text-tiny text-success">
|
||||
{t("form.status.saved")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
@@ -372,23 +409,31 @@ function GradesContent() {
|
||||
) : gradesResult.error ? (
|
||||
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
|
||||
<p className="text-sm px-3 text-danger">
|
||||
加载失败:{gradesResult.error.message}
|
||||
{t("error.loadFailed", { message: gradesResult.error.message })}
|
||||
</p>
|
||||
</div>
|
||||
) : !examId ? (
|
||||
<Empty title="请输入考试 ID" description="输入考试 UUID 后查询成绩" />
|
||||
<Empty
|
||||
title={t("empty.noExamIdTitle")}
|
||||
description={t("empty.noExamIdDescription")}
|
||||
/>
|
||||
) : grades.length === 0 ? (
|
||||
<Empty title="暂无成绩" description="该考试尚未录入成绩" />
|
||||
<Empty
|
||||
title={t("empty.noGradesTitle")}
|
||||
description={t("empty.noGradesDescription")}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* 成绩分析 */}
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-serif text-ink mb-2">成绩分析</h2>
|
||||
<h2 className="text-xl font-serif text-ink mb-2">
|
||||
{t("analysis.title")}
|
||||
</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">
|
||||
平均分
|
||||
{t("analysis.label.average")}
|
||||
</dt>
|
||||
<dd className="mt-1 text-2xl font-serif text-accent">
|
||||
{stats.average.toFixed(1)}
|
||||
@@ -396,7 +441,7 @@ function GradesContent() {
|
||||
</div>
|
||||
<div className="p-3 border border-rule rounded-card">
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
最高分
|
||||
{t("analysis.label.max")}
|
||||
</dt>
|
||||
<dd className="mt-1 text-2xl font-serif text-success">
|
||||
{stats.max}
|
||||
@@ -404,7 +449,7 @@ function GradesContent() {
|
||||
</div>
|
||||
<div className="p-3 border border-rule rounded-card">
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
最低分
|
||||
{t("analysis.label.min")}
|
||||
</dt>
|
||||
<dd className="mt-1 text-2xl font-serif text-danger">
|
||||
{stats.min}
|
||||
@@ -412,7 +457,7 @@ function GradesContent() {
|
||||
</div>
|
||||
<div className="p-3 border border-rule rounded-card">
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
及格率
|
||||
{t("analysis.label.passRate")}
|
||||
</dt>
|
||||
<dd className="mt-1 text-2xl font-serif text-ink">
|
||||
{stats.passRate.toFixed(1)}%
|
||||
@@ -422,7 +467,9 @@ function GradesContent() {
|
||||
|
||||
{/* 柱状图(纯 SVG,无外部依赖) */}
|
||||
<div className="p-4 border border-rule rounded-card bg-surface">
|
||||
<h3 className="text-sm font-serif text-ink mb-4">分数段分布</h3>
|
||||
<h3 className="text-sm font-serif text-ink mb-4">
|
||||
{t("analysis.chart.title")}
|
||||
</h3>
|
||||
<div className="flex items-end gap-4 h-40">
|
||||
{stats.bands.map((band) => {
|
||||
const heightPct = (band.count / maxBandCount) * 100;
|
||||
@@ -437,7 +484,10 @@ function GradesContent() {
|
||||
</span>
|
||||
<div
|
||||
className="w-full bg-accent rounded-t-button"
|
||||
style={{ height: `${heightPct}%`, minHeight: band.count > 0 ? "4px" : "0" }}
|
||||
style={{
|
||||
height: `${heightPct}%`,
|
||||
minHeight: band.count > 0 ? "4px" : "0",
|
||||
}}
|
||||
aria-label={`${band.label} 段 ${band.count} 人`}
|
||||
/>
|
||||
</div>
|
||||
@@ -453,9 +503,11 @@ function GradesContent() {
|
||||
|
||||
{/* 成绩列表 */}
|
||||
<section>
|
||||
<h2 className="text-xl font-serif text-ink mb-2">成绩列表</h2>
|
||||
<h2 className="text-xl font-serif text-ink mb-2">
|
||||
{t("list.title")}
|
||||
</h2>
|
||||
<div className="rule-thin mb-4" />
|
||||
<ul className="space-y-0">
|
||||
<ul className="space-y-0" aria-label={t("list.title")}>
|
||||
{grades.map((g) => (
|
||||
<li
|
||||
key={g.id}
|
||||
@@ -466,11 +518,11 @@ function GradesContent() {
|
||||
{g.studentName}
|
||||
</h3>
|
||||
<p className="mt-1 text-tiny text-ink-muted">
|
||||
学生 ID: {g.studentId}
|
||||
{t("list.label.studentId", { id: g.studentId })}
|
||||
</p>
|
||||
{g.feedback && (
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
反馈: {g.feedback}
|
||||
{t("list.label.feedback", { feedback: g.feedback })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -478,7 +530,11 @@ function GradesContent() {
|
||||
{g.score}
|
||||
</div>
|
||||
<div className="col-span-2 text-tiny text-ink-muted">
|
||||
{g.examId ? `考试: ${g.examId.slice(0, 8)}...` : "作业成绩"}
|
||||
{g.examId
|
||||
? t("list.label.exam", {
|
||||
id: g.examId.slice(0, 8) + "...",
|
||||
})
|
||||
: t("list.label.homeworkScore")}
|
||||
</div>
|
||||
<div className="col-span-2 text-right">
|
||||
<button
|
||||
@@ -486,7 +542,7 @@ function GradesContent() {
|
||||
onClick={handleExportCsv}
|
||||
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
|
||||
>
|
||||
导出 CSV
|
||||
{t("list.button.exportCsv")}
|
||||
</button>
|
||||
<p className="mt-1 text-tiny font-mono text-ink-muted">
|
||||
{g.id.slice(0, 8)}...
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "urql";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import { ClassesQuery } from "@/lib/graphql";
|
||||
@@ -41,6 +42,7 @@ function levelColor(level: string): string {
|
||||
}
|
||||
|
||||
export default function GradeStatsPage(): React.ReactNode {
|
||||
const t = useTranslations("grades");
|
||||
const [classId, setClassId] = useState("");
|
||||
const [subject, setSubject] = useState<string>(SUBJECTS[1] ?? "数学");
|
||||
|
||||
@@ -90,13 +92,11 @@ export default function GradeStatsPage(): React.ReactNode {
|
||||
href="/grades"
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回成绩查询
|
||||
{t("stats.back")}
|
||||
</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>
|
||||
<h1 className="text-3xl font-serif text-ink">{t("stats.title")}</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">{t("stats.subtitle")}</p>
|
||||
</div>
|
||||
{stats && (
|
||||
<button
|
||||
@@ -104,7 +104,7 @@ export default function GradeStatsPage(): React.ReactNode {
|
||||
onClick={exportCsv}
|
||||
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle"
|
||||
>
|
||||
导出 CSV
|
||||
{t("stats.button.exportCsv")}
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
@@ -116,14 +116,14 @@ export default function GradeStatsPage(): React.ReactNode {
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
班级
|
||||
{t("stats.label.class")}
|
||||
</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>
|
||||
<option value="">{t("stats.placeholder.selectClass")}</option>
|
||||
{classes.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
@@ -133,7 +133,7 @@ export default function GradeStatsPage(): React.ReactNode {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
学科
|
||||
{t("stats.label.subject")}
|
||||
</label>
|
||||
<select
|
||||
value={subject}
|
||||
@@ -157,11 +157,16 @@ export default function GradeStatsPage(): React.ReactNode {
|
||||
) : 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}
|
||||
{t("stats.error.loadFailed", {
|
||||
message: statsResult.error.message,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : !stats ? (
|
||||
<Empty title="暂无统计数据" description="请选择班级和学科" />
|
||||
<Empty
|
||||
title={t("stats.empty.title")}
|
||||
description={t("stats.empty.description")}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* 5 个统计卡片 */}
|
||||
@@ -173,7 +178,7 @@ export default function GradeStatsPage(): React.ReactNode {
|
||||
<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">
|
||||
平均分
|
||||
{t("stats.stats.average")}
|
||||
</dt>
|
||||
<dd className="mt-2 text-3xl font-serif text-accent">
|
||||
{stats.avg.toFixed(1)}
|
||||
@@ -181,7 +186,7 @@ export default function GradeStatsPage(): React.ReactNode {
|
||||
</div>
|
||||
<div className="p-4 border border-rule rounded-card">
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
中位数
|
||||
{t("stats.stats.median")}
|
||||
</dt>
|
||||
<dd className="mt-2 text-3xl font-serif text-ink">
|
||||
{stats.median.toFixed(1)}
|
||||
@@ -189,7 +194,7 @@ export default function GradeStatsPage(): React.ReactNode {
|
||||
</div>
|
||||
<div className="p-4 border border-rule rounded-card">
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
最高分
|
||||
{t("stats.stats.max")}
|
||||
</dt>
|
||||
<dd className="mt-2 text-3xl font-serif text-success">
|
||||
{stats.max}
|
||||
@@ -197,7 +202,7 @@ export default function GradeStatsPage(): React.ReactNode {
|
||||
</div>
|
||||
<div className="p-4 border border-rule rounded-card">
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
最低分
|
||||
{t("stats.stats.min")}
|
||||
</dt>
|
||||
<dd className="mt-2 text-3xl font-serif text-danger">
|
||||
{stats.min}
|
||||
@@ -205,7 +210,7 @@ export default function GradeStatsPage(): React.ReactNode {
|
||||
</div>
|
||||
<div className="p-4 border border-rule rounded-card">
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
及格率
|
||||
{t("stats.stats.passRate")}
|
||||
</dt>
|
||||
<dd className="mt-2 text-3xl font-serif text-ink">
|
||||
{stats.passRate.toFixed(1)}%
|
||||
@@ -213,32 +218,37 @@ export default function GradeStatsPage(): React.ReactNode {
|
||||
</div>
|
||||
</dl>
|
||||
<p className="mt-4 text-tiny text-ink-muted">
|
||||
标准差 σ = {stats.stdDev.toFixed(1)} · 共 {stats.totalCount} 名学生
|
||||
{t("stats.stats.footer", {
|
||||
stdDev: stats.stdDev.toFixed(1),
|
||||
count: stats.totalCount,
|
||||
})}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 排名表格 */}
|
||||
<section>
|
||||
<h2 className="text-xl font-serif text-ink mb-4">学生排名</h2>
|
||||
<h2 className="text-xl font-serif text-ink mb-4">
|
||||
{t("stats.ranking.title")}
|
||||
</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">
|
||||
排名
|
||||
{t("stats.ranking.header.rank")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
学号
|
||||
{t("stats.ranking.header.studentNo")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
姓名
|
||||
{t("stats.ranking.header.name")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted w-32">
|
||||
总分
|
||||
{t("stats.ranking.header.totalScore")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted w-24">
|
||||
等级
|
||||
{t("stats.ranking.header.level")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -145,7 +145,9 @@ export default function HomeworkDetailPage() {
|
||||
截止时间
|
||||
</dt>
|
||||
<dd className="mt-1 text-ink">
|
||||
{new Date(result.data.homeworkDetail.dueDate).toLocaleString("zh-CN")}
|
||||
{new Date(result.data.homeworkDetail.dueDate).toLocaleString(
|
||||
"zh-CN",
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
@@ -153,7 +155,8 @@ export default function HomeworkDetailPage() {
|
||||
状态
|
||||
</dt>
|
||||
<dd className="mt-1 text-ink">
|
||||
{SUBMISSION_STATUS_LABEL[result.data.homeworkDetail.status] ?? result.data.homeworkDetail.status}
|
||||
{SUBMISSION_STATUS_LABEL[result.data.homeworkDetail.status] ??
|
||||
result.data.homeworkDetail.status}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@@ -181,10 +184,7 @@ export default function HomeworkDetailPage() {
|
||||
const form = gradingForms[sub.id];
|
||||
const hasError = errorId === sub.id;
|
||||
return (
|
||||
<li
|
||||
key={sub.id}
|
||||
className="py-4 border-b border-rule"
|
||||
>
|
||||
<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">
|
||||
@@ -196,7 +196,9 @@ export default function HomeworkDetailPage() {
|
||||
{sub.submittedAt && (
|
||||
<p className="mt-1 text-tiny text-ink-muted">
|
||||
提交时间:
|
||||
{new Date(sub.submittedAt).toLocaleString("zh-CN")}
|
||||
{new Date(sub.submittedAt).toLocaleString(
|
||||
"zh-CN",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -235,10 +237,14 @@ export default function HomeworkDetailPage() {
|
||||
<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">
|
||||
<label
|
||||
htmlFor={`grading-score-${sub.id}`}
|
||||
className="block text-tiny uppercase tracking-wide text-ink-muted mb-1"
|
||||
>
|
||||
分数 <span className="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
id={`grading-score-${sub.id}`}
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.score}
|
||||
@@ -251,15 +257,21 @@ export default function HomeworkDetailPage() {
|
||||
},
|
||||
}))
|
||||
}
|
||||
aria-label={`${sub.studentName} 分数`}
|
||||
aria-invalid={hasError}
|
||||
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
|
||||
htmlFor={`grading-feedback-${sub.id}`}
|
||||
className="block text-tiny uppercase tracking-wide text-ink-muted mb-1"
|
||||
>
|
||||
反馈
|
||||
</label>
|
||||
<input
|
||||
id={`grading-feedback-${sub.id}`}
|
||||
type="text"
|
||||
value={form.feedback}
|
||||
onChange={(e) =>
|
||||
@@ -271,6 +283,7 @@ export default function HomeworkDetailPage() {
|
||||
},
|
||||
}))
|
||||
}
|
||||
aria-label={`${sub.studentName} 反馈`}
|
||||
className="w-full px-3 py-2 bg-paper border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
placeholder="可选:批改意见"
|
||||
/>
|
||||
@@ -299,9 +312,13 @@ export default function HomeworkDetailPage() {
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
{recordGradeResult.data && !hasError && submittingId === null && (
|
||||
<span className="text-tiny text-success">已保存</span>
|
||||
)}
|
||||
{recordGradeResult.data &&
|
||||
!hasError &&
|
||||
submittingId === null && (
|
||||
<span className="text-tiny text-success">
|
||||
已保存
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -136,7 +136,8 @@ export default function ScanGradingPage(): React.ReactNode {
|
||||
</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)
|
||||
GraphQL SubmissionScanGradingQuery + SaveScanGradingMutation ·
|
||||
core-edu 域(P7 扩展,MSW mock)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -166,11 +167,11 @@ export default function ScanGradingPage(): React.ReactNode {
|
||||
<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 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">
|
||||
提交时间:
|
||||
@@ -207,12 +208,22 @@ export default function ScanGradingPage(): React.ReactNode {
|
||||
) : (
|
||||
<div>
|
||||
{/* 图片预览区 */}
|
||||
<div className="border border-rule rounded-card bg-subtle p-4 flex items-center justify-center overflow-hidden" style={{ minHeight: "400px" }}>
|
||||
<div
|
||||
className="border border-rule rounded-card bg-subtle p-4 flex items-center justify-center overflow-hidden"
|
||||
style={{ minHeight: "400px" }}
|
||||
>
|
||||
{currentImage && (
|
||||
// 保留 <img> 而非 next/image:图片 URL 来自 MSW mock fixture(非真实静态资源),
|
||||
// next/image 的远程优化需要配置 remotePatterns 且对 mock 数据无意义。
|
||||
// 后端接入真实扫描图片 CDN 后,应迁移到 next/image 并配置 loader。
|
||||
<img
|
||||
src={currentImage.url}
|
||||
alt={`扫描第 ${currentImage.page} 页`}
|
||||
style={{ transform: `scale(${zoom})`, maxHeight: "380px", transition: "transform 0.2s" }}
|
||||
style={{
|
||||
transform: `scale(${zoom})`,
|
||||
maxHeight: "380px",
|
||||
transition: "transform 0.2s",
|
||||
}}
|
||||
className="max-w-full h-auto"
|
||||
/>
|
||||
)}
|
||||
@@ -243,7 +254,9 @@ export default function ScanGradingPage(): React.ReactNode {
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentImageIdx((i) => Math.max(0, i - 1))}
|
||||
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"
|
||||
>
|
||||
@@ -251,7 +264,11 @@ export default function ScanGradingPage(): React.ReactNode {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentImageIdx((i) => Math.min(images.length - 1, i + 1))}
|
||||
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"
|
||||
>
|
||||
@@ -266,7 +283,9 @@ export default function ScanGradingPage(): React.ReactNode {
|
||||
|
||||
{/* 右侧:识别作答 + 评分表单 */}
|
||||
<section>
|
||||
<h3 className="text-lg font-serif text-ink mb-4">识别作答 + 评分</h3>
|
||||
<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">
|
||||
@@ -330,7 +349,8 @@ export default function ScanGradingPage(): React.ReactNode {
|
||||
...prev,
|
||||
[item.questionId]: {
|
||||
score: String(item.aiSuggestedScore ?? ""),
|
||||
feedback: g?.feedback ?? item.aiSuggestion ?? "",
|
||||
feedback:
|
||||
g?.feedback ?? item.aiSuggestion ?? "",
|
||||
recognizedAnswer: g?.recognizedAnswer ?? "",
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 课标覆盖热力图 SVG 矩阵组件
|
||||
*
|
||||
* 从 heatmap/page.tsx 提取,用于 next/dynamic 懒加载(重型 SVG,CSR only)。
|
||||
* 包含:X/Y 轴标签、单元格矩阵、覆盖数文字、hover Tooltip。
|
||||
*
|
||||
* 颜色使用 var(--color-*) 语义令牌(project_rules §3.10)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import type { LessonPlanHeatmap as HeatmapData } from "@/lib/graphql-p7-advanced";
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
interface HeatmapMatrixProps {
|
||||
heatmap: HeatmapData;
|
||||
/** cell 查找 Map:`${kpId}|${planId}` → coverage */
|
||||
cellMap: Map<string, number>;
|
||||
}
|
||||
|
||||
export default function HeatmapMatrix({
|
||||
heatmap,
|
||||
cellMap,
|
||||
}: HeatmapMatrixProps): React.ReactNode {
|
||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
||||
|
||||
// SVG 尺寸
|
||||
const kpCount = heatmap.knowledgePoints.length;
|
||||
const planCount = heatmap.lessonPlans.length;
|
||||
const svgWidth = LABEL_COL_W + kpCount * CELL_W + HEADER_PADDING;
|
||||
const svgHeight = LABEL_ROW_H + planCount * CELL_H + HEADER_PADDING;
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useQuery } from "urql";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
@@ -26,14 +27,7 @@ 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 最深) */
|
||||
/** 按覆盖度映射填充色(图例用,矩阵内逻辑在 HeatmapMatrix 组件) */
|
||||
function coverageColor(coverage: number): string {
|
||||
switch (coverage) {
|
||||
case 0:
|
||||
@@ -49,21 +43,11 @@ function coverageColor(coverage: number): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** 按 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;
|
||||
}
|
||||
// 懒加载热力图 SVG 矩阵组件(CSR only,重型 SVG 按需加载)
|
||||
const HeatmapMatrix = dynamic(() => import("./HeatmapMatrix"), {
|
||||
ssr: false,
|
||||
loading: () => <Loading lines={8} />,
|
||||
});
|
||||
|
||||
export default function LessonPlanHeatmapPage(): React.ReactNode {
|
||||
const [textbookId, setTextbookId] = useState(
|
||||
@@ -76,8 +60,6 @@ export default function LessonPlanHeatmapPage(): React.ReactNode {
|
||||
pause: !textbookId,
|
||||
});
|
||||
|
||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
||||
|
||||
const heatmap: HeatmapData | null = result.data?.lessonPlanHeatmap ?? null;
|
||||
|
||||
// 构建 cell 查找 Map:`${kpId}|${planId}` → coverage
|
||||
@@ -91,12 +73,6 @@ export default function LessonPlanHeatmapPage(): React.ReactNode {
|
||||
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;
|
||||
@@ -202,151 +178,13 @@ export default function LessonPlanHeatmapPage(): React.ReactNode {
|
||||
))}
|
||||
</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>
|
||||
{/* SVG 热力图矩阵(懒加载组件) */}
|
||||
<HeatmapMatrix heatmap={heatmap} cellMap={cellMap} />
|
||||
|
||||
{/* 统计摘要 */}
|
||||
<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.knowledgePoints.length} />
|
||||
<StatCard label="课案数" value={heatmap.lessonPlans.length} />
|
||||
<StatCard
|
||||
label="总覆盖数"
|
||||
value={heatmap.cells.reduce((acc, c) => acc + c.coverage, 0)}
|
||||
|
||||
@@ -33,7 +33,10 @@ import type {
|
||||
QuestionUpdateInput,
|
||||
QuestionBatchImportItem,
|
||||
} from "@/lib/graphql-p7-exams";
|
||||
import { MOCK_TEXTBOOKS, MOCK_TEXTBOOK_DETAILS } from "@/mocks/fixtures/textbooks";
|
||||
import {
|
||||
MOCK_TEXTBOOKS,
|
||||
MOCK_TEXTBOOK_DETAILS,
|
||||
} from "@/mocks/fixtures/textbooks";
|
||||
import {
|
||||
QUESTION_TYPE_OPTIONS,
|
||||
QUESTION_DIFFICULTY_OPTIONS,
|
||||
@@ -271,9 +274,7 @@ export default function QuestionsPage(): React.ReactNode {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = async (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setImportMsg(null);
|
||||
@@ -398,6 +399,7 @@ export default function QuestionsPage(): React.ReactNode {
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileChange}
|
||||
aria-label="批量导入文件选择"
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
@@ -420,7 +422,7 @@ export default function QuestionsPage(): React.ReactNode {
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<section className="mb-6">
|
||||
<section className="mb-6" aria-label="题目筛选">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
<input
|
||||
type="text"
|
||||
@@ -429,6 +431,7 @@ export default function QuestionsPage(): React.ReactNode {
|
||||
setFilterQ(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
aria-label="关键词筛选(题干/知识点)"
|
||||
placeholder="关键词(题干/知识点)"
|
||||
className="px-3 py-1.5 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
/>
|
||||
@@ -438,6 +441,7 @@ export default function QuestionsPage(): React.ReactNode {
|
||||
setFilterType(e.target.value as QuestionType | "ALL");
|
||||
setPage(1);
|
||||
}}
|
||||
aria-label="按类型筛选"
|
||||
className="px-2 py-1.5 bg-transparent border border-rule rounded-button text-tiny text-ink focus:outline-none"
|
||||
>
|
||||
<option value="ALL">全部类型</option>
|
||||
@@ -453,6 +457,7 @@ export default function QuestionsPage(): React.ReactNode {
|
||||
setFilterDifficulty(e.target.value as QuestionDifficulty | "ALL");
|
||||
setPage(1);
|
||||
}}
|
||||
aria-label="按难度筛选"
|
||||
className="px-2 py-1.5 bg-transparent border border-rule rounded-button text-tiny text-ink focus:outline-none"
|
||||
>
|
||||
<option value="ALL">全部难度</option>
|
||||
@@ -470,6 +475,7 @@ export default function QuestionsPage(): React.ReactNode {
|
||||
setFilterKp("all");
|
||||
setPage(1);
|
||||
}}
|
||||
aria-label="按教材筛选"
|
||||
className="px-2 py-1.5 bg-transparent border border-rule rounded-button text-tiny text-ink focus:outline-none"
|
||||
>
|
||||
<option value="all">全部教材</option>
|
||||
@@ -487,6 +493,7 @@ export default function QuestionsPage(): React.ReactNode {
|
||||
setPage(1);
|
||||
}}
|
||||
disabled={filterChapterOptions.length === 0}
|
||||
aria-label="按章节筛选"
|
||||
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>
|
||||
@@ -503,6 +510,7 @@ export default function QuestionsPage(): React.ReactNode {
|
||||
setPage(1);
|
||||
}}
|
||||
disabled={filterKpOptions.length === 0}
|
||||
aria-label="按知识点筛选"
|
||||
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>
|
||||
@@ -521,8 +529,12 @@ export default function QuestionsPage(): React.ReactNode {
|
||||
>
|
||||
清空筛选
|
||||
</button>
|
||||
{importMsg && <span className="text-tiny text-ink-muted">{importMsg}</span>}
|
||||
{exportMsg && <span className="text-tiny text-success">{exportMsg}</span>}
|
||||
{importMsg && (
|
||||
<span className="text-tiny text-ink-muted">{importMsg}</span>
|
||||
)}
|
||||
{exportMsg && (
|
||||
<span className="text-tiny text-success">{exportMsg}</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -654,11 +666,23 @@ export default function QuestionsPage(): React.ReactNode {
|
||||
{dialogOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-paper/80"
|
||||
onClick={() => !dialogSaving && setDialogOpen(false)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="点击关闭弹窗"
|
||||
onClick={(e) => {
|
||||
if (!dialogSaving && e.target === e.currentTarget) {
|
||||
setDialogOpen(false);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (!dialogSaving && e.key === "Escape") {
|
||||
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()}
|
||||
role="presentation"
|
||||
>
|
||||
<h2 className="text-xl font-serif text-ink mb-4">
|
||||
{form.questionId ? "编辑题目" : "新建题目"}
|
||||
@@ -667,14 +691,19 @@ export default function QuestionsPage(): React.ReactNode {
|
||||
<div className="space-y-4">
|
||||
{/* 题干 */}
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
<label
|
||||
htmlFor="question-form-content"
|
||||
className="block text-tiny uppercase tracking-wide text-ink-muted mb-1"
|
||||
>
|
||||
题干 <span className="text-danger">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="question-form-content"
|
||||
value={form.content}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, content: e.target.value }))
|
||||
}
|
||||
aria-label="题干"
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
placeholder="支持换行、选项等"
|
||||
@@ -683,10 +712,14 @@ export default function QuestionsPage(): React.ReactNode {
|
||||
{/* 类型 + 难度 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
<label
|
||||
htmlFor="question-form-type"
|
||||
className="block text-tiny uppercase tracking-wide text-ink-muted mb-1"
|
||||
>
|
||||
类型 <span className="text-danger">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="question-form-type"
|
||||
value={form.type}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({
|
||||
@@ -694,6 +727,7 @@ export default function QuestionsPage(): React.ReactNode {
|
||||
type: e.target.value as QuestionType,
|
||||
}))
|
||||
}
|
||||
aria-label="类型"
|
||||
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) => (
|
||||
@@ -704,10 +738,14 @@ export default function QuestionsPage(): React.ReactNode {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
<label
|
||||
htmlFor="question-form-difficulty"
|
||||
className="block text-tiny uppercase tracking-wide text-ink-muted mb-1"
|
||||
>
|
||||
难度 <span className="text-danger">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="question-form-difficulty"
|
||||
value={form.difficulty}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({
|
||||
@@ -715,6 +753,7 @@ export default function QuestionsPage(): React.ReactNode {
|
||||
difficulty: e.target.value as QuestionDifficulty,
|
||||
}))
|
||||
}
|
||||
aria-label="难度"
|
||||
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) => (
|
||||
@@ -728,10 +767,14 @@ export default function QuestionsPage(): React.ReactNode {
|
||||
{/* 教材 / 章节 / 知识点 */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
<label
|
||||
htmlFor="question-form-textbook"
|
||||
className="block text-tiny uppercase tracking-wide text-ink-muted mb-1"
|
||||
>
|
||||
教材
|
||||
</label>
|
||||
<select
|
||||
id="question-form-textbook"
|
||||
value={form.textbookId}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({
|
||||
@@ -741,6 +784,7 @@ export default function QuestionsPage(): React.ReactNode {
|
||||
kpId: "",
|
||||
}))
|
||||
}
|
||||
aria-label="教材"
|
||||
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-tiny text-ink focus:outline-none"
|
||||
>
|
||||
<option value="">不关联</option>
|
||||
@@ -752,10 +796,14 @@ export default function QuestionsPage(): React.ReactNode {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
<label
|
||||
htmlFor="question-form-chapter"
|
||||
className="block text-tiny uppercase tracking-wide text-ink-muted mb-1"
|
||||
>
|
||||
章节
|
||||
</label>
|
||||
<select
|
||||
id="question-form-chapter"
|
||||
value={form.chapterId}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({
|
||||
@@ -765,6 +813,7 @@ export default function QuestionsPage(): React.ReactNode {
|
||||
}))
|
||||
}
|
||||
disabled={dialogChapters.length === 0}
|
||||
aria-label="章节"
|
||||
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>
|
||||
@@ -776,15 +825,20 @@ export default function QuestionsPage(): React.ReactNode {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
<label
|
||||
htmlFor="question-form-kp"
|
||||
className="block text-tiny uppercase tracking-wide text-ink-muted mb-1"
|
||||
>
|
||||
知识点
|
||||
</label>
|
||||
<select
|
||||
id="question-form-kp"
|
||||
value={form.kpId}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, kpId: e.target.value }))
|
||||
}
|
||||
disabled={dialogKps.length === 0}
|
||||
aria-label="知识点"
|
||||
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>
|
||||
@@ -798,43 +852,58 @@ export default function QuestionsPage(): React.ReactNode {
|
||||
</div>
|
||||
{/* 分值 */}
|
||||
<div className="w-32">
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
<label
|
||||
htmlFor="question-form-score"
|
||||
className="block text-tiny uppercase tracking-wide text-ink-muted mb-1"
|
||||
>
|
||||
分值 <span className="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="question-form-score"
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.score}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, score: e.target.value }))
|
||||
}
|
||||
aria-label="分值"
|
||||
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">
|
||||
<label
|
||||
htmlFor="question-form-answer"
|
||||
className="block text-tiny uppercase tracking-wide text-ink-muted mb-1"
|
||||
>
|
||||
答案 <span className="text-danger">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="question-form-answer"
|
||||
value={form.answer}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, answer: e.target.value }))
|
||||
}
|
||||
aria-label="答案"
|
||||
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
|
||||
htmlFor="question-form-analysis"
|
||||
className="block text-tiny uppercase tracking-wide text-ink-muted mb-1"
|
||||
>
|
||||
解析
|
||||
</label>
|
||||
<textarea
|
||||
id="question-form-analysis"
|
||||
value={form.analysis}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, analysis: e.target.value }))
|
||||
}
|
||||
aria-label="解析"
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
/>
|
||||
|
||||
@@ -14,10 +14,12 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation } from "urql";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Loading } from "@edu/ui-components";
|
||||
import { MeQuery, UpdateUserMutation } from "@/lib/graphql";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const t = useTranslations("settings");
|
||||
const [result, reexecuteQuery] = useQuery({ query: MeQuery });
|
||||
const [updateResult, updateUser] = useMutation(UpdateUserMutation);
|
||||
|
||||
@@ -50,11 +52,11 @@ export default function SettingsPage() {
|
||||
setError(null);
|
||||
|
||||
if (!name.trim()) {
|
||||
setError("姓名不能为空");
|
||||
setError(t("error.nameRequired"));
|
||||
return;
|
||||
}
|
||||
if (!email.trim() || !email.includes("@")) {
|
||||
setError("邮箱格式不正确");
|
||||
setError(t("error.emailInvalid"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -78,10 +80,8 @@ export default function SettingsPage() {
|
||||
<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 MeQuery + UpdateUserMutation · P3 编辑模式
|
||||
</p>
|
||||
<h1 className="text-3xl font-serif text-ink">{t("title")}</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">{t("subtitle")}</p>
|
||||
</div>
|
||||
{!editing && result.data?.me && (
|
||||
<button
|
||||
@@ -89,7 +89,7 @@ export default function SettingsPage() {
|
||||
onClick={handleStartEdit}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
|
||||
>
|
||||
编辑
|
||||
{t("button.edit")}
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
@@ -101,45 +101,65 @@ export default function SettingsPage() {
|
||||
) : 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}
|
||||
{t("error.loadFailed", { message: result.error.message })}
|
||||
</p>
|
||||
</div>
|
||||
) : !result.data?.me ? (
|
||||
<p className="text-sm italic text-ink-muted">暂无用户信息</p>
|
||||
<p className="text-sm italic text-ink-muted">{t("empty")}</p>
|
||||
) : editing ? (
|
||||
<section className="max-w-2xl">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-serif text-ink mb-2">编辑个人信息</h2>
|
||||
<h2 className="text-xl font-serif text-ink mb-2">
|
||||
{t("edit.title")}
|
||||
</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
|
||||
htmlFor="settings-name"
|
||||
className="block text-tiny uppercase tracking-wide text-ink-muted mb-1"
|
||||
>
|
||||
{t("form.label.name")} <span className="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="settings-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
aria-label={t("form.label.name")}
|
||||
aria-invalid={Boolean(error)}
|
||||
aria-describedby={error ? "settings-form-error" : undefined}
|
||||
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
|
||||
htmlFor="settings-email"
|
||||
className="block text-tiny uppercase tracking-wide text-ink-muted mb-1"
|
||||
>
|
||||
{t("form.label.email")} <span className="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="settings-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
aria-label={t("form.label.email")}
|
||||
aria-invalid={Boolean(error)}
|
||||
aria-describedby={error ? "settings-form-error" : undefined}
|
||||
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">
|
||||
<div
|
||||
id="settings-form-error"
|
||||
role="alert"
|
||||
className="mark-left py-2 border-l-2 border-danger pl-md"
|
||||
>
|
||||
<p className="text-sm px-3 text-danger">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -150,7 +170,9 @@ export default function SettingsPage() {
|
||||
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 ? "保存中..." : "保存"}
|
||||
{submitting
|
||||
? t("form.button.submitting")
|
||||
: t("form.button.save")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -158,10 +180,12 @@ export default function SettingsPage() {
|
||||
disabled={submitting}
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70 disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
{t("form.button.cancel")}
|
||||
</button>
|
||||
{updateResult.data && !error && !submitting && (
|
||||
<span className="text-tiny text-success">保存成功</span>
|
||||
<span className="text-tiny text-success">
|
||||
{t("form.status.saved")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
@@ -169,12 +193,14 @@ export default function SettingsPage() {
|
||||
) : (
|
||||
<section className="max-w-2xl">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-serif text-ink mb-2">基本信息</h2>
|
||||
<h2 className="text-xl font-serif text-ink mb-2">
|
||||
{t("info.title")}
|
||||
</h2>
|
||||
<div className="rule-thin mb-4" />
|
||||
<dl className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4 items-baseline">
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
用户 ID
|
||||
{t("info.label.userId")}
|
||||
</dt>
|
||||
<dd className="col-span-2 text-sm font-mono text-ink">
|
||||
{result.data.me.id}
|
||||
@@ -182,7 +208,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 items-baseline">
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
姓名
|
||||
{t("info.label.name")}
|
||||
</dt>
|
||||
<dd className="col-span-2 text-sm text-ink">
|
||||
{result.data.me.name}
|
||||
@@ -190,7 +216,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 items-baseline">
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
邮箱
|
||||
{t("info.label.email")}
|
||||
</dt>
|
||||
<dd className="col-span-2 text-sm text-ink">
|
||||
{result.data.me.email}
|
||||
@@ -198,15 +224,15 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 items-baseline">
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
角色
|
||||
{t("info.label.roles")}
|
||||
</dt>
|
||||
<dd className="col-span-2 text-sm text-ink">
|
||||
{result.data.me.roles.join(", ") || "无角色"}
|
||||
{result.data.me.roles.join(", ") || t("info.noRoles")}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 items-baseline">
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
数据范围
|
||||
{t("info.label.dataScope")}
|
||||
</dt>
|
||||
<dd className="col-span-2 text-sm text-ink">
|
||||
{result.data.me.dataScope}
|
||||
@@ -216,9 +242,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-8 p-4 border border-rule rounded-card bg-subtle">
|
||||
<p className="text-sm text-ink-muted">
|
||||
P3 已启用编辑功能(UpdateUserMutation)。点击右上角"编辑"按钮修改姓名与邮箱。
|
||||
</p>
|
||||
<p className="text-sm text-ink-muted">{t("info.editHint")}</p>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import "./globals.css";
|
||||
import type { Metadata } from "next";
|
||||
import { Inter, Fraunces, JetBrains_Mono } from "next/font/google";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getLocale, getMessages } from "next-intl/server";
|
||||
import { GraphQLProvider } from "./providers";
|
||||
import { ErrorBoundary } from "@edu/ui-components";
|
||||
import { ObservabilityProvider } from "@/components/observability-provider";
|
||||
import { PerformanceDashboard } from "@/components/performance-dashboard";
|
||||
|
||||
/**
|
||||
* 字体加载(next/font/google self-host)
|
||||
@@ -38,22 +41,28 @@ export const metadata: Metadata = {
|
||||
description: "K12 智慧教务平台 - 教师端",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const locale = await getLocale();
|
||||
const messages = await getMessages();
|
||||
|
||||
return (
|
||||
<html
|
||||
lang="zh-CN"
|
||||
lang={locale}
|
||||
className={`${inter.variable} ${fraunces.variable} ${mono.variable}`}
|
||||
>
|
||||
<body>
|
||||
<ObservabilityProvider>
|
||||
<ErrorBoundary>
|
||||
<GraphQLProvider>{children}</GraphQLProvider>
|
||||
</ErrorBoundary>
|
||||
</ObservabilityProvider>
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
<ObservabilityProvider>
|
||||
<ErrorBoundary>
|
||||
<GraphQLProvider>{children}</GraphQLProvider>
|
||||
</ErrorBoundary>
|
||||
</ObservabilityProvider>
|
||||
<PerformanceDashboard />
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
47
apps/teacher-portal/src/app/locale-switcher.tsx
Normal file
47
apps/teacher-portal/src/app/locale-switcher.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* LocaleSwitcher - 语言切换器
|
||||
*
|
||||
* 非路由式 i18n:通过 NEXT_LOCALE cookie 切换语言,刷新页面生效。
|
||||
* - zh-CN(默认)/ en
|
||||
* - 使用 document.cookie 设置(httpOnly=false,SameSite=Strict)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { ChangeEvent } from "react";
|
||||
|
||||
const LOCALE_COOKIE = "NEXT_LOCALE";
|
||||
const LOCALE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
|
||||
|
||||
export function LocaleSwitcher(): React.ReactNode {
|
||||
const t = useTranslations("common.locale");
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
||||
const nextLocale = e.target.value;
|
||||
document.cookie = `${LOCALE_COOKIE}=${nextLocale};path=/;max-age=${LOCALE_COOKIE_MAX_AGE};SameSite=Strict`;
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<label className="flex items-center gap-2 text-tiny text-ink-muted">
|
||||
<span className="uppercase tracking-wide">{t("label")}</span>
|
||||
<select
|
||||
value={locale}
|
||||
onChange={handleChange}
|
||||
className="bg-transparent border-b border-rule text-tiny text-ink focus:outline-none focus:border-accent cursor-pointer"
|
||||
aria-label={t("label")}
|
||||
>
|
||||
<option value="zh-CN">{t("zhCN")}</option>
|
||||
<option value="en">{t("en")}</option>
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default LocaleSwitcher;
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
useCrossTabSync,
|
||||
broadcastCrossTabEvent,
|
||||
} from "@/hooks/use-cross-tab-sync";
|
||||
import { LocaleSwitcher } from "@/app/locale-switcher";
|
||||
|
||||
/**
|
||||
* 权限上下文:从 localStorage 读取(登录时由 iam 返回并存储)。
|
||||
@@ -142,7 +143,7 @@ export default function AppShell({ children }: { children: React.ReactNode }) {
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* 底部:用户信息 + 登出 */}
|
||||
{/* 底部:用户信息 + 语言切换 + 登出 */}
|
||||
<div className="mt-auto px-6 py-4 border-t border-rule">
|
||||
{user && (
|
||||
<div className="mb-2">
|
||||
@@ -152,6 +153,9 @@ export default function AppShell({ children }: { children: React.ReactNode }) {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-3">
|
||||
<LocaleSwitcher />
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
|
||||
255
apps/teacher-portal/src/components/performance-dashboard.tsx
Normal file
255
apps/teacher-portal/src/components/performance-dashboard.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 性能监控 Dashboard 组件(dev 环境)
|
||||
*
|
||||
* - 仅在 dev 环境渲染(process.env.NODE_ENV === "development")
|
||||
* - 采集 Web Vitals(LCP/FCP/CLS/INP/TTFB)并存储到 sessionStorage
|
||||
* - 浮动按钮 + 弹出面板,展示实时指标与阈值对比
|
||||
* - 使用语义设计令牌(bg-subtle / text-ink / border-rule 等)
|
||||
*
|
||||
* 关联:lib/observability/performance.ts PERFORMANCE_BUDGETS
|
||||
* 02-architecture-design.md §12 可观测性
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
|
||||
/** Web Vital 指标存储结构 */
|
||||
interface VitalMetric {
|
||||
name: string;
|
||||
value: number;
|
||||
rating: "good" | "needs-improvement" | "poor";
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** sessionStorage 存储键 */
|
||||
const STORAGE_KEY = "teacher-portal:web-vitals";
|
||||
|
||||
/** 性能阈值(与 PERFORMANCE_BUDGETS 对齐) */
|
||||
const THRESHOLDS: Record<string, { good: number; poor: number; unit: string }> =
|
||||
{
|
||||
LCP: { good: 2500, poor: 4000, unit: "ms" },
|
||||
FCP: { good: 1800, poor: 3000, unit: "ms" },
|
||||
CLS: { good: 0.1, poor: 0.25, unit: "" },
|
||||
INP: { good: 200, poor: 500, unit: "ms" },
|
||||
TTFB: { good: 800, poor: 1800, unit: "ms" },
|
||||
};
|
||||
|
||||
/** 指标显示顺序 */
|
||||
const VITAL_ORDER = ["LCP", "FCP", "CLS", "INP", "TTFB"] as const;
|
||||
|
||||
/**
|
||||
* 从 sessionStorage 读取已采集的指标。
|
||||
*/
|
||||
function readStoredMetrics(): Record<string, VitalMetric> {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
return JSON.parse(raw) as Record<string, VitalMetric>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将指标写入 sessionStorage。
|
||||
*/
|
||||
function writeStoredMetrics(metrics: Record<string, VitalMetric>): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(metrics));
|
||||
} catch {
|
||||
// sessionStorage 写入失败(如隐私模式),静默忽略
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 rating 返回对应的语义颜色类。
|
||||
*/
|
||||
function ratingColorClass(rating: string): string {
|
||||
switch (rating) {
|
||||
case "good":
|
||||
return "text-success";
|
||||
case "needs-improvement":
|
||||
return "text-warning";
|
||||
case "poor":
|
||||
return "text-danger";
|
||||
default:
|
||||
return "text-ink-muted";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化指标值显示。
|
||||
*/
|
||||
function formatValue(value: number, unit: string): string {
|
||||
if (unit === "") {
|
||||
return value.toFixed(3);
|
||||
}
|
||||
return `${Math.round(value)}${unit}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 性能监控 Dashboard 组件。
|
||||
*
|
||||
* 仅在 dev 环境渲染。采集 Web Vitals 并展示在浮动面板中。
|
||||
*/
|
||||
export function PerformanceDashboard(): React.ReactNode {
|
||||
const [metrics, setMetrics] = useState<Record<string, VitalMetric>>({});
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// 注册 web-vitals 回调,采集指标
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV !== "development") return;
|
||||
|
||||
// 动态 import web-vitals 库
|
||||
let cancelled = false;
|
||||
|
||||
void import("web-vitals")
|
||||
.then(({ onLCP, onCLS, onFCP, onINP, onTTFB }) => {
|
||||
if (cancelled) return;
|
||||
|
||||
const handleMetric = (metric: {
|
||||
name: string;
|
||||
value: number;
|
||||
rating: string;
|
||||
}): void => {
|
||||
const vital: VitalMetric = {
|
||||
name: metric.name,
|
||||
value: metric.value,
|
||||
rating: metric.rating as VitalMetric["rating"],
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setMetrics((prev) => {
|
||||
const updated = { ...prev, [metric.name]: vital };
|
||||
writeStoredMetrics(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
onLCP(handleMetric);
|
||||
onCLS(handleMetric);
|
||||
onFCP(handleMetric);
|
||||
onINP(handleMetric);
|
||||
onTTFB(handleMetric);
|
||||
})
|
||||
.catch(() => {
|
||||
// web-vitals 库未安装时静默降级
|
||||
});
|
||||
|
||||
// 加载已存储的指标
|
||||
setMetrics(readStoredMetrics());
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// dev 环境外不渲染
|
||||
if (process.env.NODE_ENV !== "development") return null;
|
||||
|
||||
// Escape 关闭面板
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent): void => {
|
||||
if (e.key === "Escape") {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const metricCount = Object.keys(metrics).length;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50" onKeyDown={handleKeyDown}>
|
||||
{/* 浮动按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-label={isOpen ? "关闭性能监控面板" : "打开性能监控面板"}
|
||||
aria-expanded={isOpen}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-full bg-accent text-ink-on-accent shadow-lg hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
<span className="text-sm font-mono" aria-hidden="true">
|
||||
{metricCount > 0 ? metricCount : "⚡"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* 弹出面板 */}
|
||||
{isOpen && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="Web Vitals 性能指标"
|
||||
className="absolute bottom-12 right-0 w-72 p-4 bg-paper border border-rule rounded-card shadow-lg"
|
||||
>
|
||||
<div className="flex items-baseline justify-between mb-3">
|
||||
<h2 className="text-sm font-serif text-ink">Web Vitals</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
aria-label="关闭"
|
||||
className="text-tiny text-ink-muted hover:text-ink"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rule-thin mb-3" />
|
||||
|
||||
{metricCount === 0 ? (
|
||||
<p className="text-tiny text-ink-muted italic">等待指标采集…</p>
|
||||
) : (
|
||||
<ul className="space-y-2" aria-label="性能指标列表">
|
||||
{VITAL_ORDER.map((name) => {
|
||||
const metric = metrics[name];
|
||||
if (!metric) return null;
|
||||
const threshold = THRESHOLDS[name];
|
||||
if (!threshold) return null;
|
||||
return (
|
||||
<li
|
||||
key={name}
|
||||
className="flex items-baseline justify-between"
|
||||
>
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
{name}
|
||||
</span>
|
||||
<span
|
||||
className={`text-sm font-mono ${ratingColorClass(metric.rating)}`}
|
||||
>
|
||||
{formatValue(metric.value, threshold.unit)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="rule-thin mt-3 mb-2" />
|
||||
|
||||
{/* 阈值图例 */}
|
||||
<div className="flex gap-3 text-tiny">
|
||||
<span className="text-success">● good</span>
|
||||
<span className="text-warning">● needs improvement</span>
|
||||
<span className="text-danger">● poor</span>
|
||||
</div>
|
||||
|
||||
{/* 清除按钮 */}
|
||||
{metricCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
setMetrics({});
|
||||
}}
|
||||
className="mt-2 text-tiny uppercase tracking-wide text-ink-muted hover:text-ink"
|
||||
>
|
||||
清除指标
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PerformanceDashboard;
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* useCrossTabSync Hook 单元测试
|
||||
*
|
||||
* 测试点:
|
||||
* - BroadcastChannel 触发 message 事件时回调被调用(token-refresh → setToken)
|
||||
* - 卸载时关闭 channel(close 被调用)
|
||||
* - API 不可用时的兜底(typeof BroadcastChannel === 'undefined')
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import type { CrossTabMessage } from "../use-cross-tab-sync";
|
||||
|
||||
// mock @/lib/auth 的 setToken(hook 内部依赖)
|
||||
vi.mock("@/lib/auth", () => ({
|
||||
setToken: vi.fn(),
|
||||
getToken: vi.fn(() => null),
|
||||
clearToken: vi.fn(),
|
||||
getUser: vi.fn(() => null),
|
||||
setUser: vi.fn(),
|
||||
isAuthenticated: vi.fn(() => false),
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
}));
|
||||
|
||||
import { setToken } from "@/lib/auth";
|
||||
|
||||
// ============ 可控 BroadcastChannel mock ============
|
||||
|
||||
class TestableBroadcastChannel {
|
||||
name: string;
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
listeners: Map<string, Set<(event: MessageEvent) => void>> = new Map();
|
||||
closeCount = 0;
|
||||
|
||||
static instances: TestableBroadcastChannel[] = [];
|
||||
static lastInstance: TestableBroadcastChannel | null = null;
|
||||
|
||||
constructor(name: string) {
|
||||
this.name = name;
|
||||
TestableBroadcastChannel.instances.push(this);
|
||||
TestableBroadcastChannel.lastInstance = this;
|
||||
}
|
||||
|
||||
addEventListener(
|
||||
type: string,
|
||||
listener: (event: MessageEvent) => void,
|
||||
): void {
|
||||
if (!this.listeners.has(type)) {
|
||||
this.listeners.set(type, new Set());
|
||||
}
|
||||
this.listeners.get(type)!.add(listener);
|
||||
}
|
||||
|
||||
removeEventListener(
|
||||
type: string,
|
||||
listener: (event: MessageEvent) => void,
|
||||
): void {
|
||||
this.listeners.get(type)?.delete(listener);
|
||||
}
|
||||
|
||||
postMessage(): void {
|
||||
// 本测试中不模拟跨 Tab 传递
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closeCount++;
|
||||
}
|
||||
|
||||
/** 测试辅助:触发 message 事件给注册的监听器 */
|
||||
dispatchMessage(data: CrossTabMessage): void {
|
||||
const event = new MessageEvent("message", { data });
|
||||
this.listeners.get("message")?.forEach((fn) => fn(event));
|
||||
if (this.onmessage) {
|
||||
this.onmessage(event);
|
||||
}
|
||||
}
|
||||
|
||||
static reset(): void {
|
||||
TestableBroadcastChannel.instances = [];
|
||||
TestableBroadcastChannel.lastInstance = null;
|
||||
}
|
||||
}
|
||||
|
||||
describe("useCrossTabSync", () => {
|
||||
let originalBroadcastChannel: typeof BroadcastChannel | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
TestableBroadcastChannel.reset();
|
||||
|
||||
// 保存原始 BroadcastChannel
|
||||
originalBroadcastChannel = globalThis.BroadcastChannel;
|
||||
// 挂载可控 mock(测试中需要 as 断言挂载全局 mock)
|
||||
globalThis.BroadcastChannel =
|
||||
TestableBroadcastChannel as unknown as typeof BroadcastChannel;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// 恢复原始 BroadcastChannel
|
||||
if (originalBroadcastChannel) {
|
||||
globalThis.BroadcastChannel = originalBroadcastChannel;
|
||||
} else {
|
||||
// 原始环境无 BroadcastChannel(jsdom),恢复为 setup 中的 mock
|
||||
delete (globalThis as Record<string, unknown>).BroadcastChannel;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("收到 token-refresh 事件时调用 setToken", async () => {
|
||||
const { useCrossTabSync } = await import("../use-cross-tab-sync");
|
||||
renderHook(() => useCrossTabSync());
|
||||
|
||||
const channel = TestableBroadcastChannel.lastInstance;
|
||||
expect(channel).not.toBeNull();
|
||||
expect(channel!.name).toBe("edu-session");
|
||||
|
||||
const token = "new-token-abc123";
|
||||
channel!.dispatchMessage({
|
||||
type: "token-refresh",
|
||||
payload: { token },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
expect(setToken).toHaveBeenCalledWith(token);
|
||||
});
|
||||
|
||||
it("收到 logout 事件时跳转登录页", async () => {
|
||||
const { useCrossTabSync } = await import("../use-cross-tab-sync");
|
||||
renderHook(() => useCrossTabSync());
|
||||
|
||||
const channel = TestableBroadcastChannel.lastInstance;
|
||||
expect(channel).not.toBeNull();
|
||||
|
||||
// mock window.location.href setter
|
||||
const hrefSetter = vi.fn();
|
||||
Object.defineProperty(window, "location", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: {
|
||||
...window.location,
|
||||
set href(val: string) {
|
||||
hrefSetter(val);
|
||||
},
|
||||
get href() {
|
||||
return "";
|
||||
},
|
||||
reload: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
channel!.dispatchMessage({
|
||||
type: "logout",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
expect(hrefSetter).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
|
||||
it("收到 role-change 事件时刷新页面", async () => {
|
||||
const { useCrossTabSync } = await import("../use-cross-tab-sync");
|
||||
const reloadSpy = vi.fn();
|
||||
Object.defineProperty(window, "location", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: {
|
||||
...window.location,
|
||||
href: "",
|
||||
reload: reloadSpy,
|
||||
},
|
||||
});
|
||||
|
||||
renderHook(() => useCrossTabSync());
|
||||
|
||||
const channel = TestableBroadcastChannel.lastInstance;
|
||||
expect(channel).not.toBeNull();
|
||||
|
||||
channel!.dispatchMessage({
|
||||
type: "role-change",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
expect(reloadSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("卸载时关闭 channel", async () => {
|
||||
const { useCrossTabSync } = await import("../use-cross-tab-sync");
|
||||
const { unmount } = renderHook(() => useCrossTabSync());
|
||||
|
||||
const channel = TestableBroadcastChannel.lastInstance;
|
||||
expect(channel).not.toBeNull();
|
||||
expect(channel!.closeCount).toBe(0);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(channel!.closeCount).toBe(1);
|
||||
});
|
||||
|
||||
it("BroadcastChannel 不可用时不崩溃", async () => {
|
||||
// 删除 BroadcastChannel 模拟 API 不可用环境
|
||||
delete (globalThis as Record<string, unknown>).BroadcastChannel;
|
||||
|
||||
const { useCrossTabSync } = await import("../use-cross-tab-sync");
|
||||
// 不应抛出异常
|
||||
const { unmount } = renderHook(() => useCrossTabSync());
|
||||
unmount();
|
||||
|
||||
// 无 channel 创建,setToken 未被调用
|
||||
expect(setToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("忽略无效消息体", async () => {
|
||||
const { useCrossTabSync } = await import("../use-cross-tab-sync");
|
||||
renderHook(() => useCrossTabSync());
|
||||
|
||||
const channel = TestableBroadcastChannel.lastInstance;
|
||||
expect(channel).not.toBeNull();
|
||||
|
||||
// 发送 null 消息(不应触发任何副作用)
|
||||
const event = new MessageEvent("message", { data: null });
|
||||
channel!.listeners.get("message")?.forEach((fn) => fn(event));
|
||||
|
||||
expect(setToken).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* useNotificationsWebSocket Hook 单元测试
|
||||
*
|
||||
* 测试点:
|
||||
* - 连接成功后 onMessage 回调(通知添加到列表)
|
||||
* - 指数退避重连(1s/2s/4s/8s/16s,max 30s,达到上限后停止)
|
||||
* - 手动 reconnect 调用(关闭旧连接 + 重新开始)
|
||||
* - unmount 时清理 setInterval + WebSocket
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useNotificationsWebSocket } from "../use-notifications-websocket";
|
||||
|
||||
// ============ 可控 WebSocket mock ============
|
||||
|
||||
class MockWebSocket {
|
||||
url: string;
|
||||
onopen: ((event: Event) => void) | null = null;
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
onerror: ((event: Event) => void) | null = null;
|
||||
onclose: ((event: CloseEvent) => void) | null = null;
|
||||
readyState = 0;
|
||||
closed = false;
|
||||
|
||||
static instances: MockWebSocket[] = [];
|
||||
static closeCount = 0;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
MockWebSocket.instances.push(this);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
MockWebSocket.closeCount++;
|
||||
}
|
||||
|
||||
send(): void {
|
||||
// no-op
|
||||
}
|
||||
|
||||
// ---- 测试辅助方法 ----
|
||||
triggerOpen(): void {
|
||||
this.readyState = 1;
|
||||
this.onopen?.(new Event("open"));
|
||||
}
|
||||
|
||||
triggerMessage(data: string): void {
|
||||
this.onmessage?.(new MessageEvent("message", { data }));
|
||||
}
|
||||
|
||||
triggerError(): void {
|
||||
this.onerror?.(new Event("error"));
|
||||
}
|
||||
|
||||
triggerClose(): void {
|
||||
this.readyState = 3;
|
||||
// 测试中需要 as 断言:jsdom 的 Event 不完全匹配 CloseEvent 类型,
|
||||
// 但 hook 的 onclose 不使用 event 参数,类型不匹配无副作用
|
||||
this.onclose?.(new Event("close") as unknown as CloseEvent);
|
||||
}
|
||||
|
||||
static reset(): void {
|
||||
MockWebSocket.instances = [];
|
||||
MockWebSocket.closeCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
describe("useNotificationsWebSocket", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubEnv("NEXT_PUBLIC_API_MOCKING", "disabled");
|
||||
MockWebSocket.reset();
|
||||
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("连接成功", () => {
|
||||
it("onopen 后状态为 connected,onmessage 回调添加通知", () => {
|
||||
const { result } = renderHook(() => useNotificationsWebSocket());
|
||||
|
||||
// 初始状态:connecting(useEffect 已执行 connect)
|
||||
expect(result.current.connectionState).toBe("connecting");
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
|
||||
const ws = MockWebSocket.instances[0]!;
|
||||
|
||||
// 触发 onopen:连接成功
|
||||
act(() => ws.triggerOpen());
|
||||
expect(result.current.connectionState).toBe("connected");
|
||||
|
||||
// 触发 onmessage:收到通知
|
||||
const notification = {
|
||||
id: "test-ntf-001",
|
||||
type: "HOMEWORK_SUBMITTED",
|
||||
title: "测试通知",
|
||||
message: "测试消息内容",
|
||||
read: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
act(() => {
|
||||
ws.triggerMessage(
|
||||
JSON.stringify({ type: "notification", payload: notification }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.notifications).toHaveLength(1);
|
||||
expect(result.current.notifications[0]!.id).toBe("test-ntf-001");
|
||||
});
|
||||
|
||||
it("重复 ID 的通知不重复添加", () => {
|
||||
const { result } = renderHook(() => useNotificationsWebSocket());
|
||||
|
||||
const ws = MockWebSocket.instances[0]!;
|
||||
const notification = {
|
||||
id: "dup-001",
|
||||
type: "EXAM_GRADED",
|
||||
title: "重复测试",
|
||||
message: "重复消息",
|
||||
read: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
act(() => {
|
||||
ws.triggerMessage(JSON.stringify({ payload: notification }));
|
||||
});
|
||||
expect(result.current.notifications).toHaveLength(1);
|
||||
|
||||
// 再次发送相同 ID:去重
|
||||
act(() => {
|
||||
ws.triggerMessage(JSON.stringify({ payload: notification }));
|
||||
});
|
||||
expect(result.current.notifications).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("指数退避重连", () => {
|
||||
it("连接失败后按 1s/2s/4s/8s/16s 间隔重连,达到上限后停止", () => {
|
||||
const { result } = renderHook(() => useNotificationsWebSocket());
|
||||
|
||||
// 初始连接:1 个 WebSocket 实例
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
|
||||
// MAX_RETRIES=5, MAX_DELAY_MS=30000
|
||||
// 退避序列:retry0=1s, retry1=2s, retry2=4s, retry3=8s, retry4=16s(均 < 30s cap)
|
||||
const delays = [1000, 2000, 4000, 8000, 16000];
|
||||
|
||||
for (let i = 0; i < delays.length; i++) {
|
||||
const delay = delays[i]!;
|
||||
|
||||
// 触发连接失败
|
||||
act(() => MockWebSocket.instances[i]!.triggerClose());
|
||||
expect(result.current.connectionState).toBe("disconnected");
|
||||
|
||||
// 间隔前 1ms:尚未重连
|
||||
act(() => vi.advanceTimersByTime(delay - 1));
|
||||
expect(MockWebSocket.instances).toHaveLength(i + 1);
|
||||
|
||||
// 到达间隔:重连,新实例创建
|
||||
act(() => vi.advanceTimersByTime(1));
|
||||
expect(MockWebSocket.instances).toHaveLength(i + 2);
|
||||
expect(result.current.connectionState).toBe("connecting");
|
||||
}
|
||||
|
||||
// 5 次重试后(retryRef=5 >= MAX_RETRIES),不再重连
|
||||
act(() => MockWebSocket.instances[5]!.triggerClose());
|
||||
|
||||
// 推进 60s(远超 max 30s cap):无新实例
|
||||
act(() => vi.advanceTimersByTime(60000));
|
||||
expect(MockWebSocket.instances).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("手动 reconnect", () => {
|
||||
it("调用 reconnect 关闭旧连接并重新开始", () => {
|
||||
const { result } = renderHook(() => useNotificationsWebSocket());
|
||||
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
const oldWs = MockWebSocket.instances[0]!;
|
||||
|
||||
// 调用 reconnect
|
||||
act(() => result.current.reconnect());
|
||||
|
||||
// 旧 WebSocket 已关闭
|
||||
expect(oldWs.closed).toBe(true);
|
||||
|
||||
// 新连接已建立
|
||||
expect(MockWebSocket.instances).toHaveLength(2);
|
||||
expect(result.current.connectionState).toBe("connecting");
|
||||
});
|
||||
});
|
||||
|
||||
describe("卸载清理", () => {
|
||||
it("unmount 时关闭 WebSocket 连接", () => {
|
||||
const { unmount } = renderHook(() => useNotificationsWebSocket());
|
||||
|
||||
const ws = MockWebSocket.instances[0]!;
|
||||
expect(ws.closed).toBe(false);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(ws.closed).toBe(true);
|
||||
});
|
||||
|
||||
it("unmount 后不再触发重连定时器", () => {
|
||||
const { unmount } = renderHook(() => useNotificationsWebSocket());
|
||||
|
||||
// 触发连接失败,调度重连定时器
|
||||
act(() => MockWebSocket.instances[0]!.triggerClose());
|
||||
|
||||
unmount();
|
||||
|
||||
const countBefore = MockWebSocket.instances.length;
|
||||
|
||||
// 推进 60s:disposed=true 阻止重连,无新实例
|
||||
act(() => vi.advanceTimersByTime(60000));
|
||||
expect(MockWebSocket.instances.length).toBe(countBefore);
|
||||
});
|
||||
});
|
||||
});
|
||||
37
apps/teacher-portal/src/i18n.ts
Normal file
37
apps/teacher-portal/src/i18n.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* next-intl 非路由式 i18n 配置(cookie 驱动)
|
||||
*
|
||||
* - locales: zh-CN(默认)/ en
|
||||
* - 通过 NEXT_LOCALE cookie 切换语言(无 [locale] 路由段,不破坏现有 MF 路由)
|
||||
* - getRequestConfig 在每次请求时读取 cookie 并加载对应 messages
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { getRequestConfig } from "next-intl/server";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export const locales = ["zh-CN", "en"] as const;
|
||||
export type Locale = (typeof locales)[number];
|
||||
export const defaultLocale: Locale = "zh-CN";
|
||||
|
||||
function isLocale(value: string | undefined): value is Locale {
|
||||
return value != null && (locales as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export default getRequestConfig(async () => {
|
||||
const cookieStore = cookies();
|
||||
const cookieValue = cookieStore.get("NEXT_LOCALE")?.value;
|
||||
const locale: Locale = isLocale(cookieValue) ? cookieValue : defaultLocale;
|
||||
|
||||
const messages = (
|
||||
locale === "zh-CN"
|
||||
? await import("./messages/zh-CN.json")
|
||||
: await import("./messages/en.json")
|
||||
).default;
|
||||
|
||||
return {
|
||||
locale,
|
||||
messages,
|
||||
};
|
||||
});
|
||||
381
apps/teacher-portal/src/messages/en.json
Normal file
381
apps/teacher-portal/src/messages/en.json
Normal file
@@ -0,0 +1,381 @@
|
||||
{
|
||||
"common": {
|
||||
"brand": "Edu",
|
||||
"brandSubtitle": "Teacher Portal",
|
||||
"button": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"edit": "Edit",
|
||||
"export": "Export",
|
||||
"search": "Search",
|
||||
"delete": "Delete",
|
||||
"retry": "Retry"
|
||||
},
|
||||
"label": {
|
||||
"search": "Search",
|
||||
"loading": "Loading...",
|
||||
"student": "Student",
|
||||
"studentNo": "Student No.",
|
||||
"score": "Score",
|
||||
"feedback": "Feedback",
|
||||
"class": "Class",
|
||||
"subject": "Subject",
|
||||
"name": "Name",
|
||||
"email": "Email",
|
||||
"roles": "Roles"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Loading...",
|
||||
"saved": "Saved"
|
||||
},
|
||||
"error": {
|
||||
"loadFailed": "Failed to load: {message}",
|
||||
"pageError": "Something went wrong"
|
||||
},
|
||||
"nav": {
|
||||
"empty": "No viewports available",
|
||||
"loadError": "Navigation failed to load: {message}",
|
||||
"logout": "Log out",
|
||||
"noRoles": "No roles"
|
||||
},
|
||||
"locale": {
|
||||
"label": "Language",
|
||||
"zhCN": "中文",
|
||||
"en": "English"
|
||||
},
|
||||
"navLabel": {
|
||||
"dashboard": "Dashboard",
|
||||
"classes": "Classes",
|
||||
"exams": "Exams",
|
||||
"homework": "Homework",
|
||||
"grades": "Grades",
|
||||
"analytics": "Analytics",
|
||||
"knowledgeGraph": "Knowledge Graph",
|
||||
"notifications": "Notifications",
|
||||
"aiAssist": "AI Assist",
|
||||
"aiLessonPlan": "AI Lesson Plan",
|
||||
"aiReport": "AI Report",
|
||||
"attendance": "Attendance",
|
||||
"questions": "Questions",
|
||||
"textbooks": "Textbooks",
|
||||
"lessonPlans": "Lesson Plans",
|
||||
"coursePlans": "Course Plans",
|
||||
"diagnostic": "Diagnostic",
|
||||
"errorBook": "Error Book",
|
||||
"practice": "Practice",
|
||||
"elective": "Elective",
|
||||
"leave": "Leave Requests",
|
||||
"scheduleChanges": "Schedule Changes",
|
||||
"settings": "Settings",
|
||||
"students": "Students"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"title": "Login"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Welcome",
|
||||
"greeting": "Welcome, {name}",
|
||||
"subtitle": "{email} · Roles: {roles} · Data scope: {dataScope}",
|
||||
"subtitleNoRoles": "None",
|
||||
"error": {
|
||||
"loadFailed": "Dashboard failed to load: {message}"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No data",
|
||||
"description": "Dashboard data is not ready yet"
|
||||
},
|
||||
"stats": {
|
||||
"classes": "Total Classes",
|
||||
"exams": "Total Exams",
|
||||
"pendingGrading": "Pending Grading"
|
||||
},
|
||||
"classes": {
|
||||
"title": "My Classes",
|
||||
"count": "{count} total",
|
||||
"viewAll": "View all",
|
||||
"emptyTitle": "No classes",
|
||||
"emptyDescription": "Please contact the administrator to assign classes",
|
||||
"grade": "Grade: {gradeId}",
|
||||
"studentCount": "{count} students",
|
||||
"viewStudents": "View students"
|
||||
}
|
||||
},
|
||||
"classes": {
|
||||
"title": "Classes"
|
||||
},
|
||||
"exams": {
|
||||
"title": "Exams"
|
||||
},
|
||||
"homework": {
|
||||
"title": "Homework"
|
||||
},
|
||||
"grades": {
|
||||
"title": "Grades",
|
||||
"subtitle": "GraphQL ExamGradesQuery + RecordGradeMutation · core-edu domain (P3 extension, MSW mock)",
|
||||
"button": {
|
||||
"exportCsv": "Export CSV",
|
||||
"excelImport": "Excel Import",
|
||||
"showForm": "Enter Grade",
|
||||
"hideForm": "Collapse Form"
|
||||
},
|
||||
"nav": {
|
||||
"entry": "Grade Entry",
|
||||
"stats": "Statistics",
|
||||
"analytics": "Analytics",
|
||||
"reportCard": "Report Card"
|
||||
},
|
||||
"label": {
|
||||
"examId": "Exam ID"
|
||||
},
|
||||
"placeholder": {
|
||||
"examId": "Enter exam UUID"
|
||||
},
|
||||
"form": {
|
||||
"title": "Enter Grade",
|
||||
"label": {
|
||||
"studentId": "Student ID",
|
||||
"score": "Score",
|
||||
"feedback": "Feedback"
|
||||
},
|
||||
"placeholder": {
|
||||
"studentId": "Student UUID",
|
||||
"feedback": "Optional: grading feedback"
|
||||
},
|
||||
"button": {
|
||||
"submitting": "Saving...",
|
||||
"save": "Save Grade",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"status": {
|
||||
"saved": "Saved"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"studentIdRequired": "Please enter a student ID",
|
||||
"examIdRequired": "Please specify an exam ID first",
|
||||
"scoreInvalid": "Score must be a number",
|
||||
"fileRequired": "Please select a file",
|
||||
"noValidData": "No valid data parsed (format: studentId,score)",
|
||||
"importFailed": "Import failed: {message}",
|
||||
"fileReadFailed": "Failed to read file"
|
||||
},
|
||||
"success": {
|
||||
"imported": "Imported {count} grade(s)"
|
||||
},
|
||||
"empty": {
|
||||
"noExamIdTitle": "Please enter an exam ID",
|
||||
"noExamIdDescription": "Enter an exam UUID to query grades",
|
||||
"noGradesTitle": "No grades",
|
||||
"noGradesDescription": "No grades have been entered for this exam"
|
||||
},
|
||||
"analysis": {
|
||||
"title": "Grade Analysis",
|
||||
"label": {
|
||||
"average": "Average",
|
||||
"max": "Highest",
|
||||
"min": "Lowest",
|
||||
"passRate": "Pass Rate"
|
||||
},
|
||||
"chart": {
|
||||
"title": "Score Distribution"
|
||||
}
|
||||
},
|
||||
"list": {
|
||||
"title": "Grade List",
|
||||
"label": {
|
||||
"studentId": "Student ID: {id}",
|
||||
"feedback": "Feedback: {feedback}",
|
||||
"exam": "Exam: {id}",
|
||||
"homeworkScore": "Homework Score"
|
||||
},
|
||||
"button": {
|
||||
"exportCsv": "Export CSV"
|
||||
}
|
||||
},
|
||||
"entry": {
|
||||
"back": "← Back to Grades",
|
||||
"title": "Batch Grade Entry",
|
||||
"subtitle": "GraphQL GradeEntryQuery + SaveGradeEntriesMutation · core-edu domain (P7 extension, MSW mock)",
|
||||
"label": {
|
||||
"class": "Class",
|
||||
"exam": "Exam Paper"
|
||||
},
|
||||
"placeholder": {
|
||||
"selectClass": "Select a class",
|
||||
"selectExam": "Select an exam paper",
|
||||
"feedback": "Optional: grading feedback"
|
||||
},
|
||||
"info": {
|
||||
"sameGrade": "Same grade classes: {names} (Grade {gradeId})"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Please select class and exam",
|
||||
"description": "Select a class first, then an exam paper to load the student grade entry list"
|
||||
},
|
||||
"error": {
|
||||
"loadFailed": "Failed to load: {message}",
|
||||
"classExamRequired": "Please select class and exam first",
|
||||
"noValidEntries": "Please fill in at least one valid grade"
|
||||
},
|
||||
"emptyStudents": {
|
||||
"title": "No students",
|
||||
"description": "No student roster has been imported for this class"
|
||||
},
|
||||
"table": {
|
||||
"title": "Student Grade Entry",
|
||||
"studentCount": "{count} students",
|
||||
"header": {
|
||||
"student": "Student",
|
||||
"studentNo": "Student No.",
|
||||
"score": "Score",
|
||||
"feedback": "Feedback"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"submitting": "Saving...",
|
||||
"saveAll": "Save All"
|
||||
},
|
||||
"success": {
|
||||
"saved": "Saved {count} grade(s)",
|
||||
"savedRecords": "Saved {count} grade record(s)"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"back": "← Back to Grades",
|
||||
"title": "Class Grade Statistics",
|
||||
"subtitle": "GraphQL GradeStatsQuery · core-edu domain (P7 extension, MSW mock)",
|
||||
"button": {
|
||||
"exportCsv": "Export CSV"
|
||||
},
|
||||
"label": {
|
||||
"class": "Class",
|
||||
"subject": "Subject"
|
||||
},
|
||||
"placeholder": {
|
||||
"selectClass": "Select a class"
|
||||
},
|
||||
"error": {
|
||||
"loadFailed": "Failed to load: {message}"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No statistics data",
|
||||
"description": "Please select a class and subject"
|
||||
},
|
||||
"stats": {
|
||||
"average": "Average",
|
||||
"median": "Median",
|
||||
"max": "Highest",
|
||||
"min": "Lowest",
|
||||
"passRate": "Pass Rate",
|
||||
"footer": "Std. dev. σ = {stdDev} · {count} students total"
|
||||
},
|
||||
"ranking": {
|
||||
"title": "Student Ranking",
|
||||
"header": {
|
||||
"rank": "Rank",
|
||||
"studentNo": "Student No.",
|
||||
"name": "Name",
|
||||
"totalScore": "Total Score",
|
||||
"level": "Level"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Analytics"
|
||||
},
|
||||
"knowledgeGraph": {
|
||||
"title": "Knowledge Graph"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications"
|
||||
},
|
||||
"aiAssist": {
|
||||
"title": "AI Assist"
|
||||
},
|
||||
"aiLessonPlan": {
|
||||
"title": "AI Lesson Plan"
|
||||
},
|
||||
"aiReport": {
|
||||
"title": "AI Report"
|
||||
},
|
||||
"attendance": {
|
||||
"title": "Attendance"
|
||||
},
|
||||
"questions": {
|
||||
"title": "Questions"
|
||||
},
|
||||
"textbooks": {
|
||||
"title": "Textbooks"
|
||||
},
|
||||
"lessonPlans": {
|
||||
"title": "Lesson Plans"
|
||||
},
|
||||
"coursePlans": {
|
||||
"title": "Course Plans"
|
||||
},
|
||||
"diagnostic": {
|
||||
"title": "Diagnostic"
|
||||
},
|
||||
"errorBook": {
|
||||
"title": "Error Book"
|
||||
},
|
||||
"practice": {
|
||||
"title": "Practice"
|
||||
},
|
||||
"elective": {
|
||||
"title": "Elective"
|
||||
},
|
||||
"leave": {
|
||||
"title": "Leave Requests"
|
||||
},
|
||||
"scheduleChanges": {
|
||||
"title": "Schedule Changes"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"subtitle": "GraphQL MeQuery + UpdateUserMutation · P3 edit mode",
|
||||
"button": {
|
||||
"edit": "Edit"
|
||||
},
|
||||
"error": {
|
||||
"loadFailed": "Failed to load: {message}",
|
||||
"nameRequired": "Name cannot be empty",
|
||||
"emailInvalid": "Invalid email format"
|
||||
},
|
||||
"empty": "No user information available",
|
||||
"edit": {
|
||||
"title": "Edit Profile"
|
||||
},
|
||||
"form": {
|
||||
"label": {
|
||||
"name": "Name",
|
||||
"email": "Email"
|
||||
},
|
||||
"button": {
|
||||
"submitting": "Saving...",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"status": {
|
||||
"saved": "Saved successfully"
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"title": "Basic Information",
|
||||
"label": {
|
||||
"userId": "User ID",
|
||||
"name": "Name",
|
||||
"email": "Email",
|
||||
"roles": "Roles",
|
||||
"dataScope": "Data Scope"
|
||||
},
|
||||
"noRoles": "No roles",
|
||||
"editHint": "P3 editing is enabled (UpdateUserMutation). Click the \"Edit\" button in the top right to modify your name and email."
|
||||
}
|
||||
},
|
||||
"students": {
|
||||
"title": "Students"
|
||||
}
|
||||
}
|
||||
381
apps/teacher-portal/src/messages/zh-CN.json
Normal file
381
apps/teacher-portal/src/messages/zh-CN.json
Normal file
@@ -0,0 +1,381 @@
|
||||
{
|
||||
"common": {
|
||||
"brand": "Edu",
|
||||
"brandSubtitle": "教师端",
|
||||
"button": {
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"edit": "编辑",
|
||||
"export": "导出",
|
||||
"search": "搜索",
|
||||
"delete": "删除",
|
||||
"retry": "重试"
|
||||
},
|
||||
"label": {
|
||||
"search": "搜索",
|
||||
"loading": "加载中...",
|
||||
"student": "学生",
|
||||
"studentNo": "学号",
|
||||
"score": "分数",
|
||||
"feedback": "反馈",
|
||||
"class": "班级",
|
||||
"subject": "学科",
|
||||
"name": "姓名",
|
||||
"email": "邮箱",
|
||||
"roles": "角色"
|
||||
},
|
||||
"status": {
|
||||
"loading": "加载中...",
|
||||
"saved": "已保存"
|
||||
},
|
||||
"error": {
|
||||
"loadFailed": "加载失败:{message}",
|
||||
"pageError": "页面出错了"
|
||||
},
|
||||
"nav": {
|
||||
"empty": "暂无可用视口",
|
||||
"loadError": "导航加载失败:{message}",
|
||||
"logout": "退出登录",
|
||||
"noRoles": "无角色"
|
||||
},
|
||||
"locale": {
|
||||
"label": "语言",
|
||||
"zhCN": "中文",
|
||||
"en": "English"
|
||||
},
|
||||
"navLabel": {
|
||||
"dashboard": "仪表盘",
|
||||
"classes": "班级管理",
|
||||
"exams": "考试管理",
|
||||
"homework": "作业管理",
|
||||
"grades": "成绩查询",
|
||||
"analytics": "学情分析",
|
||||
"knowledgeGraph": "知识图谱",
|
||||
"notifications": "通知中心",
|
||||
"aiAssist": "AI 辅助",
|
||||
"aiLessonPlan": "AI 教案",
|
||||
"aiReport": "AI 学情报告",
|
||||
"attendance": "考勤管理",
|
||||
"questions": "题库",
|
||||
"textbooks": "教材",
|
||||
"lessonPlans": "备课",
|
||||
"coursePlans": "课程计划",
|
||||
"diagnostic": "诊断报告",
|
||||
"errorBook": "错题本",
|
||||
"practice": "练习分析",
|
||||
"elective": "选修课",
|
||||
"leave": "请假审批",
|
||||
"scheduleChanges": "调课申请",
|
||||
"settings": "个人设置",
|
||||
"students": "学生"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"title": "登录"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "欢迎",
|
||||
"greeting": "欢迎,{name}",
|
||||
"subtitle": "{email} · 角色:{roles} · 数据范围:{dataScope}",
|
||||
"subtitleNoRoles": "无",
|
||||
"error": {
|
||||
"loadFailed": "仪表盘加载失败:{message}"
|
||||
},
|
||||
"empty": {
|
||||
"title": "暂无数据",
|
||||
"description": "仪表盘数据尚未就绪"
|
||||
},
|
||||
"stats": {
|
||||
"classes": "班级总数",
|
||||
"exams": "考试总数",
|
||||
"pendingGrading": "待批改"
|
||||
},
|
||||
"classes": {
|
||||
"title": "我的班级",
|
||||
"count": "{count} 个",
|
||||
"viewAll": "查看全部",
|
||||
"emptyTitle": "暂无班级",
|
||||
"emptyDescription": "请联系管理员分配班级",
|
||||
"grade": "年级:{gradeId}",
|
||||
"studentCount": "{count} 名学生",
|
||||
"viewStudents": "查看学生"
|
||||
}
|
||||
},
|
||||
"classes": {
|
||||
"title": "班级管理"
|
||||
},
|
||||
"exams": {
|
||||
"title": "考试管理"
|
||||
},
|
||||
"homework": {
|
||||
"title": "作业管理"
|
||||
},
|
||||
"grades": {
|
||||
"title": "成绩查询",
|
||||
"subtitle": "GraphQL ExamGradesQuery + RecordGradeMutation · core-edu 域(P3 扩展,MSW mock)",
|
||||
"button": {
|
||||
"exportCsv": "导出 CSV",
|
||||
"excelImport": "Excel 导入",
|
||||
"showForm": "录入成绩",
|
||||
"hideForm": "收起录入"
|
||||
},
|
||||
"nav": {
|
||||
"entry": "录入成绩",
|
||||
"stats": "统计",
|
||||
"analytics": "分析",
|
||||
"reportCard": "报告卡"
|
||||
},
|
||||
"label": {
|
||||
"examId": "考试 ID"
|
||||
},
|
||||
"placeholder": {
|
||||
"examId": "输入考试 UUID"
|
||||
},
|
||||
"form": {
|
||||
"title": "录入成绩",
|
||||
"label": {
|
||||
"studentId": "学生 ID",
|
||||
"score": "分数",
|
||||
"feedback": "反馈"
|
||||
},
|
||||
"placeholder": {
|
||||
"studentId": "学生 UUID",
|
||||
"feedback": "可选:批改意见"
|
||||
},
|
||||
"button": {
|
||||
"submitting": "录入中...",
|
||||
"save": "保存成绩",
|
||||
"cancel": "取消"
|
||||
},
|
||||
"status": {
|
||||
"saved": "已保存"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"studentIdRequired": "请填写学生 ID",
|
||||
"examIdRequired": "请先指定考试 ID",
|
||||
"scoreInvalid": "分数必须为数字",
|
||||
"fileRequired": "请选择文件",
|
||||
"noValidData": "未解析到有效数据(格式:学号,分数)",
|
||||
"importFailed": "导入失败:{message}",
|
||||
"fileReadFailed": "文件读取失败"
|
||||
},
|
||||
"success": {
|
||||
"imported": "已导入 {count} 条成绩"
|
||||
},
|
||||
"empty": {
|
||||
"noExamIdTitle": "请输入考试 ID",
|
||||
"noExamIdDescription": "输入考试 UUID 后查询成绩",
|
||||
"noGradesTitle": "暂无成绩",
|
||||
"noGradesDescription": "该考试尚未录入成绩"
|
||||
},
|
||||
"analysis": {
|
||||
"title": "成绩分析",
|
||||
"label": {
|
||||
"average": "平均分",
|
||||
"max": "最高分",
|
||||
"min": "最低分",
|
||||
"passRate": "及格率"
|
||||
},
|
||||
"chart": {
|
||||
"title": "分数段分布"
|
||||
}
|
||||
},
|
||||
"list": {
|
||||
"title": "成绩列表",
|
||||
"label": {
|
||||
"studentId": "学生 ID: {id}",
|
||||
"feedback": "反馈: {feedback}",
|
||||
"exam": "考试: {id}",
|
||||
"homeworkScore": "作业成绩"
|
||||
},
|
||||
"button": {
|
||||
"exportCsv": "导出 CSV"
|
||||
}
|
||||
},
|
||||
"entry": {
|
||||
"back": "← 返回成绩查询",
|
||||
"title": "成绩批量录入",
|
||||
"subtitle": "GraphQL GradeEntryQuery + SaveGradeEntriesMutation · core-edu 域(P7 扩展,MSW mock)",
|
||||
"label": {
|
||||
"class": "班级",
|
||||
"exam": "试卷"
|
||||
},
|
||||
"placeholder": {
|
||||
"selectClass": "请选择班级",
|
||||
"selectExam": "请选择试卷",
|
||||
"feedback": "可选:批改意见"
|
||||
},
|
||||
"info": {
|
||||
"sameGrade": "同年级班级:{names}(年级 {gradeId})"
|
||||
},
|
||||
"empty": {
|
||||
"title": "请选择班级和试卷",
|
||||
"description": "选择班级后选择试卷,再拉取学生成绩录入列表"
|
||||
},
|
||||
"error": {
|
||||
"loadFailed": "加载失败:{message}",
|
||||
"classExamRequired": "请先选择班级和试卷",
|
||||
"noValidEntries": "请至少填写一条有效成绩"
|
||||
},
|
||||
"emptyStudents": {
|
||||
"title": "暂无学生",
|
||||
"description": "该班级尚未导入学生名单"
|
||||
},
|
||||
"table": {
|
||||
"title": "学生成绩录入",
|
||||
"studentCount": "{count} 名学生",
|
||||
"header": {
|
||||
"student": "学生",
|
||||
"studentNo": "学号",
|
||||
"score": "分数",
|
||||
"feedback": "反馈"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"submitting": "保存中...",
|
||||
"saveAll": "保存全部"
|
||||
},
|
||||
"success": {
|
||||
"saved": "已保存 {count} 条成绩",
|
||||
"savedRecords": "已保存 {count} 条成绩记录"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"back": "← 返回成绩查询",
|
||||
"title": "班级成绩统计",
|
||||
"subtitle": "GraphQL GradeStatsQuery · core-edu 域(P7 扩展,MSW mock)",
|
||||
"button": {
|
||||
"exportCsv": "导出 CSV"
|
||||
},
|
||||
"label": {
|
||||
"class": "班级",
|
||||
"subject": "学科"
|
||||
},
|
||||
"placeholder": {
|
||||
"selectClass": "请选择班级"
|
||||
},
|
||||
"error": {
|
||||
"loadFailed": "加载失败:{message}"
|
||||
},
|
||||
"empty": {
|
||||
"title": "暂无统计数据",
|
||||
"description": "请选择班级和学科"
|
||||
},
|
||||
"stats": {
|
||||
"average": "平均分",
|
||||
"median": "中位数",
|
||||
"max": "最高分",
|
||||
"min": "最低分",
|
||||
"passRate": "及格率",
|
||||
"footer": "标准差 σ = {stdDev} · 共 {count} 名学生"
|
||||
},
|
||||
"ranking": {
|
||||
"title": "学生排名",
|
||||
"header": {
|
||||
"rank": "排名",
|
||||
"studentNo": "学号",
|
||||
"name": "姓名",
|
||||
"totalScore": "总分",
|
||||
"level": "等级"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"title": "学情分析"
|
||||
},
|
||||
"knowledgeGraph": {
|
||||
"title": "知识图谱"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "通知中心"
|
||||
},
|
||||
"aiAssist": {
|
||||
"title": "AI 辅助"
|
||||
},
|
||||
"aiLessonPlan": {
|
||||
"title": "AI 教案"
|
||||
},
|
||||
"aiReport": {
|
||||
"title": "AI 学情报告"
|
||||
},
|
||||
"attendance": {
|
||||
"title": "考勤管理"
|
||||
},
|
||||
"questions": {
|
||||
"title": "题库"
|
||||
},
|
||||
"textbooks": {
|
||||
"title": "教材"
|
||||
},
|
||||
"lessonPlans": {
|
||||
"title": "备课"
|
||||
},
|
||||
"coursePlans": {
|
||||
"title": "课程计划"
|
||||
},
|
||||
"diagnostic": {
|
||||
"title": "诊断报告"
|
||||
},
|
||||
"errorBook": {
|
||||
"title": "错题本"
|
||||
},
|
||||
"practice": {
|
||||
"title": "练习分析"
|
||||
},
|
||||
"elective": {
|
||||
"title": "选修课"
|
||||
},
|
||||
"leave": {
|
||||
"title": "请假审批"
|
||||
},
|
||||
"scheduleChanges": {
|
||||
"title": "调课申请"
|
||||
},
|
||||
"settings": {
|
||||
"title": "个人设置",
|
||||
"subtitle": "GraphQL MeQuery + UpdateUserMutation · P3 编辑模式",
|
||||
"button": {
|
||||
"edit": "编辑"
|
||||
},
|
||||
"error": {
|
||||
"loadFailed": "加载失败:{message}",
|
||||
"nameRequired": "姓名不能为空",
|
||||
"emailInvalid": "邮箱格式不正确"
|
||||
},
|
||||
"empty": "暂无用户信息",
|
||||
"edit": {
|
||||
"title": "编辑个人信息"
|
||||
},
|
||||
"form": {
|
||||
"label": {
|
||||
"name": "姓名",
|
||||
"email": "邮箱"
|
||||
},
|
||||
"button": {
|
||||
"submitting": "保存中...",
|
||||
"save": "保存",
|
||||
"cancel": "取消"
|
||||
},
|
||||
"status": {
|
||||
"saved": "保存成功"
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"title": "基本信息",
|
||||
"label": {
|
||||
"userId": "用户 ID",
|
||||
"name": "姓名",
|
||||
"email": "邮箱",
|
||||
"roles": "角色",
|
||||
"dataScope": "数据范围"
|
||||
},
|
||||
"noRoles": "无角色",
|
||||
"editHint": "P3 已启用编辑功能(UpdateUserMutation)。点击右上角“编辑”按钮修改姓名与邮箱。"
|
||||
}
|
||||
},
|
||||
"students": {
|
||||
"title": "学生"
|
||||
}
|
||||
}
|
||||
196
apps/teacher-portal/src/mocks/__tests__/handlers-p4.test.ts
Normal file
196
apps/teacher-portal/src/mocks/__tests__/handlers-p4.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* MSW P4 Handlers 单元测试
|
||||
*
|
||||
* 测试 handleP4GraphQL 函数:
|
||||
* - KnowledgeGraph 操作返回 nodes + edges
|
||||
* - ClassAnalytics 操作返回班级统计
|
||||
* - StudentAnalytics 操作返回单生学情
|
||||
* - 未覆盖的 operationName 返回 null(fallthrough)
|
||||
*
|
||||
* 直接测试 handleP4GraphQL 函数返回值(HttpResponse 或 null)。
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { handleP4GraphQL } from "../handlers-p4";
|
||||
import {
|
||||
mockKnowledgeGraph,
|
||||
filterKnowledgeGraph,
|
||||
} from "../fixtures/knowledge-graph";
|
||||
import {
|
||||
mockClassAnalytics,
|
||||
findStudentAnalytics,
|
||||
} from "../fixtures/analytics";
|
||||
|
||||
/** 从 HttpResponse 中提取 JSON 数据 */
|
||||
async function extractJson(res: Response): Promise<Record<string, unknown>> {
|
||||
return (await res.json()) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("handleP4GraphQL: KnowledgeGraph", () => {
|
||||
it("subject 为 null 时返回全量图谱(13 节点 + 10 边)", async () => {
|
||||
const res = handleP4GraphQL("KnowledgeGraph", { subject: null });
|
||||
expect(res).not.toBeNull();
|
||||
const body = await extractJson(res!);
|
||||
const data = body.data as {
|
||||
knowledgeGraph: typeof mockKnowledgeGraph;
|
||||
};
|
||||
expect(data.knowledgeGraph.nodes.length).toBe(
|
||||
mockKnowledgeGraph.nodes.length,
|
||||
);
|
||||
expect(data.knowledgeGraph.edges.length).toBe(
|
||||
mockKnowledgeGraph.edges.length,
|
||||
);
|
||||
});
|
||||
|
||||
it("subject 为 '数学' 时仅返回数学节点(5 节点)", async () => {
|
||||
const res = handleP4GraphQL("KnowledgeGraph", { subject: "数学" });
|
||||
expect(res).not.toBeNull();
|
||||
const body = await extractJson(res!);
|
||||
const data = body.data as {
|
||||
knowledgeGraph: { nodes: Array<{ subject: string }>; edges: unknown[] };
|
||||
};
|
||||
const expected = filterKnowledgeGraph("数学");
|
||||
expect(data.knowledgeGraph.nodes.length).toBe(expected.nodes.length);
|
||||
for (const node of data.knowledgeGraph.nodes) {
|
||||
expect(node.subject).toBe("数学");
|
||||
}
|
||||
});
|
||||
|
||||
it("subject 为 undefined 时返回全量(兜底)", async () => {
|
||||
const res = handleP4GraphQL("KnowledgeGraph", {});
|
||||
expect(res).not.toBeNull();
|
||||
const body = await extractJson(res!);
|
||||
const data = body.data as {
|
||||
knowledgeGraph: typeof mockKnowledgeGraph;
|
||||
};
|
||||
expect(data.knowledgeGraph.nodes.length).toBe(
|
||||
mockKnowledgeGraph.nodes.length,
|
||||
);
|
||||
});
|
||||
|
||||
it("每条 edge 引用的节点存在于 nodes 列表中", async () => {
|
||||
const res = handleP4GraphQL("KnowledgeGraph", { subject: "数学" });
|
||||
expect(res).not.toBeNull();
|
||||
const body = await extractJson(res!);
|
||||
const data = body.data as {
|
||||
knowledgeGraph: {
|
||||
nodes: Array<{ id: string }>;
|
||||
edges: Array<{ from: string; to: string }>;
|
||||
};
|
||||
};
|
||||
const nodeIds = new Set(data.knowledgeGraph.nodes.map((n) => n.id));
|
||||
for (const edge of data.knowledgeGraph.edges) {
|
||||
expect(nodeIds.has(edge.from)).toBe(true);
|
||||
expect(nodeIds.has(edge.to)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleP4GraphQL: ClassAnalytics", () => {
|
||||
it("返回班级学情分析数据(含 avgScore / passRate / topStudents)", async () => {
|
||||
const classId = "class-test-001";
|
||||
const res = handleP4GraphQL("ClassAnalytics", { classId });
|
||||
expect(res).not.toBeNull();
|
||||
const body = await extractJson(res!);
|
||||
const data = body.data as {
|
||||
classAnalytics: {
|
||||
classId: string;
|
||||
className: string;
|
||||
avgScore: number;
|
||||
passRate: number;
|
||||
avgTrend: number[];
|
||||
topStudents: unknown[];
|
||||
weakPoints: string[];
|
||||
};
|
||||
};
|
||||
expect(data.classAnalytics.classId).toBe(classId);
|
||||
expect(data.classAnalytics.className).toBe(mockClassAnalytics.className);
|
||||
expect(typeof data.classAnalytics.avgScore).toBe("number");
|
||||
expect(typeof data.classAnalytics.passRate).toBe("number");
|
||||
expect(Array.isArray(data.classAnalytics.avgTrend)).toBe(true);
|
||||
expect(data.classAnalytics.avgTrend.length).toBeGreaterThan(0);
|
||||
expect(Array.isArray(data.classAnalytics.topStudents)).toBe(true);
|
||||
expect(data.classAnalytics.topStudents.length).toBeGreaterThan(0);
|
||||
expect(Array.isArray(data.classAnalytics.weakPoints)).toBe(true);
|
||||
});
|
||||
|
||||
it("classId 为 undefined 时回退到 mockClassAnalytics.classId", async () => {
|
||||
const res = handleP4GraphQL("ClassAnalytics", {});
|
||||
expect(res).not.toBeNull();
|
||||
const body = await extractJson(res!);
|
||||
const data = body.data as { classAnalytics: { classId: string } };
|
||||
expect(data.classAnalytics.classId).toBe(mockClassAnalytics.classId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleP4GraphQL: StudentAnalytics", () => {
|
||||
it("返回指定学生的学情数据(命中 stu-001)", async () => {
|
||||
const studentId = "stu-001";
|
||||
const res = handleP4GraphQL("StudentAnalytics", { studentId });
|
||||
expect(res).not.toBeNull();
|
||||
const body = await extractJson(res!);
|
||||
const data = body.data as {
|
||||
studentAnalytics: {
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
avgScore: number;
|
||||
trend: number[];
|
||||
weakPoints: string[];
|
||||
strongPoints: string[];
|
||||
masteryRate: number;
|
||||
};
|
||||
};
|
||||
const expected = findStudentAnalytics(studentId);
|
||||
expect(data.studentAnalytics.studentId).toBe(studentId);
|
||||
expect(data.studentAnalytics.studentName).toBe(expected.studentName);
|
||||
expect(data.studentAnalytics.avgScore).toBe(expected.avgScore);
|
||||
expect(data.studentAnalytics.trend).toEqual(expected.trend);
|
||||
expect(data.studentAnalytics.weakPoints).toEqual(expected.weakPoints);
|
||||
expect(data.studentAnalytics.strongPoints).toEqual(expected.strongPoints);
|
||||
expect(data.studentAnalytics.masteryRate).toBe(expected.masteryRate);
|
||||
});
|
||||
|
||||
it("未命中的 studentId 返回兜底数据(非空)", async () => {
|
||||
const studentId = "stu-unknown-999";
|
||||
const res = handleP4GraphQL("StudentAnalytics", { studentId });
|
||||
expect(res).not.toBeNull();
|
||||
const body = await extractJson(res!);
|
||||
const data = body.data as {
|
||||
studentAnalytics: { studentId: string; avgScore: number };
|
||||
};
|
||||
expect(data.studentAnalytics.studentId).toBe(studentId);
|
||||
expect(typeof data.studentAnalytics.avgScore).toBe("number");
|
||||
});
|
||||
|
||||
it("studentId 为 undefined 时回退到 stu-001", async () => {
|
||||
const res = handleP4GraphQL("StudentAnalytics", {});
|
||||
expect(res).not.toBeNull();
|
||||
const body = await extractJson(res!);
|
||||
const data = body.data as {
|
||||
studentAnalytics: { studentId: string };
|
||||
};
|
||||
expect(data.studentAnalytics.studentId).toBe("stu-001");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleP4GraphQL: 未覆盖的 operationName", () => {
|
||||
it("非 P4 operation 返回 null(fallthrough)", () => {
|
||||
const res = handleP4GraphQL("Dashboard", {});
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
|
||||
it("Me operation 返回 null", () => {
|
||||
const res = handleP4GraphQL("Me", {});
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
|
||||
it("空字符串 operationName 返回 null", () => {
|
||||
const res = handleP4GraphQL("", {});
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
|
||||
it("Unknown operationName 返回 null", () => {
|
||||
const res = handleP4GraphQL("SomeFutureOperation", {});
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
});
|
||||
192
apps/teacher-portal/src/mocks/__tests__/handlers.test.ts
Normal file
192
apps/teacher-portal/src/mocks/__tests__/handlers.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* MSW Handlers 单元测试
|
||||
*
|
||||
* 测试 handlers.ts 拦截的关键 operationName:
|
||||
* - POST /api/auth/login → 登录返回 token
|
||||
* - POST /api/v1/teacher/graphql → Me / Viewports / CreateExam / 未知 operation
|
||||
* - GET /api/health、GET /api/ready → 健康检查
|
||||
*
|
||||
* 使用 msw/node server(在 vitest.setup.ts 中启动)+ fetch 调用验证。
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { server } from "../server";
|
||||
import { handlers } from "../handlers";
|
||||
import { mockUser } from "../fixtures/user";
|
||||
import { mockViewports } from "../fixtures/viewports";
|
||||
|
||||
const GRAPHQL_URL = "http://localhost/api/v1/teacher/graphql";
|
||||
const LOGIN_URL = "http://localhost/api/auth/login";
|
||||
const HEALTH_URL = "http://localhost/api/health";
|
||||
const READY_URL = "http://localhost/api/ready";
|
||||
|
||||
// 替换 MSW handlers 为仅 handlers.ts(避免 p7/p4/p5 抢占同一 endpoint
|
||||
// 导致 request body 被多次读取的 "Body is unusable" 错误)
|
||||
beforeAll(() => {
|
||||
server.resetHandlers(...handlers);
|
||||
});
|
||||
afterAll(() => {
|
||||
server.restoreHandlers();
|
||||
});
|
||||
|
||||
async function graphql(
|
||||
operationName: string,
|
||||
variables: Record<string, unknown> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const res = await fetch(GRAPHQL_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ operationName, variables }),
|
||||
});
|
||||
return (await res.json()) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("handlers: POST /api/auth/login", () => {
|
||||
it("邮箱密码齐全时返回 envelope 包含 user + tokens", async () => {
|
||||
const res = await fetch(LOGIN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "teacher@edu.test", password: "pass" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
success: boolean;
|
||||
data: { user: typeof mockUser; tokens: { accessToken: string } };
|
||||
};
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.user.id).toBe(mockUser.id);
|
||||
expect(body.data.user.email).toBe(mockUser.email);
|
||||
expect(typeof body.data.tokens.accessToken).toBe("string");
|
||||
expect(body.data.tokens.accessToken.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("缺少 email 时返回 400", async () => {
|
||||
const res = await fetch(LOGIN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password: "pass" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as {
|
||||
success: boolean;
|
||||
error: { code: string };
|
||||
};
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error.code).toBe("GW_UNAUTHORIZED");
|
||||
});
|
||||
|
||||
it("缺少 password 时返回 400", async () => {
|
||||
const res = await fetch(LOGIN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "teacher@edu.test" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handlers: POST /api/v1/teacher/graphql Me", () => {
|
||||
it("Me 操作返回 mockUser", async () => {
|
||||
const body = await graphql("Me");
|
||||
const data = body.data as { me: typeof mockUser };
|
||||
expect(data.me).toBeDefined();
|
||||
expect(data.me.id).toBe(mockUser.id);
|
||||
expect(data.me.email).toBe(mockUser.email);
|
||||
expect(data.me.name).toBe(mockUser.name);
|
||||
expect(data.me.roles).toEqual(mockUser.roles);
|
||||
expect(data.me.dataScope).toBe(mockUser.dataScope);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handlers: POST /api/v1/teacher/graphql Viewports", () => {
|
||||
it("Viewports 操作返回 mockViewports(实际 23 项)", async () => {
|
||||
const body = await graphql("Viewports");
|
||||
const data = body.data as { viewports: typeof mockViewports };
|
||||
expect(Array.isArray(data.viewports)).toBe(true);
|
||||
expect(data.viewports.length).toBe(mockViewports.length);
|
||||
// 抽样校验首项
|
||||
const first = data.viewports[0];
|
||||
expect(first).toBeDefined();
|
||||
expect(first?.key).toBe("dashboard");
|
||||
expect(first?.label).toBe("仪表盘");
|
||||
expect(first?.route).toBe("/dashboard");
|
||||
expect(first?.sortOrder).toBe("01");
|
||||
});
|
||||
|
||||
it("每项视口包含必需字段", async () => {
|
||||
const body = await graphql("Viewports");
|
||||
const data = body.data as { viewports: typeof mockViewports };
|
||||
for (const v of data.viewports) {
|
||||
expect(typeof v.key).toBe("string");
|
||||
expect(typeof v.label).toBe("string");
|
||||
expect(typeof v.route).toBe("string");
|
||||
expect(typeof v.sortOrder).toBe("string");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("handlers: POST /api/v1/teacher/graphql CreateExam", () => {
|
||||
it("CreateExam 操作返回 examId(含 classId 前缀)", async () => {
|
||||
const input = {
|
||||
classId: "class-test-001",
|
||||
title: "单元测试一",
|
||||
description: "覆盖函数与导数",
|
||||
examDate: new Date().toISOString(),
|
||||
duration: 90,
|
||||
totalScore: 100,
|
||||
};
|
||||
const body = await graphql("CreateExam", { input });
|
||||
const data = body.data as {
|
||||
createExam: {
|
||||
id: string;
|
||||
classId: string;
|
||||
title: string;
|
||||
status: string;
|
||||
};
|
||||
};
|
||||
expect(data.createExam).toBeDefined();
|
||||
expect(data.createExam.id).toContain(input.classId);
|
||||
expect(data.createExam.classId).toBe(input.classId);
|
||||
expect(data.createExam.title).toBe(input.title);
|
||||
expect(data.createExam.status).toBe("DRAFT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handlers: POST /api/v1/teacher/graphql 未知 operation", () => {
|
||||
it("未覆盖的 operationName 返回 GraphQL errors", async () => {
|
||||
const body = await graphql("NotARealOperation");
|
||||
expect(body.data).toBeUndefined();
|
||||
const errors = body.errors as Array<{
|
||||
message: string;
|
||||
extensions: { code: string };
|
||||
}>;
|
||||
expect(Array.isArray(errors)).toBe(true);
|
||||
expect(errors[0]?.extensions.code).toBe("BFF_TEACHER_NOT_IMPLEMENTED");
|
||||
expect(errors[0]?.message).toContain("NotARealOperation");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handlers: GET /api/health", () => {
|
||||
it("返回 ok 状态", async () => {
|
||||
const res = await fetch(HEALTH_URL);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { status: string; service: string };
|
||||
expect(body.status).toBe("ok");
|
||||
expect(body.service).toBe("teacher-portal");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handlers: GET /api/ready", () => {
|
||||
it("返回 ok 状态 + checks", async () => {
|
||||
const res = await fetch(READY_URL);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
status: string;
|
||||
service: string;
|
||||
checks: { msw: boolean };
|
||||
};
|
||||
expect(body.status).toBe("ok");
|
||||
expect(body.service).toBe("teacher-portal");
|
||||
expect(body.checks.msw).toBe(true);
|
||||
});
|
||||
});
|
||||
204
apps/teacher-portal/src/mocks/fixtures/__tests__/exams.test.ts
Normal file
204
apps/teacher-portal/src/mocks/fixtures/__tests__/exams.test.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* exams fixture 单元测试
|
||||
*
|
||||
* 测试 generateMockExams / findMockExamDetail / createMockExam:
|
||||
* - generateMockExams 返回 3 项考试(PUBLISHED / DRAFT / SCORED)
|
||||
* - 每项考试包含必需字段
|
||||
* - findMockExamDetail 按已存在 ID 返回详情(含 questions)
|
||||
* - findMockExamDetail 未命中 ID 返回兜底详情
|
||||
* - createMockExam 返回 DRAFT 状态新考试,ID 含 classId 前缀
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
generateMockExams,
|
||||
findMockExamDetail,
|
||||
createMockExam,
|
||||
} from "../exams";
|
||||
import type { ExamItem, ExamDetail, CreateExamInput } from "@/lib/graphql";
|
||||
|
||||
const TEST_CLASS_ID = "class-ut-001";
|
||||
|
||||
describe("generateMockExams", () => {
|
||||
const exams = generateMockExams(TEST_CLASS_ID);
|
||||
|
||||
it("返回 3 项考试", () => {
|
||||
expect(exams).toHaveLength(3);
|
||||
expect(Array.isArray(exams)).toBe(true);
|
||||
});
|
||||
|
||||
it("三项考试状态覆盖 PUBLISHED / DRAFT / SCORED", () => {
|
||||
const statuses = new Set(exams.map((e) => e.status));
|
||||
expect(statuses.has("PUBLISHED")).toBe(true);
|
||||
expect(statuses.has("DRAFT")).toBe(true);
|
||||
expect(statuses.has("SCORED")).toBe(true);
|
||||
});
|
||||
|
||||
it("每项考试 ID 包含 classId 前缀", () => {
|
||||
for (const e of exams) {
|
||||
expect(e.id.startsWith(`${TEST_CLASS_ID}-exam-`)).toBe(true);
|
||||
expect(e.classId).toBe(TEST_CLASS_ID);
|
||||
}
|
||||
});
|
||||
|
||||
it("每项考试包含必需字段且类型正确", () => {
|
||||
for (const e of exams) {
|
||||
expect(typeof e.id).toBe("string");
|
||||
expect(e.id.length).toBeGreaterThan(0);
|
||||
expect(typeof e.classId).toBe("string");
|
||||
expect(typeof e.title).toBe("string");
|
||||
expect(e.title.length).toBeGreaterThan(0);
|
||||
// description 可为 string 或 null
|
||||
expect(e.description === null || typeof e.description === "string").toBe(
|
||||
true,
|
||||
);
|
||||
expect(typeof e.examDate).toBe("string");
|
||||
// examDate 应可被 Date 解析
|
||||
expect(() => new Date(e.examDate).toISOString()).not.toThrow();
|
||||
expect(typeof e.duration).toBe("number");
|
||||
expect(e.duration).toBeGreaterThan(0);
|
||||
expect(typeof e.totalScore).toBe("number");
|
||||
expect(e.totalScore).toBeGreaterThan(0);
|
||||
expect([
|
||||
"DRAFT",
|
||||
"PUBLISHED",
|
||||
"IN_PROGRESS",
|
||||
"GRADING",
|
||||
"SCORED",
|
||||
"ARCHIVED",
|
||||
]).toContain(e.status);
|
||||
}
|
||||
});
|
||||
|
||||
it("类型符合 ExamItem 接口", () => {
|
||||
const sample: ExamItem = exams[0]!;
|
||||
expect(sample).toHaveProperty("id");
|
||||
expect(sample).toHaveProperty("classId");
|
||||
expect(sample).toHaveProperty("title");
|
||||
expect(sample).toHaveProperty("examDate");
|
||||
expect(sample).toHaveProperty("duration");
|
||||
expect(sample).toHaveProperty("totalScore");
|
||||
expect(sample).toHaveProperty("status");
|
||||
});
|
||||
|
||||
it("不同 classId 生成的数据独立(不共享引用)", () => {
|
||||
const a = generateMockExams("class-A");
|
||||
const b = generateMockExams("class-B");
|
||||
expect(a[0]!.id).not.toBe(b[0]!.id);
|
||||
expect(a[0]!.classId).toBe("class-A");
|
||||
expect(b[0]!.classId).toBe("class-B");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findMockExamDetail", () => {
|
||||
it("按已存在 examId 返回详情(含 4 道题目)", () => {
|
||||
const exams = generateMockExams(TEST_CLASS_ID);
|
||||
const firstId = exams[0]!.id;
|
||||
const detail = findMockExamDetail(firstId);
|
||||
expect(detail.id).toBe(firstId);
|
||||
expect(detail.classId).toBe(TEST_CLASS_ID);
|
||||
expect(Array.isArray(detail.questions)).toBe(true);
|
||||
expect(detail.questions).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("题目字段完整(id / examId / title / type / score / order)", () => {
|
||||
const exams = generateMockExams(TEST_CLASS_ID);
|
||||
const firstId = exams[0]!.id;
|
||||
const detail = findMockExamDetail(firstId);
|
||||
for (const q of detail.questions) {
|
||||
expect(typeof q.id).toBe("string");
|
||||
expect(q.examId).toBe(firstId);
|
||||
expect(typeof q.title).toBe("string");
|
||||
expect([
|
||||
"SINGLE_CHOICE",
|
||||
"MULTIPLE_CHOICE",
|
||||
"SHORT_ANSWER",
|
||||
"ESSAY",
|
||||
]).toContain(q.type);
|
||||
expect(typeof q.score).toBe("number");
|
||||
expect(typeof q.order).toBe("number");
|
||||
}
|
||||
});
|
||||
|
||||
it("题目类型覆盖 SINGLE_CHOICE / MULTIPLE_CHOICE / SHORT_ANSWER / ESSAY", () => {
|
||||
const exams = generateMockExams(TEST_CLASS_ID);
|
||||
const firstId = exams[0]!.id;
|
||||
const detail = findMockExamDetail(firstId);
|
||||
const types = new Set(detail.questions.map((q) => q.type));
|
||||
expect(types.has("SINGLE_CHOICE")).toBe(true);
|
||||
expect(types.has("MULTIPLE_CHOICE")).toBe(true);
|
||||
expect(types.has("SHORT_ANSWER")).toBe(true);
|
||||
expect(types.has("ESSAY")).toBe(true);
|
||||
});
|
||||
|
||||
it("未命中的 examId 返回兜底详情(不抛出)", () => {
|
||||
const fakeId = "fake-class-exam-999";
|
||||
const detail = findMockExamDetail(fakeId);
|
||||
expect(detail.id).toBe(fakeId);
|
||||
expect(Array.isArray(detail.questions)).toBe(true);
|
||||
expect(detail.questions.length).toBeGreaterThan(0);
|
||||
expect(detail.status).toBe("DRAFT");
|
||||
});
|
||||
|
||||
it("详情类型符合 ExamDetail 接口", () => {
|
||||
const exams = generateMockExams(TEST_CLASS_ID);
|
||||
const detail: ExamDetail = findMockExamDetail(exams[0]!.id);
|
||||
expect(detail).toHaveProperty("id");
|
||||
expect(detail).toHaveProperty("questions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createMockExam", () => {
|
||||
const input: CreateExamInput = {
|
||||
classId: TEST_CLASS_ID,
|
||||
title: "测试考试 - 单元一",
|
||||
description: "覆盖函数章节",
|
||||
examDate: "2026-08-01T10:00:00.000Z",
|
||||
duration: 60,
|
||||
totalScore: 80,
|
||||
};
|
||||
|
||||
it("返回 DRAFT 状态的新考试", () => {
|
||||
const exam = createMockExam(input);
|
||||
expect(exam.status).toBe("DRAFT");
|
||||
});
|
||||
|
||||
it("ID 包含 classId 前缀", () => {
|
||||
const exam = createMockExam(input);
|
||||
expect(exam.id.startsWith(`${TEST_CLASS_ID}-exam-`)).toBe(true);
|
||||
});
|
||||
|
||||
it("字段从 input 透传", () => {
|
||||
const exam = createMockExam(input);
|
||||
expect(exam.classId).toBe(input.classId);
|
||||
expect(exam.title).toBe(input.title);
|
||||
expect(exam.description).toBe(input.description);
|
||||
expect(exam.examDate).toBe(input.examDate);
|
||||
expect(exam.duration).toBe(input.duration);
|
||||
expect(exam.totalScore).toBe(input.totalScore);
|
||||
});
|
||||
|
||||
it("description 为 undefined 时透传为 null", () => {
|
||||
const noDescInput: CreateExamInput = {
|
||||
classId: TEST_CLASS_ID,
|
||||
title: "无描述考试",
|
||||
examDate: "2026-08-01T10:00:00.000Z",
|
||||
duration: 90,
|
||||
totalScore: 100,
|
||||
};
|
||||
const exam = createMockExam(noDescInput);
|
||||
expect(exam.description).toBeNull();
|
||||
});
|
||||
|
||||
it("多次调用生成不同 ID(随机后缀)", () => {
|
||||
const a = createMockExam(input);
|
||||
const b = createMockExam(input);
|
||||
expect(a.id).not.toBe(b.id);
|
||||
});
|
||||
|
||||
it("类型符合 ExamItem 接口", () => {
|
||||
const exam: ExamItem = createMockExam(input);
|
||||
expect(exam).toHaveProperty("id");
|
||||
expect(exam).toHaveProperty("status");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* viewports fixture 单元测试
|
||||
*
|
||||
* 测试 mockViewports 数据:
|
||||
* - 数量符合预期(23 项)
|
||||
* - 每项包含必需字段(key / label / route / sortOrder / requiredPermission)
|
||||
* - sortOrder 单调递增
|
||||
* - key / route 唯一
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mockViewports } from "../viewports";
|
||||
import type { ViewportItem } from "@/lib/graphql";
|
||||
|
||||
describe("mockViewports 数据完整性", () => {
|
||||
it("数组长度应为 23", () => {
|
||||
expect(mockViewports).toHaveLength(23);
|
||||
expect(Array.isArray(mockViewports)).toBe(true);
|
||||
});
|
||||
|
||||
it("每项视口包含必需字段且类型正确", () => {
|
||||
for (const v of mockViewports) {
|
||||
expect(typeof v.key).toBe("string");
|
||||
expect(v.key.length).toBeGreaterThan(0);
|
||||
expect(typeof v.label).toBe("string");
|
||||
expect(v.label.length).toBeGreaterThan(0);
|
||||
expect(typeof v.route).toBe("string");
|
||||
expect(v.route.startsWith("/")).toBe(true);
|
||||
expect(typeof v.sortOrder).toBe("string");
|
||||
expect(v.sortOrder.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("sortOrder 单调递增", () => {
|
||||
for (let i = 1; i < mockViewports.length; i++) {
|
||||
const prev = mockViewports[i - 1];
|
||||
const curr = mockViewports[i];
|
||||
// settings 的 sortOrder 为 "99"(兜底末位),允许大于前项
|
||||
expect(prev && curr).toBeDefined();
|
||||
expect(curr!.sortOrder >= prev!.sortOrder).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("key 字段唯一", () => {
|
||||
const keys = mockViewports.map((v) => v.key);
|
||||
const uniqueKeys = new Set(keys);
|
||||
expect(uniqueKeys.size).toBe(keys.length);
|
||||
});
|
||||
|
||||
it("route 字段唯一", () => {
|
||||
const routes = mockViewports.map((v) => v.route);
|
||||
const uniqueRoutes = new Set(routes);
|
||||
expect(uniqueRoutes.size).toBe(routes.length);
|
||||
});
|
||||
|
||||
it("首项应为 dashboard(sortOrder=01)", () => {
|
||||
const first = mockViewports[0];
|
||||
expect(first).toBeDefined();
|
||||
expect(first?.key).toBe("dashboard");
|
||||
expect(first?.sortOrder).toBe("01");
|
||||
});
|
||||
|
||||
it("末项应为 settings(sortOrder=99)", () => {
|
||||
const last = mockViewports[mockViewports.length - 1];
|
||||
expect(last).toBeDefined();
|
||||
expect(last?.key).toBe("settings");
|
||||
expect(last?.sortOrder).toBe("99");
|
||||
});
|
||||
|
||||
it("requiredPermission 字段为字符串或 null", () => {
|
||||
for (const v of mockViewports) {
|
||||
expect(
|
||||
v.requiredPermission === null ||
|
||||
typeof v.requiredPermission === "string",
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("icon 字段为 null(mock 数据未配置图标)", () => {
|
||||
for (const v of mockViewports) {
|
||||
expect(v.icon).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("类型符合 ViewportItem 接口", () => {
|
||||
const sample: ViewportItem = mockViewports[0]!;
|
||||
expect(sample).toHaveProperty("key");
|
||||
expect(sample).toHaveProperty("label");
|
||||
expect(sample).toHaveProperty("route");
|
||||
expect(sample).toHaveProperty("icon");
|
||||
expect(sample).toHaveProperty("sortOrder");
|
||||
expect(sample).toHaveProperty("requiredPermission");
|
||||
});
|
||||
|
||||
it("关键视口存在(dashboard / classes / exams / homework / grades / analytics / settings)", () => {
|
||||
const keys = new Set(mockViewports.map((v) => v.key));
|
||||
const expected = [
|
||||
"dashboard",
|
||||
"classes",
|
||||
"exams",
|
||||
"homework",
|
||||
"grades",
|
||||
"analytics",
|
||||
"settings",
|
||||
];
|
||||
for (const k of expected) {
|
||||
expect(keys.has(k)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user