Files
Edu/apps/portal-shell/src/features/admin/scheduling/auto-schedule-client.tsx
SpecialX 039db5efdd fix(portal-shell): 管理域 UI 规范合规与 TypeScript 修复
- 替换 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 规则引用
2026-08-01 05:50:25 +08:00

271 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 DoDloading骨架/ error局部降级/ emptyEmptyState + 行动按钮)
*
* 关联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-pendingMSW 兜底
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>
);
}