Files
NextEdu/src/modules/lesson-preparation/components/schedule-dialog.tsx
SpecialX 214ebec976 refactor(design-tokens): 全量体系化重建设计令牌
Primitive + Semantic 双层令牌架构,HEX->HSL,明暗双份,@theme inline 暴露为 Tailwind 类。

- 新建 src/app/styles/tokens/ 6 个令牌文件(primitive/semantic-light/semantic-dark/lesson-preparation/tailwind-theme/index)
- globals.css 改为 @import 引入,477->258 行
- 清理 91 处 #hex 硬编码颜色 -> hsl(var(--*))
- 清理 10 处硬编码字体 -> var(--font-family-*)
- 清理 100 文件 Tailwind 任意值(Tier 1 映射/Tier 3 注释豁免)
- 清理 M3 Surface 死代码,升级 --lp-* 令牌(HEX->HSL + 暗色补全)
- 新建 ESLint 自定义规则 no-hardcoded-design-tokens(单词边界正则)
- eslint.config.mjs 新增 no-restricted-syntax 禁止 #hex + 自定义规则加载(pathToFileURL)
- 项目规则新增设计令牌规范强制章节
- 架构图 004/005 同步设计令牌体系节点
- known-issues.md 追加设计令牌问题分类(7 个规则表)

验证: tsc --noEmit 0 errors, npm run lint 0 errors/12 warnings(均为既有问题)
2026-07-05 01:03:19 +08:00

