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:
SpecialX
2026-07-14 08:26:27 +08:00
parent 99580fa13a
commit 0b42302a64
42 changed files with 4804 additions and 637 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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() {
};
// P7Excel 导入处理(解析 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)}...

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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 ?? "",
},
}))

View File

@@ -0,0 +1,207 @@
"use client";
/**
* 课标覆盖热力图 SVG 矩阵组件
*
* 从 heatmap/page.tsx 提取,用于 next/dynamic 懒加载(重型 SVGCSR only
* 包含X/Y 轴标签、单元格矩阵、覆盖数文字、hover Tooltip。
*
* 颜色使用 var(--color-*) 语义令牌project_rules §3.10
*
* 维护者ai13teacher-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>
{/* Tooltiphover 显示) */}
{tooltip && (
<div
className="absolute pointer-events-none p-2 border border-rule rounded-card bg-paper text-tiny"
style={{
left: `${tooltip.x + 8}px`,
top: `${tooltip.y + 8}px`,
zIndex: 10,
}}
>
<p className="font-serif text-ink">{tooltip.kpName}</p>
<p className="text-ink-muted">{tooltip.planName}</p>
<p className="mt-1 text-accent">{tooltip.coverage} </p>
</div>
)}
</div>
);
}

View File

@@ -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>
{/* Tooltiphover 显示) */}
{tooltip && (
<div
className="absolute pointer-events-none p-2 border border-rule rounded-card bg-paper text-tiny"
style={{
left: `${tooltip.x + 8}px`,
top: `${tooltip.y + 8}px`,
zIndex: 10,
}}
>
<p className="font-serif text-ink">{tooltip.kpName}</p>
<p className="text-ink-muted">{tooltip.planName}</p>
<p className="mt-1 text-accent">
{tooltip.coverage}
</p>
</div>
)}
</div>
{/* 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)}

View File

@@ -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"
/>

View File

@@ -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>
)}

View File

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

View File

@@ -0,0 +1,47 @@
"use client";
/**
* LocaleSwitcher - 语言切换器
*
* 非路由式 i18n通过 NEXT_LOCALE cookie 切换语言,刷新页面生效。
* - zh-CN默认/ en
* - 使用 document.cookie 设置httpOnly=falseSameSite=Strict
*
* 维护者ai13teacher-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;