- 替换 41 处原生 select 为 Select 组件封装 - 替换 5 处 window.confirm 为 shadcn AlertDialog - 修复 lesson-plans delete-confirm-dialog 为 AlertDialog - 修复 5 处 Tailwind 任意值 text-[10px] - 修复 graphql-data.ts mutation case 缺少 id 定义 - 修复 use-position-persistence.ts eslint 规则引用
271 lines
8.3 KiB
TypeScript
271 lines
8.3 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* 自动排课页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5 / admin-NeedTodo §四)
|
||
*
|
||
* 数据契约:
|
||
* - adminClasses():❌ schema 无 → MSW 兜底(@contract-pending)
|
||
* - autoSchedule(classId) mutation:❌ schema 无 → MSW 兜底
|
||
*
|
||
* URL 状态:无(操作型页面,状态本地维护)
|
||
*
|
||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState + 行动按钮)
|
||
*
|
||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
|
||
*/
|
||
import { CalendarClock } from "lucide-react";
|
||
import Link from "next/link";
|
||
import { useState } from "react";
|
||
import { useTranslations } from "next-intl";
|
||
|
||
import { useAdminClasses, useAutoSchedule } from "@/lib/api";
|
||
import { notify } from "@/shared/lib/notify";
|
||
import {
|
||
AlertDialog,
|
||
AlertDialogAction,
|
||
AlertDialogCancel,
|
||
AlertDialogContent,
|
||
AlertDialogDescription,
|
||
AlertDialogFooter,
|
||
AlertDialogHeader,
|
||
AlertDialogTitle,
|
||
} from "@/shared/components/ui/alert-dialog";
|
||
import { Button } from "@/shared/components/ui/button";
|
||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||
import {
|
||
ListPageShell,
|
||
ListPageSkeleton,
|
||
} from "@/shared/components/page-templates";
|
||
import {
|
||
formatGeneratedEntries,
|
||
hasConflicts,
|
||
scheduleStatusToBadgeClass,
|
||
scheduleStatusToKey,
|
||
type ScheduleStatus,
|
||
} from "@/features/admin/scheduling/transformations";
|
||
|
||
/** 班级排课结果本地状态 */
|
||
interface ClassScheduleResult {
|
||
status: ScheduleStatus;
|
||
generatedEntries?: number;
|
||
conflicts?: string[];
|
||
}
|
||
|
||
/**
|
||
* 自动排课客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||
*/
|
||
export function AutoScheduleClient(): React.ReactElement {
|
||
const t = useTranslations("admin.scheduling.auto");
|
||
const tCommon = useTranslations("common");
|
||
|
||
// @contract-pending:MSW 兜底
|
||
const { data, loading, error } = useAdminClasses();
|
||
const { run: autoSchedule, loading: running } = useAutoSchedule();
|
||
|
||
const [results, setResults] = useState<Record<string, ClassScheduleResult>>(
|
||
{},
|
||
);
|
||
|
||
const classes = data ?? [];
|
||
|
||
const handleStart = async (classId: string): Promise<void> => {
|
||
// 标记为 pending(覆盖之前结果)
|
||
setResults((prev) => ({
|
||
...prev,
|
||
[classId]: { status: "pending" },
|
||
}));
|
||
try {
|
||
const result = await autoSchedule(classId);
|
||
const nextStatus: ScheduleStatus = hasConflicts(result.conflicts)
|
||
? "failed"
|
||
: "scheduled";
|
||
setResults((prev) => ({
|
||
...prev,
|
||
[classId]: {
|
||
status: nextStatus,
|
||
generatedEntries: result.generatedEntries,
|
||
conflicts: result.conflicts,
|
||
},
|
||
}));
|
||
if (nextStatus === "scheduled") {
|
||
notify.success(t("startSuccess", { count: result.generatedEntries }));
|
||
} else {
|
||
notify.error(t("startError"));
|
||
}
|
||
} catch (err) {
|
||
setResults((prev) => ({
|
||
...prev,
|
||
[classId]: { status: "failed", conflicts: [String(err)] },
|
||
}));
|
||
notify.error(`${t("startError")}: ${String(err)}`);
|
||
}
|
||
};
|
||
|
||
const errorNode = error ? (
|
||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||
<p className="text-sm text-destructive">
|
||
{tCommon("error.loadFailed", { message: String(error) })}
|
||
</p>
|
||
<p className="mt-2 text-xs text-muted-foreground">{t("mswNotice")}</p>
|
||
</div>
|
||
) : undefined;
|
||
|
||
const emptyNode = (
|
||
<EmptyState
|
||
icon={CalendarClock}
|
||
title={t("emptyTitle")}
|
||
description={t("emptyDescription")}
|
||
action={{
|
||
label: t("emptyAction"),
|
||
href: "/shell/admin/school/classes",
|
||
}}
|
||
/>
|
||
);
|
||
|
||
return (
|
||
<ListPageShell
|
||
title={t("title")}
|
||
description={t("description")}
|
||
icon={<CalendarClock className="size-6" />}
|
||
actions={
|
||
<Button asChild variant="outline">
|
||
<Link href="/shell/admin/scheduling/rules">
|
||
{t("configureRulesButton")}
|
||
</Link>
|
||
</Button>
|
||
}
|
||
loading={loading}
|
||
loadingNode={<ListPageSkeleton rows={5} />}
|
||
empty={classes.length === 0 && !loading}
|
||
emptyNode={emptyNode}
|
||
errorNode={errorNode}
|
||
>
|
||
<AutoScheduleTable
|
||
classes={classes}
|
||
results={results}
|
||
running={running}
|
||
onStart={handleStart}
|
||
/>
|
||
<p className="text-xs text-muted-foreground">{t("mswNotice")}</p>
|
||
</ListPageShell>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 班级自动排课表格。
|
||
*/
|
||
function AutoScheduleTable({
|
||
classes,
|
||
results,
|
||
running,
|
||
onStart,
|
||
}: {
|
||
classes: NonNullable<ReturnType<typeof useAdminClasses>["data"]>;
|
||
results: Record<string, ClassScheduleResult>;
|
||
running: boolean;
|
||
onStart: (classId: string) => Promise<void>;
|
||
}): React.ReactElement {
|
||
const t = useTranslations("admin.scheduling.auto");
|
||
const tCommon = useTranslations("common");
|
||
const [confirmId, setConfirmId] = useState<string | null>(null);
|
||
return (
|
||
<div className="overflow-x-auto rounded-xl border">
|
||
<table className="w-full text-sm">
|
||
<thead className="border-b bg-muted/30">
|
||
<tr>
|
||
<th className="p-3 text-left font-medium">{t("colClassName")}</th>
|
||
<th className="p-3 text-left font-medium">{t("colGrade")}</th>
|
||
<th className="p-3 text-left font-medium">{t("colTeacher")}</th>
|
||
<th className="p-3 text-left font-medium">
|
||
{t("colSubjectCount")}
|
||
</th>
|
||
<th className="p-3 text-left font-medium">{t("colStatus")}</th>
|
||
<th className="p-3 text-right font-medium">{t("startButton")}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y">
|
||
{classes.map((cls) => {
|
||
const result = results[cls.id];
|
||
const statusKey = scheduleStatusToKey(result?.status ?? "pending");
|
||
const statusLabel = t(
|
||
`status${statusKey.charAt(0).toUpperCase()}${statusKey.slice(1)}` as
|
||
"statusPending" | "statusScheduled" | "statusFailed",
|
||
);
|
||
return (
|
||
<tr key={cls.id} className="hover:bg-muted/30">
|
||
<td className="p-3 font-medium">{cls.name}</td>
|
||
<td className="p-3 text-muted-foreground">{cls.gradeName}</td>
|
||
<td className="p-3 text-muted-foreground">
|
||
{cls.headTeacherName || "--"}
|
||
</td>
|
||
<td className="p-3 font-mono text-xs">{cls.subjectCount}</td>
|
||
<td className="p-3">
|
||
<StatusBadge status={statusKey} label={statusLabel} />
|
||
{result?.generatedEntries != null ? (
|
||
<span className="ml-2 text-xs text-muted-foreground">
|
||
({formatGeneratedEntries(result.generatedEntries)})
|
||
</span>
|
||
) : null}
|
||
</td>
|
||
<td className="p-3 text-right">
|
||
<Button
|
||
size="sm"
|
||
disabled={running}
|
||
onClick={() => setConfirmId(cls.id)}
|
||
>
|
||
{t("startButton")}
|
||
</Button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
<AlertDialog
|
||
open={confirmId !== null}
|
||
onOpenChange={(open) => {
|
||
if (!open) setConfirmId(null);
|
||
}}
|
||
>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>{t("startButton")}</AlertDialogTitle>
|
||
<AlertDialogDescription>{t("startConfirm")}</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
|
||
<AlertDialogAction
|
||
onClick={() => {
|
||
if (confirmId) void onStart(confirmId);
|
||
setConfirmId(null);
|
||
}}
|
||
>
|
||
{t("startButton")}
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 排课状态徽章。
|
||
*/
|
||
function StatusBadge({
|
||
status,
|
||
label,
|
||
}: {
|
||
status: ScheduleStatus;
|
||
label: string;
|
||
}): React.ReactElement {
|
||
const cls = scheduleStatusToBadgeClass(status);
|
||
return (
|
||
<span
|
||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
|
||
>
|
||
{label}
|
||
</span>
|
||
);
|
||
}
|