feat(admin-portal): 完成参考项目差距闭环 - 4 批次补齐 + Vitest 测试

差距分析闭环(workline §7-§8):
- P0:审计子模块 3 页 + 学校组织 7 页
- P1:系统设置 4 Card + 公告管理 + 邀请码管理
- P2:文件管理 + AI 配置
- P6:5 测试文件 / 69 用例全过

共享基础设施:
- permissions +11 权限点 +14 路由
- view-models +20 接口
- i18n +100 key
- graphql-client +25 operation
- fixtures/handlers +15 mock +20 handler

质量:typecheck + lint 零错误,vitest 69/69 通过,arch.db 已更新
This commit is contained in:
SpecialX
2026-07-13 13:05:29 +08:00
parent fad6e43b47
commit 7cf9aec20e
40 changed files with 6707 additions and 7 deletions

View File

@@ -0,0 +1,250 @@
"use client";
import { type ReactNode, useState, useMemo } from "react";
import { useGrades, useGradeInsights } from "@/hooks/use-school";
import {
PaperCard,
Select,
LoadingState,
ErrorState,
EmptyState,
Table,
TableRow,
TableCell,
} from "@/components/ui";
import { t } from "@/lib/i18n";
interface SummaryCardProps {
label: string;
value: number | string;
suffix?: string;
}
function SummaryCard({ label, value, suffix }: SummaryCardProps): ReactNode {
return (
<PaperCard className="p-4">
<p
style={{
fontSize: 11,
color: "var(--color-ink-muted)",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
{label}
</p>
<p
style={{
fontFamily: "var(--font-serif)",
fontSize: 28,
color: "var(--color-ink)",
margin: "8px 0 0 0",
}}
>
{value}
{suffix && (
<span
style={{
fontSize: 14,
color: "var(--color-ink-muted)",
marginLeft: 4,
}}
>
{suffix}
</span>
)}
</p>
</PaperCard>
);
}
function formatPercent(rate: number): string {
return `${(rate * 100).toFixed(1)}%`;
}
export default function GradeInsightsPage(): ReactNode {
const { data: grades, loading: gradesLoading } = useGrades();
const [gradeId, setGradeId] = useState<string>("");
const selectedGradeId = gradeId || (grades[0]?.id ?? null);
const { data, loading, error } = useGradeInsights(selectedGradeId);
const ranked = useMemo(() => {
if (!data) return [];
return [...data.classRankings].sort((a, b) => a.rank - b.rank);
}, [data]);
return (
<>
<PaperCard className="p-4 mb-4">
<Select
value={selectedGradeId ?? ""}
onChange={(e) => setGradeId(e.target.value)}
disabled={gradesLoading || grades.length === 0}
>
{grades.length === 0 && <option value=""></option>}
{grades.map((g) => (
<option key={g.id} value={g.id}>
{g.name}{g.schoolName}
</option>
))}
</Select>
</PaperCard>
{loading && <LoadingState />}
{error && <ErrorState message={error.message} />}
{!loading && !error && !data && (
<PaperCard className="p-4">
<EmptyState message={t("admin.common.empty")} />
</PaperCard>
)}
{data && (
<div className="flex flex-col gap-6">
{/* 全校概览 */}
<div>
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 16,
color: "var(--color-ink)",
marginBottom: 12,
}}
>
{t("admin.school.insights.summary")} {data.gradeName}
</h3>
<div className="grid grid-cols-4 gap-4">
<SummaryCard
label={t("admin.dashboard.totalStudents")}
value={data.schoolWideSummary.totalStudents}
/>
<SummaryCard
label={t("admin.dashboard.totalClasses")}
value={data.schoolWideSummary.totalClasses}
/>
<SummaryCard
label={t("admin.school.insights.avgScore")}
value={data.schoolWideSummary.avgScore.toFixed(1)}
/>
<SummaryCard
label={t("admin.school.insights.passingRate")}
value={formatPercent(data.schoolWideSummary.passingRate)}
/>
</div>
</div>
{/* 作业时间线 */}
<PaperCard className="p-4">
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 16,
color: "var(--color-ink)",
marginBottom: 12,
}}
>
{t("admin.school.insights.homework")}
</h3>
{data.homeworkTimeline.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<Table
headers={[
"作业",
t("admin.common.class"),
t("admin.school.insights.avgScore"),
"提交情况",
t("admin.school.endDate"),
]}
>
{data.homeworkTimeline.map((hw) => (
<TableRow key={hw.homeworkId}>
<TableCell style={{ fontWeight: 500 }}>
{hw.title}
</TableCell>
<TableCell>{hw.className}</TableCell>
<TableCell style={{ fontFamily: "var(--font-mono)" }}>
{hw.avgScore.toFixed(1)}
</TableCell>
<TableCell
style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}
>
{hw.submittedCount}/{hw.totalCount}
</TableCell>
<TableCell style={{ whiteSpace: "nowrap" }}>
{hw.dueDate}
</TableCell>
</TableRow>
))}
</Table>
)}
</PaperCard>
{/* 班级排名 */}
<PaperCard className="p-4">
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 16,
color: "var(--color-ink)",
marginBottom: 12,
}}
>
{t("admin.school.insights.rankings")}
</h3>
{ranked.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<Table
headers={[
t("admin.school.insights.rank"),
t("admin.common.class"),
t("admin.school.insights.avgScore"),
t("admin.school.insights.delta"),
]}
>
{ranked.map((cls) => {
const deltaColor =
cls.delta > 0
? "var(--color-success)"
: cls.delta < 0
? "var(--color-danger)"
: "var(--color-ink-muted)";
const deltaSign = cls.delta > 0 ? "+" : "";
return (
<TableRow key={cls.classId}>
<TableCell
style={{
fontFamily: "var(--font-serif)",
fontSize: 15,
fontWeight: 500,
}}
>
{cls.rank}
</TableCell>
<TableCell style={{ fontWeight: 500 }}>
{cls.className}
</TableCell>
<TableCell style={{ fontFamily: "var(--font-mono)" }}>
{cls.avgScore.toFixed(1)}
</TableCell>
<TableCell
style={{
fontFamily: "var(--font-mono)",
color: deltaColor,
fontWeight: 500,
}}
>
{deltaSign}
{cls.delta.toFixed(1)}
</TableCell>
</TableRow>
);
})}
</Table>
)}
</PaperCard>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,85 @@
"use client";
import { type ReactNode, useState, useMemo } from "react";
import { useGrades, useSchools } from "@/hooks/use-school";
import {
PaperCard,
Select,
LoadingState,
ErrorState,
EmptyState,
Table,
TableRow,
TableCell,
} from "@/components/ui";
import { t } from "@/lib/i18n";
import type { GradeViewModel } from "@/types/view-models";
export default function GradesPage(): ReactNode {
const [schoolId, setSchoolId] = useState<string>("");
const { data: schools, loading: schoolsLoading } = useSchools();
const { data, loading, error } = useGrades(schoolId || undefined);
const sorted = useMemo<GradeViewModel[]>(() => {
if (!data) return [];
return [...data].sort((a, b) => a.sortOrder - b.sortOrder);
}, [data]);
return (
<>
<PaperCard className="p-4 mb-4">
<Select
value={schoolId}
onChange={(e) => setSchoolId(e.target.value)}
disabled={schoolsLoading}
>
<option value="">
{t("admin.common.all")}
{t("admin.common.school")}
</option>
{schools.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</Select>
</PaperCard>
<PaperCard className="p-4">
{loading && <LoadingState />}
{error && <ErrorState message={error.message} />}
{!loading && !error && (
<>
{sorted.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<Table
headers={[
t("admin.common.grade"),
t("admin.common.school"),
t("admin.school.director"),
t("admin.common.class"),
t("admin.dashboard.totalStudents"),
t("admin.viewports.sortOrder"),
]}
>
{sorted.map((grade) => (
<TableRow key={grade.id}>
<TableCell style={{ fontWeight: 500 }}>
{grade.name}
</TableCell>
<TableCell>{grade.schoolName}</TableCell>
<TableCell>{grade.directorName ?? "—"}</TableCell>
<TableCell>{grade.classCount}</TableCell>
<TableCell>{grade.studentCount}</TableCell>
<TableCell>{grade.sortOrder}</TableCell>
</TableRow>
))}
</Table>
)}
</>
)}
</PaperCard>
</>
);
}