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(均为既有问题)
275 lines
9.2 KiB
TypeScript
275 lines
9.2 KiB
TypeScript
"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>
|
||
);
|
||
}
|