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:
@@ -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 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
*
|
||||
@@ -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-pending:MSW 兜底
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user