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;
|
||||
Reference in New Issue
Block a user