feat(portal-shell): 管理域全模块功能补齐与差异修复

按 ARCHITECTURE.md 与 admin-NeedTodo.md 要求补齐所有管理页面缺失功能:
- users/roles/permissions:权限矩阵搜索/折叠、zod 校验、value 字段
- audit-logs:行内详情对话框、分页页码、ChartCardShell
- school:CRUD 对话框、GradeOverviewCards、academic-year 侧栏
- announcements/invitation-codes/ai-settings:发布按钮、分页、zod 校验
- course-plans/elective:Select 导入、undefined 处理
- error-book/scheduling/questions/lesson-plans/attendance:统计卡片

验证:typecheck 0 错误、arch:scan 已更新
This commit is contained in:
SpecialX
2026-07-30 17:49:49 +08:00
parent 071542b757
commit f991bf0446
98 changed files with 25956 additions and 2140 deletions

View File

@@ -7,8 +7,10 @@
* - adminAttendanceStats():❌ schema 无 → MSW 兜底(@contract-pending
* - attendanceGradeCorrelation():❌ schema 无 → MSW 兜底
* - adminClasses():❌ schema 无 → MSW 兜底(用于班级筛选下拉)
* - grades():✅ 真实 schema用于年级筛选下拉
* - classComparison():❌ schema 无 → MSW 兜底(由 ClassComparisonCard 内部调用)
*
* URL 状态:?classId=xxx&status=xxx&date=xxx
* URL 状态:?classId=xxx&status=xxx&date=xxx&gradeId=xxx&page=xxx
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
*
@@ -23,8 +25,8 @@ import {
useAdminAttendanceStats,
useAdminClasses,
useAttendanceGradeCorrelation,
useGrades,
} from "@/lib/api";
import { Card, CardContent } from "@/shared/components/ui/card";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { StatCard } from "@/shared/components/ui/stat-card";
import {
@@ -32,21 +34,20 @@ import {
ListPageSkeleton,
} from "@/shared/components/page-templates";
import {
attendanceStatusToBadgeClass,
attendanceStatusToKey,
computeAbnormalRate,
computeAvgCorrelation,
formatAbnormalRate,
formatAvgScore,
formatCorrelation,
formatRate,
formatRecordDate,
hasAttendanceData,
presentRateToColorClass,
sortClassesByPresentRate,
truncateNote,
type AttendanceStatus,
} from "@/features/admin/attendance/transformations";
import { ClassComparisonCard } from "@/features/admin/attendance/class-comparison-card";
import { AttendanceGradeCorrelationCard } from "@/features/admin/attendance/attendance-grade-correlation-card";
import { AttendanceRecordsList } from "@/features/admin/attendance/attendance-records-list";
/** 考勤状态选项(用于筛选下拉) */
const STATUS_OPTIONS: readonly AttendanceStatus[] = [
@@ -70,11 +71,13 @@ export function AdminAttendanceClient(): React.ReactElement {
const classId = searchParams.get("classId") ?? "";
const status = searchParams.get("status") ?? "";
const date = searchParams.get("date") ?? "";
const gradeId = searchParams.get("gradeId") ?? "";
// @contract-pendingMSW 兜底
const { data: stats, loading, error } = useAdminAttendanceStats();
const { data: correlations } = useAttendanceGradeCorrelation();
const { data: classes } = useAdminClasses();
const { data: grades } = useGrades();
const avgCorrelation = useMemo(
() => computeAvgCorrelation(correlations ?? []),
@@ -88,6 +91,10 @@ export function AdminAttendanceClient(): React.ReactElement {
} else {
params.delete(key);
}
// 切换筛选时重置页码
if (key !== "page") {
params.delete("page");
}
startTransition(() => {
router.push(`/shell/admin/attendance?${params.toString()}`);
});
@@ -122,10 +129,13 @@ export function AdminAttendanceClient(): React.ReactElement {
classId={classId}
status={status}
date={date}
gradeId={gradeId}
classes={classes ?? []}
grades={grades ?? []}
onClassChange={(v) => updateFilter("classId", v)}
onStatusChange={(v) => updateFilter("status", v)}
onDateChange={(v) => updateFilter("date", v)}
onGradeChange={(v) => updateFilter("gradeId", v)}
/>
}
loading={loading}
@@ -135,40 +145,74 @@ export function AdminAttendanceClient(): React.ReactElement {
errorNode={errorNode}
>
{stats ? (
<AttendanceContent
stats={stats}
correlations={correlations ?? []}
avgCorrelation={avgCorrelation}
/>
<AttendanceContent stats={stats} avgCorrelation={avgCorrelation} />
) : null}
{/* 班级对比卡 + 考勤-成绩关联分析卡并排布局lg 以上双列) */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<ClassComparisonCard />
<AttendanceGradeCorrelationCard />
</div>
{/* 考勤记录列表:按 classId/status/date/gradeId 筛选 + URL 分页 */}
<AttendanceRecordsList
classId={classId}
status={status}
date={date}
gradeId={gradeId}
/>
<p className="text-xs text-muted-foreground">{t("mswNotice")}</p>
</ListPageShell>
);
}
/**
* 考勤筛选栏(班级 + 状态 + 日期)。
* 考勤筛选栏(年级 + 班级 + 状态 + 日期)。
*/
function AttendanceFilters({
classId,
status,
date,
gradeId,
classes,
grades,
onClassChange,
onStatusChange,
onDateChange,
onGradeChange,
}: {
classId: string;
status: string;
date: string;
gradeId: string;
classes: NonNullable<ReturnType<typeof useAdminClasses>["data"]>;
grades: NonNullable<ReturnType<typeof useGrades>["data"]>;
onClassChange: (v: string) => void;
onStatusChange: (v: string) => void;
onDateChange: (v: string) => void;
onGradeChange: (v: string) => void;
}): React.ReactElement {
const t = useTranslations("admin.attendance.list");
return (
<div className="flex flex-wrap items-center gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">
{t("gradeFilter")}
</label>
<select
value={gradeId}
onChange={(e) => onGradeChange(e.target.value)}
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
>
<option value="">{t("allGrades")}</option>
{grades.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">
{t("classFilter")}
@@ -227,17 +271,15 @@ function AttendanceFilters({
}
/**
* 考勤主体内容(统计卡片 + 班级对比 + 考勤-成绩关联分析)。
* 考勤主体内容(统计卡片)。
* 班级对比与考勤-成绩关联分析由独立卡片组件渲染ClassComparisonCard /
* AttendanceGradeCorrelationCard各自管理数据获取与三态。
*/
function AttendanceContent({
stats,
correlations,
avgCorrelation,
}: {
stats: NonNullable<ReturnType<typeof useAdminAttendanceStats>["data"]>;
correlations: NonNullable<
ReturnType<typeof useAttendanceGradeCorrelation>["data"]
>;
avgCorrelation: number;
}): React.ReactElement {
const t = useTranslations("admin.attendance.list");
@@ -246,7 +288,6 @@ function AttendanceContent({
stats.lateRate,
stats.earlyLeaveRate,
);
const sortedClasses = sortClassesByPresentRate(stats);
return (
<div className="flex flex-col gap-6">
@@ -291,117 +332,6 @@ function AttendanceContent({
valueClassName="text-sky-600 dark:text-sky-400"
/>
</div>
{/* 班级对比 */}
<Card>
<CardContent className="p-6">
<h2 className="mb-4 text-lg font-semibold">
{t("classComparisonTitle")}
</h2>
{sortedClasses.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
{t("emptyTitle")}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-2 text-left font-medium">
{t("classComparisonClass")}
</th>
<th className="p-2 text-left font-medium">
{t("statsAbsentRate")}
</th>
<th className="p-2 text-left font-medium">
{t("classComparisonRate")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{sortedClasses.map((cls) => (
<tr key={cls.classId} className="hover:bg-muted/30">
<td className="p-2 font-medium">{cls.className}</td>
<td className="p-2 font-mono text-xs text-destructive">
{formatRate(cls.absentRate)}
</td>
<td
className={`p-2 font-mono text-xs ${presentRateToColorClass(cls.presentRate)}`}
>
{formatRate(cls.presentRate)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* 考勤-成绩关联分析 */}
<Card>
<CardContent className="p-6">
<h2 className="mb-4 text-lg font-semibold">
{t("correlationTitle")}
</h2>
{correlations.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
{t("emptyTitle")}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-2 text-left font-medium">
{t("classComparisonClass")}
</th>
<th className="p-2 text-left font-medium">
{t("correlationAttendance")}
</th>
<th className="p-2 text-left font-medium">
{t("correlationGrade")}
</th>
<th className="p-2 text-left font-medium">
{t("statsAvgCorrelation")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{correlations.map((c) => {
const statusKey = attendanceStatusToKey(
c.presentRate >= 0.9 ? "present" : "absent",
);
return (
<tr key={c.classId} className="hover:bg-muted/30">
<td className="p-2 font-medium">{c.className}</td>
<td
className={`p-2 font-mono text-xs ${presentRateToColorClass(c.presentRate)}`}
>
{formatRate(c.presentRate)}
</td>
<td className="p-2 font-mono text-xs">
{formatAvgScore(c.avgScore)}
</td>
<td className="p-2 font-mono text-xs">
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${attendanceStatusToBadgeClass(
statusKey,
)}`}
>
{formatCorrelation(c.correlation)}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}