275 lines
9.2 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";
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { X, Calendar, Trash2, Plus } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
import { toast } from "sonner";
import {
getLessonPlanSchedulesAction,
createLessonPlanScheduleAction,
deleteLessonPlanScheduleAction,
} from "../actions-schedules";
import type { LessonPlanScheduleRecord } from "../data-access-schedules";
interface ScheduleOption {
id: string;
name: string;
}
interface Props {
planId: string;
classes: ScheduleOption[];
onClose: () => void;
onScheduled?: () => void;
}
/**
* V5-7课时绑定对话框
*
* 功能:
* 1. 列出课案已绑定的课时
* 2. 添加新课时绑定(选班级 + 日期 + 节次 + 时长)
* 3. 删除课时绑定
*
* 不通过 service 注入,直接调用 actions因为是新增功能且仅教师使用
*/
export function ScheduleDialog({ planId, classes, onClose, onScheduled }: Props) {
const t = useTranslations("lessonPreparation");
const [schedules, setSchedules] = useState<LessonPlanScheduleRecord[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
// 表单状态
const [classId, setClassId] = useState("");
const [scheduledDate, setScheduledDate] = useState("");
const [period, setPeriod] = useState(1);
const [durationMin, setDurationMin] = useState(40);
const [error, setError] = useState<string | null>(null);
const loadSchedules = useCallback(async () => {
setLoading(true);
try {
const res = await getLessonPlanSchedulesAction(planId);
if (res.success && res.data) {
setSchedules(res.data.items);
}
} catch (e) {
console.error("[ScheduleDialog] load failed", e);
toast.error(t("error.getOne"));
} finally {
setLoading(false);
}
}, [planId, t]);
useEffect(() => {
void loadSchedules();
}, [loadSchedules]);
useEffect(() => {
function handleEsc(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleEsc);
return () => document.removeEventListener("keydown", handleEsc);
}, [onClose]);
async function handleAdd() {
if (!classId) {
setError(t("schedule.selectClass"));
return;
}
if (!scheduledDate) {
setError(t("schedule.selectDate"));
return;
}
setSubmitting(true);
setError(null);
try {
const res = await createLessonPlanScheduleAction({
planId,
classId,
scheduledDate,
period,
durationMin,
});
if (res.success) {
toast.success(t("schedule.addSuccess"));
void loadSchedules();
onScheduled?.();
} else {
setError(res.message ?? t("error.save"));
}
} catch (e) {
console.error("[ScheduleDialog] add failed", e);
setError(t("error.save"));
} finally {
setSubmitting(false);
}
}
async function handleDelete(id: string) {
try {
const res = await deleteLessonPlanScheduleAction(id);
if (res.success) {
toast.success(t("schedule.deleteSuccess"));
void loadSchedules();
onScheduled?.();
} else {
toast.error(res.message ?? t("error.save"));
}
} catch (e) {
console.error("[ScheduleDialog] delete failed", e);
toast.error(t("error.save"));
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
{/* arbitrary-value: dialog fixed width */}
<div
role="dialog"
aria-modal="true"
aria-label={t("schedule.title")}
className="bg-surface rounded-lg shadow-xl w-[560px] max-h-[80vh] flex flex-col"
>
<FocusTrap className="contents">
<div className="flex justify-between items-center p-4 border-b">
<h3 className="font-title-md flex items-center gap-2">
<Calendar className="w-4 h-4" aria-hidden="true" />
{t("schedule.title")}
</h3>
<button onClick={onClose} aria-label={t("action.close")}>
<X className="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div className="p-4 overflow-y-auto flex-1 space-y-4">
{/* 已绑定课时列表 */}
<div>
<label className="text-sm font-medium block mb-2">
{t("schedule.boundList")}
</label>
{loading ? (
<p className="text-sm text-on-surface-variant text-center py-4">
{t("version.loading")}
</p>
) : schedules.length === 0 ? (
<p className="text-sm text-on-surface-variant text-center py-4">
{t("schedule.empty")}
</p>
) : (
<ul className="space-y-1">
{schedules.map((s) => (
<li
key={s.id}
className="flex items-center gap-2 border rounded p-2 text-sm"
>
<span className="flex-1">
<strong>{s.className}</strong>
<span className="text-on-surface-variant ml-2">
{s.scheduledDate} · {t("schedule.period", { n: s.period })} · {t("schedule.duration", { n: s.durationMin })}
</span>
</span>
<button
className="text-error hover:bg-error/10 p-1 rounded"
onClick={() => void handleDelete(s.id)}
aria-label={t("schedule.delete")}
>
<Trash2 className="w-3 h-3" aria-hidden="true" />
</button>
</li>
))}
</ul>
)}
</div>
{/* 添加新绑定 */}
<div className="border-t pt-3 space-y-2">
<label className="text-sm font-medium block">
{t("schedule.addNew")}
</label>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-on-surface-variant">
{t("schedule.classLabel")}
</label>
<select
value={classId}
onChange={(e) => setClassId(e.target.value)}
className="w-full border rounded px-2 py-1 mt-0.5 text-sm"
>
<option value="">{t("schedule.selectClass")}</option>
{classes.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div>
<label className="text-xs text-on-surface-variant">
{t("schedule.dateLabel")}
</label>
<input
type="date"
value={scheduledDate}
onChange={(e) => setScheduledDate(e.target.value)}
className="w-full border rounded px-2 py-1 mt-0.5 text-sm"
/>
</div>
<div>
<label className="text-xs text-on-surface-variant">
{t("schedule.periodLabel")}
</label>
<select
value={period}
onChange={(e) => setPeriod(Number(e.target.value))}
className="w-full border rounded px-2 py-1 mt-0.5 text-sm"
>
{Array.from({ length: 12 }, (_, i) => i + 1).map((n) => (
<option key={n} value={n}>
{t("schedule.period", { n })}
</option>
))}
</select>
</div>
<div>
<label className="text-xs text-on-surface-variant">
{t("schedule.durationLabel")}
</label>
<input
type="number"
min={5}
max={180}
value={durationMin}
onChange={(e) => setDurationMin(Number(e.target.value))}
className="w-full border rounded px-2 py-1 mt-0.5 text-sm"
/>
</div>
</div>
{error && <p className="text-error text-sm">{error}</p>}
<Button
size="sm"
onClick={handleAdd}
disabled={submitting}
className="w-full"
>
<Plus className="w-4 h-4 mr-1" />
{t("schedule.add")}
</Button>
</div>
</div>
<div className="p-4 border-t flex justify-end">
<Button variant="outline" onClick={onClose}>
{t("action.close")}
</Button>
</div>
</FocusTrap>
</div>
</div>
);
}