diff --git a/internal/handlers/study_session_report.go b/internal/handlers/study_session_report.go index 4e30b95..ea83d73 100644 --- a/internal/handlers/study_session_report.go +++ b/internal/handlers/study_session_report.go @@ -280,10 +280,12 @@ func loadSessionWordLabels(db *gorm.DB, session *models.StudySession, filter ses } func studySessionReportPrompts(report studySessionReportDTO) (systemPrompt, userPrompt string) { - systemPrompt = "你是英语陪练老师的课堂助教,根据本节课数据写一段简短「教练点评」。" + - "硬性要求:中文;严格 2-3 句;总长不超过 100 字;语气克制,像老师随手记的教学笔记;" + - "禁止使用 emoji;禁止 Markdown;禁止复述用时、正确率、筛词数、识记数、剩余待学等界面已有量化数据;" + - "只写表现判断与下一步建议;不要编造未提供的信息。" + systemPrompt = "你是英语陪练老师,写一段简短的课堂评价。" + + "硬性要求:中文;只写 2-3 句自然连贯的话;总长不超过 80 字;" + + "只聚焦本节正确率、课堂状态和表现是否有进步;表现好就直接表扬,表现下降或正确率偏低就简短鼓励并说下次加油;" + + "禁止使用序号、列表、括号、Markdown、emoji;禁止写下节课安排、学习计划、下一步建议或扩展分析;" + + "不要编造没有提供的课堂表现或进步信息;若没有历史对比,不要虚构具体进步幅度;" + + "如需出现单词,只能从“未记住单词”中选择,禁止出现其他学过的单词。" forgotLine := "无" if len(report.ForgotWords) > 0 { forgotLine = strings.Join(report.ForgotWords, "、") @@ -295,7 +297,7 @@ func studySessionReportPrompts(report studySessionReportDTO) (systemPrompt, user screenTotal := report.ScreenedKnownCount + report.ScreenedUnknownCount name := fallbackDash(report.StudentName) userPrompt = fmt.Sprintf( - "学员:%s\n词库:%s\n用时:约 %d 分钟\n筛词:合计 %d(认识 %d / 新学 %d)\n本课识记:%d\n训后记住:%d / 未记住:%d\n正确率:%.0f%%\n词书剩余待学:%d\n本节学习词:%s\n需巩固词:%s\n请只输出教练点评正文。", + "学员:%s\n词库:%s\n用时:约 %d 分钟\n筛词:合计 %d(认识 %d / 新学 %d)\n本课识记:%d\n训后记住:%d / 未记住:%d\n正确率:%.0f%%\n词书剩余待学:%d\n本节学习词:%s\n需巩固词:%s\n请只输出课堂评价正文,不要加标题、序号或括号。", name, fallbackDash(report.WordBookName), report.DurationMinutes, @@ -368,7 +370,19 @@ func isCurrentSessionReportFormat(text string) bool { strings.Contains(trimmed, "全程用时") || strings.Contains(trimmed, "系统记录") || strings.Contains(trimmed, "未触发熟词") || - (strings.Contains(trimmed, "正确率") && strings.Contains(trimmed, "剩余")) { + strings.Contains(trimmed, "下节课") || + strings.Contains(trimmed, "下一节") || + strings.Contains(trimmed, "学习计划") || + strings.Contains(trimmed, "建议") || + strings.Contains(trimmed, "(") || + strings.Contains(trimmed, ")") || + strings.Contains(trimmed, "(") || + strings.Contains(trimmed, ")") || + (strings.Contains(trimmed, "正确率") && strings.Contains(trimmed, "剩余")) || + strings.Contains(trimmed, "1、") || strings.Contains(trimmed, "2、") || + strings.Contains(trimmed, "3、") || strings.Contains(trimmed, "4、") || + strings.Contains(trimmed, "1.") || strings.Contains(trimmed, "2.") || + strings.Contains(trimmed, "3.") || strings.Contains(trimmed, "4.") { return false } return true diff --git a/web/src/api/timetable.ts b/web/src/api/timetable.ts new file mode 100644 index 0000000..68ccbd2 --- /dev/null +++ b/web/src/api/timetable.ts @@ -0,0 +1,157 @@ +/** + * 学习通式课程课表 — 数据访问层 + * + * 当前实现:浏览器 localStorage 持久化(方案 A)。 + * 预留后端:本文件导出 `TimetableRepository` 接口与 `timetableRepo` 单例, + * 未来切换到方案 B 时,只需新增一个基于 `get/post/put/del` 的 HTTP 实现并替换单例即可, + * 上层 store / 组件无需改动。 + * + * 预期的 RESTful 契约(方案 B): + * GET /api/timetable/config -> TimetableConfig + * PUT /api/timetable/config -> TimetableConfig + * GET /api/timetable/course -> Course[] + * POST /api/timetable/course -> Course + * PUT /api/timetable/course/:id -> Course + * DELETE /api/timetable/course/:id -> void + */ + +/** 单双周:0=每周,1=单周,2=双周 */ +export type WeekType = 0 | 1 | 2; + +/** 节次时间配置项 */ +export interface Section { + no: number; + start: string; // "08:00" + end: string; // "08:45" +} + +/** 课表全局配置 */ +export interface TimetableConfig { + /** 当前是第几周(1-based) */ + currentWeek: number; + /** 学期总周数 */ + totalWeek: number; + /** 节次时间表 */ + sections: Section[]; +} + +/** 课程对象 */ +export interface Course { + id: string; + name: string; + teacher: string; + room: string; + /** 星期 1~7(周一~周日) */ + weekDay: number; + /** 开始节次(1-based,包含) */ + startSection: number; + /** 结束节次(1-based,包含) */ + endSection: number; + /** 起始周(1-based,包含) */ + startWeek: number; + /** 结束周(1-based,包含) */ + endWeek: number; + /** 单双周 */ + weekType: WeekType; + /** 色块背景色(hex) */ + color: string; +} + +/** 仓储接口:localStorage 与未来 HTTP 实现共同遵守 */ +export interface TimetableRepository { + listCourses(): Promise; + saveCourse(course: Course): Promise; + deleteCourse(id: string): Promise; + getConfig(): Promise; + saveConfig(config: TimetableConfig): Promise; +} + +const COURSES_KEY = "cloudsteps_timetable_courses"; +const CONFIG_KEY = "cloudsteps_timetable_config"; + +/** 默认节次时间(学习通常见 12 节) */ +export const DEFAULT_SECTIONS: Section[] = [ + { no: 1, start: "08:00", end: "08:45" }, + { no: 2, start: "08:55", end: "09:40" }, + { no: 3, start: "10:00", end: "10:45" }, + { no: 4, start: "10:55", end: "11:40" }, + { no: 5, start: "14:00", end: "14:45" }, + { no: 6, start: "14:55", end: "15:40" }, + { no: 7, start: "16:00", end: "16:45" }, + { no: 8, start: "16:55", end: "17:40" }, + { no: 9, start: "19:00", end: "19:45" }, + { no: 10, start: "19:55", end: "20:40" }, + { no: 11, start: "20:50", end: "21:35" }, + { no: 12, start: "21:45", end: "22:30" }, +]; + +/** 课程色块预设(学习通风格,避免 AI 紫色审美) */ +export const COURSE_COLORS: string[] = [ + "#4ECDC4", // mint + "#55A3FF", // sky + "#FF8C5A", // coral + "#F6B26B", // amber + "#8B7FD8", // violet + "#E85555", // red + "#3DB8B0", // deep mint + "#F4A6C7", // pink + "#67B279", // green + "#7A8A99", // slate +]; + +function defaultConfig(): TimetableConfig { + return { currentWeek: 1, totalWeek: 20, sections: DEFAULT_SECTIONS }; +} + +function safeParse(raw: string | null, fallback: T): T { + if (!raw) return fallback; + try { + return JSON.parse(raw) as T; + } catch { + return fallback; + } +} + +/** 生成唯一 id(无依赖) */ +export function genCourseId(): string { + return `c_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; +} + +/** + * localStorage 实现。所有方法返回 Promise,签名与未来 HTTP 实现一致, + * 便于无缝替换。 + */ +class LocalTimetableRepository implements TimetableRepository { + async listCourses(): Promise { + return safeParse(localStorage.getItem(COURSES_KEY), []); + } + + async saveCourse(course: Course): Promise { + const list = await this.listCourses(); + const idx = list.findIndex((c) => c.id === course.id); + if (idx >= 0) list[idx] = course; + else list.push(course); + localStorage.setItem(COURSES_KEY, JSON.stringify(list)); + return course; + } + + async deleteCourse(id: string): Promise { + const list = (await this.listCourses()).filter((c) => c.id !== id); + localStorage.setItem(COURSES_KEY, JSON.stringify(list)); + } + + async getConfig(): Promise { + return safeParse(localStorage.getItem(CONFIG_KEY), defaultConfig()); + } + + async saveConfig(config: TimetableConfig): Promise { + localStorage.setItem(CONFIG_KEY, JSON.stringify(config)); + return config; + } +} + +/** + * 仓储单例。当前指向 localStorage 实现; + * 切换后端时把这里换成 `new HttpTimetableRepository()` 即可。 + */ +export const timetableRepo: TimetableRepository = new LocalTimetableRepository(); diff --git a/web/src/components/CoachingSchedulePanel.tsx b/web/src/components/CoachingSchedulePanel.tsx index 29b8073..29e0b0e 100644 --- a/web/src/components/CoachingSchedulePanel.tsx +++ b/web/src/components/CoachingSchedulePanel.tsx @@ -43,6 +43,15 @@ import { useIsMobile } from "./ui/use-mobile"; import { MobileDateWheel } from "./cloudsteps/MobileWheelPicker"; import { CloudSpin } from "./cloudsteps/arco"; import { CloudButton } from "./cloudsteps"; +import { useTimetableStore, blankCourseInput } from "../stores/timetableStore"; +import { CourseEditorDialog } from "./timetable/CourseEditorDialog"; +import { timeToSections } from "../utils/coachingSectionMap"; +import { isCourseShow, weekRangeLabel } from "../utils/timetableFilter"; +import { + DEFAULT_SECTIONS, + type Course, + type TimetableConfig, +} from "../api/timetable"; const pad2 = (n: number) => String(n).padStart(2, "0"); const fmtYMD = (d: Date) => @@ -119,6 +128,23 @@ function isSchedulePast(schedule: CoachingWeekSchedule, nowTs: number): boolean return end.getTime() <= nowTs; } +/** + * 排课显示的结束时间标签: + * - scheduled:计划 endTime + * - in_progress:空字符串(只显示「21:00-」) + * - completed:session.endedAt 的实际 HH:MM,回退到计划 endTime + */ +function displayEndLabel(schedule: CoachingWeekSchedule): string { + if (schedule.status === "in_progress") return ""; + if (schedule.status === "completed" && schedule.session?.endedAt) { + const d = new Date(schedule.session.endedAt); + if (!Number.isNaN(d.getTime())) { + return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`; + } + } + return schedule.endTime?.slice(0, 5) || ""; +} + function parseHmToMinutes(t: string): number { const raw = (t || "").trim().slice(0, 5); const m = /^(\d{1,2}):(\d{2})$/.exec(raw); @@ -252,7 +278,7 @@ function TimetableBlock({ : STATUS_SOFT[schedule.status] || STATUS_SOFT.scheduled; const { title } = lessonDisplay(t, schedule); const start = schedule.startTime?.slice(0, 5) || ""; - const end = schedule.endTime?.slice(0, 5) || ""; + const end = displayEndLabel(schedule); const widthPct = 100 / colCount; const leftPct = col * widthPct; @@ -343,6 +369,15 @@ export function CoachingSchedulePanel({ nowTs, mode = "coach" }: Props) { const [tipHole, setTipHole] = useState(null); const [tipReady, setTipReady] = useState(false); + // localStorage 自定义课程 + const ttLoad = useTimetableStore((s) => s.load); + const ttLoaded = useTimetableStore((s) => s.loaded); + const ttCourses = useTimetableStore((s) => s.courses); + const ttConfig = useTimetableStore((s) => s.config); + const [editorOpen, setEditorOpen] = useState(false); + const [editingCourse, setEditingCourse] = useState(null); + const [coursePreset, setCoursePreset] = useState<{ weekDay: number; startSection: number } | null>(null); + const timetableHostRef = useRef(null); const [axisHeightPx, setAxisHeightPx] = useState(280); @@ -353,6 +388,8 @@ export function CoachingSchedulePanel({ nowTs, mode = "coach" }: Props) { ); const todayYMD = fmtYMD(new Date()); const weekShortLabel = `${fmtMD(weekMon)}–${fmtMD(addDays(weekMon, 6))}`; + const currentWeekMon = useMemo(() => weekMonday(new Date()), []); + const isCurrentCalendarWeek = fmtYMD(weekMon) === fmtYMD(currentWeekMon); const byDay = useMemo(() => { const map: Record = {}; @@ -446,7 +483,8 @@ export function CoachingSchedulePanel({ nowTs, mode = "coach" }: Props) { useEffect(() => { void loadWeek(); void loadQuotas(); - }, [loadWeek, loadQuotas]); + if (!ttLoaded) void ttLoad(); + }, [loadWeek, loadQuotas, ttLoad, ttLoaded]); useLayoutEffect(() => { const el = timetableHostRef.current; @@ -658,6 +696,111 @@ export function CoachingSchedulePanel({ nowTs, mode = "coach" }: Props) { !!parseCoachingSlotEnd(selected.scheduledDate, selected.endTime) && parseCoachingSlotEnd(selected.scheduledDate, selected.endTime)!.getTime() < nowTs; + /** + * 节次网格合并项:后端预约(按时间映射到节次)+ localStorage 自定义课程。 + * 每项带来源标记 kind,点击时按 kind 分流交互。 + */ + const gridItems = useMemo(() => { + const sections = ttConfig.sections.length > 0 ? ttConfig.sections : DEFAULT_SECTIONS; + const items: GridItem[] = []; + + // 后端预约:按 scheduledDate 算出属于本周第几天,时间映射到节次 + for (const s of schedules) { + const ymd = s.scheduledDate?.slice?.(0, 10) || s.scheduledDate; + if (!ymd) continue; + const dayIdx = weekDays.findIndex((d) => fmtYMD(d) === ymd); + if (dayIdx < 0) continue; + const { startSection, endSection } = timeToSections(s.startTime, s.endTime, sections); + const past = isSchedulePast(s, nowTs); + const statusColor = + s.status === "completed" + ? "#F59E0B" + : past + ? "#A78BFA" + : s.status === "in_progress" + ? "#55A3FF" + : s.status === "cancelled" + ? "#E85555" + : "#4ECDC4"; + const { title, subtitle } = lessonDisplay(t, s); + items.push({ + key: `co-${s.id}`, + kind: "coaching", + weekDay: dayIdx + 1, + startSection, + endSection, + color: statusColor, + title, + subtitle, + meta: `${s.startTime?.slice(0, 5)}-${displayEndLabel(s)}`, + typeLabel: t("coaching.schedule_type"), + statusLabel: t(`coaching.status.${s.status}`, { defaultValue: s.status }), + schedule: s, + }); + } + + // localStorage 自定义课程:按 weekDay 归属到本周(不区分具体日期) + for (const c of ttCourses) { + if (c.weekDay < 1 || c.weekDay > 7) continue; + items.push({ + key: `cu-${c.id}`, + kind: "course", + weekDay: c.weekDay, + startSection: c.startSection, + endSection: c.endSection, + color: c.color, + title: c.name, + subtitle: c.room ? `@${c.room}` : undefined, + meta: weekRangeLabel(c), + typeLabel: t("timetable.custom_course"), + course: c, + }); + } + + const byDay = new Map(); + for (const item of items) { + const dayItems = byDay.get(item.weekDay) || []; + dayItems.push(item); + byDay.set(item.weekDay, dayItems); + } + + return Array.from(byDay.values()).flatMap((dayItems) => { + const sorted = [...dayItems].sort( + (a, b) => a.startSection - b.startSection || b.endSection - a.endSection, + ); + const laneEnds: number[] = []; + const assigned = sorted.map((item) => { + let lane = laneEnds.findIndex((end) => end < item.startSection); + if (lane < 0) { + lane = laneEnds.length; + laneEnds.push(item.endSection); + } else { + laneEnds[lane] = item.endSection; + } + return { item, lane }; + }); + const laneCount = Math.max(1, laneEnds.length); + + return assigned.map(({ item, lane }) => ({ + ...item, + lane, + laneCount, + conflict: laneCount > 1 && dayItems.some( + (other) => + other.key !== item.key && + other.startSection <= item.endSection && + other.endSection >= item.startSection, + ), + })); + }); + }, [schedules, ttCourses, ttConfig.sections, weekDays, t, nowTs]); + + function openCourseEditor(course: Course | null, preset: { weekDay: number; startSection: number } | null) { + setEditingCourse(course); + setCoursePreset(preset); + setEditorOpen(true); + } + const weekTrigger = ( - ); - })} - {weekDays.map((d, i) => { - const ymd = fmtYMD(d); - const isToday = ymd === todayYMD; - return ( - - ); - })} - - ) : ( -
-
- {weekDays.map((d, i) => { - const ymd = fmtYMD(d); - const isToday = ymd === todayYMD; - return ( - - ); - })} - -
- {axisMarks.map((m) => ( -
- - {fmtMinutes(m).slice(0, 5)} - -
- ))} -
- - {weekDays.map((d, dIdx) => { - const ymd = fmtYMD(d); - const isToday = ymd === todayYMD; - const isPastDay = ymd < todayYMD; - const dayItems = byDay[ymd] || []; - const laidOut = layoutDayEvents( - dayItems, - axisRange.startMin, - axisRange.endMin, - axisHeightPx, - ); - const nowLocalM = new Date(nowTs).getHours() * 60 + new Date(nowTs).getMinutes(); - const todayPastHeightPx = - isToday && nowLocalM > axisRange.startMin - ? Math.min( - axisHeightPx, - ((Math.min(nowLocalM, axisRange.endMin) - axisRange.startMin) / axisSpan) * - axisHeightPx, - ) - : 0; - return ( -
- {isPastDay ? ( -
- ) : null} - {todayPastHeightPx > 0 ? ( -
- ) : null} - {axisMarks.map((m) => ( -
- ))} - - {isCoach ? ( -
- ); - })} -
- )} +
+ 0 ? ttConfig.sections : DEFAULT_SECTIONS} + items={gridItems} + isCoach={isCoach} + t={t} + onDayHeaderClick={(d) => { + if (!isCoach) return; + dismissCellTip(); + openScheduleForDay(d); + }} + onCellClick={(weekDay, section) => { + if (!isCoach) return; + dismissCellTip(); + openCourseEditor(null, { weekDay, startSection: section }); + }} + onItemClick={(item) => { + dismissCellTip(); + if (item.kind === "coaching" && item.schedule) { + setSelected(item.schedule); + } else if (item.kind === "course" && item.course) { + openCourseEditor(item.course, null); + } + }} + />
)}
@@ -986,7 +951,7 @@ export function CoachingSchedulePanel({ nowTs, mode = "coach" }: Props) {

{selected.scheduledDate?.slice?.(0, 10) || selected.scheduledDate} ·{" "} - {selected.startTime}–{selected.endTime} + {selected.startTime}–{displayEndLabel(selected)}

)} + + +
+ ); +} + +// ===== 节次网格子组件 ===== + +type GridItem = { + key: string; + kind: "coaching" | "course"; + weekDay: number; + startSection: number; + endSection: number; + color: string; + title: string; + subtitle?: string; + meta?: string; + typeLabel: string; + statusLabel?: string; + conflict?: boolean; + lane?: number; + laneCount?: number; + schedule?: CoachingWeekSchedule; + course?: Course; +}; + +function hexToRgba(hex: string, alpha: number): string { + const h = hex.replace("#", ""); + const n = parseInt(h.length === 3 ? h.split("").map((c) => c + c).join("") : h, 16); + return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`; +} + +function SectionGrid({ + weekDays, + todayYMD, + sections, + items, + isCoach, + t, + onDayHeaderClick, + onCellClick, + onItemClick, +}: { + weekDays: Date[]; + todayYMD: string; + sections: TimetableConfig["sections"]; + items: GridItem[]; + isCoach: boolean; + t: TFunction; + onDayHeaderClick: (d: Date) => void; + onCellClick: (weekDay: number, section: number) => void; + onItemClick: (item: GridItem) => void; +}) { + const isMobile = useIsMobile(); + const totalRows = sections.length; + const gridStyle = { + gridTemplateColumns: isMobile + ? `32px repeat(7, minmax(40px, 1fr))` + : `80px repeat(7, minmax(0, 1fr))`, + gridTemplateRows: isMobile + ? `42px repeat(${totalRows}, 52px)` + : `56px repeat(${totalRows}, 64px)`, + } as const; + + return ( +
+ {/* 左上角 */} +
+ {t("timetable.section")} +
+ {/* 表头:7 天 */} + {weekDays.map((d, i) => { + const ymd = fmtYMD(d); + const isToday = ymd === todayYMD; + return ( + + ); + })} + + {/* 节次标签 + 空白格 */} + {sections.map((sec) => ( +
+
+ {sec.no} + {sec.start} + {sec.end} +
+ {Array.from({ length: 7 }, (_, dayIdx) => ( + + ))} +
+ ))} + + {items.length === 0 && ( +
+
+
+ +
+

{t("coaching.empty_schedule_title")}

+

{t("coaching.empty_schedule_desc")}

+
+
+ )} + + {/* 课程/预约色块 */} + {items.map((item) => { + const col = item.weekDay + 1; + const rowStart = item.startSection + 1; + const rowEnd = item.endSection + 2; + const span = item.endSection - item.startSection + 1; + const showDetail = span >= 2; + return ( + + ); + })}
); } diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 7afdef3..e6d08f4 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -230,7 +230,7 @@ export function Layout() { }`; const lessonPrepInnerClass = - "h-full flex flex-col max-w-[1200px] w-full mx-auto min-h-0 px-2 py-1 sm:px-3 lg:px-4 overflow-hidden"; + "h-full flex flex-col max-w-[1600px] w-full mx-auto min-h-0 px-2 py-1 sm:px-3 lg:px-4 overflow-hidden"; return (
void; + /** 传入课程为编辑模式;null 为新增模式 */ + course: Course | null; + /** 新增模式下预填的星期/节次(点击空白格时带入) */ + preset?: { weekDay: number; startSection: number } | null; + config: TimetableConfig; +} + +type Input = Omit; + +const WEEK_TYPE_OPTIONS: Array<{ value: WeekType; key: string }> = [ + { value: 0, key: "timetable.week_all" }, + { value: 1, key: "timetable.week_single" }, + { value: 2, key: "timetable.week_double" }, +]; + +export function CourseEditorDialog({ + open, + onOpenChange, + course, + preset, + config, +}: CourseEditorDialogProps) { + const { t } = useTranslation(); + const addCourse = useTimetableStore((s) => s.addCourse); + const updateCourse = useTimetableStore((s) => s.updateCourse); + const removeCourse = useTimetableStore((s) => s.removeCourse); + + const [form, setForm] = useState(() => blankCourseInput(config)); + const [submitting, setSubmitting] = useState(false); + + // 打开时根据模式初始化表单 + useEffect(() => { + if (!open) return; + if (course) { + const { id: _id, ...rest } = course; + void _id; + setForm(rest); + } else { + const base = blankCourseInput(config); + if (preset) { + setForm({ + ...base, + weekDay: preset.weekDay, + startSection: preset.startSection, + endSection: Math.min(preset.startSection + 1, config.sections.length), + }); + } else { + setForm(base); + } + } + }, [open, course, preset, config]); + + const isEdit = !!course; + + function patch(key: K, value: Input[K]) { + setForm((f) => ({ ...f, [key]: value })); + } + + async function handleSubmit() { + setSubmitting(true); + try { + const res = isEdit + ? await updateCourse(course!.id, form) + : await addCourse(form); + if (!res.ok) { + showToast.error(res.error || t("timetable.save_failed")); + return; + } + showToast.success(isEdit ? t("timetable.edit_ok") : t("timetable.add_ok")); + onOpenChange(false); + } finally { + setSubmitting(false); + } + } + + async function handleDelete() { + if (!course) return; + if (!window.confirm(t("timetable.confirm_delete"))) return; + await removeCourse(course.id); + showToast.success(t("timetable.delete_ok")); + onOpenChange(false); + } + + const sectionOptions = config.sections.map((s) => s.no); + const weekOptions = Array.from({ length: config.totalWeek }, (_, i) => i + 1); + + return ( + + + + {isEdit ? t("timetable.edit_title") : t("timetable.add_title")} + {t("timetable.form_desc")} + + +
+
+ + patch("name", e.target.value)} + placeholder={t("timetable.course_name_ph")} + /> +
+ +
+ + patch("teacher", e.target.value)} + /> +
+ +
+ + patch("room", e.target.value)} + /> +
+ +
+ + patch("weekDay", Number(v))} + options={[1, 2, 3, 4, 5, 6, 7].map((d) => ({ + value: d, + label: t("timetable.day_n", { n: d }), + }))} + /> +
+ +
+ + patch("weekType", Number(v) as WeekType)} + options={WEEK_TYPE_OPTIONS.map((o) => ({ + value: o.value, + label: t(o.key), + }))} + /> +
+ +
+ + { + const s = Number(v); + patch("startSection", s); + if (form.endSection < s) patch("endSection", s); + }} + options={sectionOptions.map((n) => ({ value: n, label: String(n) }))} + /> +
+ +
+ + patch("endSection", Number(v))} + options={sectionOptions.map((n) => ({ value: n, label: String(n) }))} + /> +
+ +
+ + { + const w = Number(v); + patch("startWeek", w); + if (form.endWeek < w) patch("endWeek", w); + }} + options={weekOptions.map((n) => ({ value: n, label: String(n) }))} + /> +
+ +
+ + patch("endWeek", Number(v))} + options={weekOptions.map((n) => ({ value: n, label: String(n) }))} + /> +
+ +
+ +
+ {COURSE_COLORS.map((c) => ( +
+
+
+ + + {isEdit && ( + + )} + + + +
+
+ ); +} + +/** 原生 select,套 Tailwind 样式与 Input 保持一致 */ +function NativeSelect({ + id, + value, + onChange, + options, +}: { + id: string; + value: number; + onChange: (v: string) => void; + options: Array<{ value: number; label: string }>; +}) { + return ( + + ); +} diff --git a/web/src/components/timetable/CourseTimetable.tsx b/web/src/components/timetable/CourseTimetable.tsx new file mode 100644 index 0000000..aed01c6 --- /dev/null +++ b/web/src/components/timetable/CourseTimetable.tsx @@ -0,0 +1,150 @@ +/** + * 学习通式课表 — 节次网格主体 + * + * 用 CSS Grid 实现:第 1 列为节次标签,后 7 列为周一~周日; + * 每一节对应一行,课程色块通过 gridColumn / gridRow 跨行渲染。 + * 不使用 ,避免跨行合并难以处理。 + */ +import { forwardRef, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Pencil } from "lucide-react"; +import type { Course, TimetableConfig } from "../../api/timetable"; +import { isCourseShow, weekRangeLabel } from "../../utils/timetableFilter"; + +const DAY_KEYS = [ + "timetable.day_mon", + "timetable.day_tue", + "timetable.day_wed", + "timetable.day_thu", + "timetable.day_fri", + "timetable.day_sat", + "timetable.day_sun", +]; + +/** hex → rgba,用于色块的浅色边框 */ +function hexToRgba(hex: string, alpha: number): string { + const h = hex.replace("#", ""); + const n = parseInt(h.length === 3 ? h.split("").map((c) => c + c).join("") : h, 16); + return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`; +} + +interface CourseTimetableProps { + courses: Course[]; + config: TimetableConfig; + viewWeek: number; + onEdit: (course: Course) => void; + onPickSlot?: (weekDay: number, section: number) => void; +} + +export const CourseTimetable = forwardRef( + function CourseTimetable({ courses, config, viewWeek, onEdit, onPickSlot }, ref) { + const { t } = useTranslation(); + const sections = config.sections; + const totalRows = sections.length; + + // 当前周可见课程 + const visible = useMemo( + () => courses.filter((c) => isCourseShow(c, viewWeek)), + [courses, viewWeek], + ); + + // 网格:第 1 列 56px,后 7 列 1fr;第 1 行表头 44px,其余每节 64px + const gridStyle = { + gridTemplateColumns: `56px repeat(7, minmax(0, 1fr))`, + gridTemplateRows: `44px repeat(${totalRows}, 64px)`, + } as const; + + return ( +
+
+ {/* 表头:左上角 + 7 天 */} +
+ {t("timetable.section")} +
+ {DAY_KEYS.map((key, i) => ( +
+ {t(key)} +
+ ))} + + {/* 节次标签 + 空白格(构成网格线) */} + {sections.map((sec, rowIdx) => ( +
+
+ {sec.no} + {sec.start} + {sec.end} +
+ {Array.from({ length: 7 }, (_, dayIdx) => ( +
+ ))} + + {/* 课程色块:跨行渲染 */} + {visible.map((c) => { + const col = c.weekDay + 1; // 第 1 列是节次标签 + const rowStart = c.startSection + 1; // +1 因为第 1 行是表头 + const rowEnd = c.endSection + 2; // grid-row-end 不含,+1 表头 +1 包含末节 + const span = c.endSection - c.startSection + 1; + const showDetail = span >= 2; + return ( + + ); + })} +
+
+ ); + }, +); + +/** 判断星期几是否是今天(用于表头高亮) */ +function isToday(weekDay: number): boolean { + const jsDay = new Date().getDay(); // 0=周日 + const monBased = jsDay === 0 ? 7 : jsDay; + return monBased === weekDay; +} diff --git a/web/src/components/timetable/TimetableToolbar.tsx b/web/src/components/timetable/TimetableToolbar.tsx new file mode 100644 index 0000000..c371ee4 --- /dev/null +++ b/web/src/components/timetable/TimetableToolbar.tsx @@ -0,0 +1,116 @@ +/** + * 学习通式课表 — 顶部工具栏 + * 周切换、回到本周、添加课程、导出 Excel、打印/导出图片 + */ +import { useTranslation } from "react-i18next"; +import { ChevronLeft, ChevronRight, Plus, Download, Printer, CalendarDays } from "lucide-react"; +import { Button } from "../ui/button"; +import { useTimetableStore } from "../../stores/timetableStore"; +import { isCourseShow, weekDayLabel, weekRangeLabel } from "../../utils/timetableFilter"; +import { showToast } from "../../utils/toast"; +import type { Course } from "../../api/timetable"; + +interface TimetableToolbarProps { + onAdd: () => void; +} + +export function TimetableToolbar({ onAdd }: TimetableToolbarProps) { + const { t } = useTranslation(); + const viewWeek = useTimetableStore((s) => s.viewWeek); + const config = useTimetableStore((s) => s.config); + const courses = useTimetableStore((s) => s.courses); + const prevWeek = useTimetableStore((s) => s.prevWeek); + const nextWeek = useTimetableStore((s) => s.nextWeek); + const setViewWeek = useTimetableStore((s) => s.setViewWeek); + const setCurrentWeek = useTimetableStore((s) => s.setCurrentWeek); + + const isCurrent = viewWeek === config.currentWeek; + const weekOptions = Array.from({ length: config.totalWeek }, (_, i) => i + 1); + + function handleJump(value: string) { + setViewWeek(Number(value)); + } + + async function handleBackToCurrent() { + await setCurrentWeek(config.currentWeek); + } + + /** 导出当前周可见课程为 Excel */ + async function handleExportExcel() { + const visible = courses.filter((c) => isCourseShow(c, viewWeek)); + if (visible.length === 0) { + showToast.warning(t("timetable.export_empty")); + return; + } + const XLSX = await import("xlsx"); + const rows = visible.map((c: Course) => ({ + [t("timetable.course_name")]: c.name, + [t("timetable.teacher")]: c.teacher, + [t("timetable.room")]: c.room, + [t("timetable.week_day")]: weekDayLabel(c.weekDay), + [t("timetable.start_section")]: c.startSection, + [t("timetable.end_section")]: c.endSection, + [t("timetable.week_range")]: weekRangeLabel(c), + })); + const ws = XLSX.utils.json_to_sheet(rows); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, `${t("timetable.week_n", { n: viewWeek })}`); + XLSX.writeFile(wb, `timetable_week${viewWeek}.xlsx`); + showToast.success(t("timetable.export_ok")); + } + + /** 打印当前页(浏览器另存为 PDF/图片) */ + function handlePrint() { + window.print(); + } + + return ( +
+
+ +
+ + / {config.totalWeek} +
+ +
+ + {!isCurrent && ( + + )} + +
+ + + +
+
+ ); +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 2a26739..18c69c5 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1292,7 +1292,13 @@ "coaching.initial_quota": "Initial coaching quota (minutes)", "coaching.confirm_add": "Confirm add", "coaching.schedule_title": "Coaching schedule", + "coaching.schedule_hint": "Click an empty section to add a course, or select an existing course for details", + "coaching.schedule_type": "Coaching class", + "coaching.empty_schedule_title": "No classes this week", + "coaching.empty_schedule_desc": "Click an empty time slot or use the button above to create a course", + "coaching.schedule_conflict": "Conflict", "coaching.my_schedule": "My schedule", + "timetable.custom_course": "Custom course", "coaching.pending_count": "{{count}} upcoming", "coaching.select_week": "Select week", "coaching.select_week_day": "Pick a day (jump to that week)", @@ -1715,8 +1721,8 @@ "anti_forgetting.training_at": "Training time: {{time}}", "anti_forgetting.word_count_tag": "{{count}} words", "anti_forgetting.current_user": "Current user", - "create_anti_forgetting.title": "Anti-forgetting settings", - "create_anti_forgetting.intro": "Set the practice time window for this lesson. The anti-forgetting list groups reviews by this slot (e.g. 2026/08/28 10:00-11:00). Review schedule follows the count configured in student management.", + "create_anti_forgetting.title": "Anti-forgetting time setup", + "create_anti_forgetting.intro": "", "create_anti_forgetting.time_label": "Practice time window", "create_anti_forgetting.date": "Lesson date", "create_anti_forgetting.start_time": "Start time", @@ -2131,5 +2137,51 @@ "lighthouse.load_failed": "Failed to load lighthouse data", "review.empty_session": "Review session or word list is empty", "auth.fetch_user_failed": "Failed to fetch user info", - "notification.invalid_id": "Invalid notification ID" + "notification.invalid_id": "Invalid notification ID", + "timetable.title": "My Timetable", + "timetable.subtitle": "Section-grid timetable · supports odd/even weeks and week ranges", + "timetable.section": "Section", + "timetable.day_mon": "Mon", + "timetable.day_tue": "Tue", + "timetable.day_wed": "Wed", + "timetable.day_thu": "Thu", + "timetable.day_fri": "Fri", + "timetable.day_sat": "Sat", + "timetable.day_sun": "Sun", + "timetable.day_n": "Day {{n}}", + "timetable.week_n": "Week {{n}}", + "timetable.prev_week": "Previous week", + "timetable.next_week": "Next week", + "timetable.jump_week": "Jump to week", + "timetable.back_to_current": "Back to current week", + "timetable.add_course": "Add course", + "timetable.export_excel": "Export Excel", + "timetable.print": "Print", + "timetable.export_empty": "No courses this week to export", + "timetable.export_ok": "Excel exported", + "timetable.week_summary": "{{count}} course(s) this week", + "timetable.add_title": "Add course", + "timetable.edit_title": "Edit course", + "timetable.form_desc": "Fill in course info, sections and effective weeks", + "timetable.course_name": "Course name", + "timetable.course_name_ph": "e.g. Calculus", + "timetable.teacher": "Teacher", + "timetable.room": "Room", + "timetable.week_day": "Weekday", + "timetable.start_section": "Start section", + "timetable.end_section": "End section", + "timetable.start_week": "Start week", + "timetable.end_week": "End week", + "timetable.week_type": "Week pattern", + "timetable.week_all": "Every week", + "timetable.week_single": "Odd weeks", + "timetable.week_double": "Even weeks", + "timetable.week_range": "Weeks", + "timetable.color": "Color", + "timetable.delete": "Delete", + "timetable.confirm_delete": "Delete this course?", + "timetable.add_ok": "Course added", + "timetable.edit_ok": "Course updated", + "timetable.delete_ok": "Course deleted", + "timetable.save_failed": "Save failed" } diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index 99a3f56..ba53039 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -1433,8 +1433,8 @@ "anti_forgetting.training_at": "训练时间:{{time}}", "anti_forgetting.word_count_tag": "{{count}} 词", "anti_forgetting.current_user": "当前用户", - "create_anti_forgetting.title": "抗遗忘设置", - "create_anti_forgetting.intro": "设置本节课识记练习的具体时段,抗遗忘列表将按此时段分组展示(如 2026/08/28 10:00-11:00)。复习排期按学员管理中配置的抗遗忘次数自动计算。", + "create_anti_forgetting.title": "抗遗忘时间设置", + "create_anti_forgetting.intro": "", "create_anti_forgetting.time_label": "识记练习时段", "create_anti_forgetting.date": "上课日期", "create_anti_forgetting.start_time": "开始时间", @@ -1917,7 +1917,13 @@ "coaching.initial_quota": "初始陪练额度(分钟)", "coaching.confirm_add": "确认添加", "coaching.schedule_title": "陪练排课", + "coaching.schedule_hint": "点击空白节次可快速新增课程,点击已有课程查看详情", + "coaching.schedule_type": "陪练课程", + "coaching.empty_schedule_title": "本周暂无排课", + "coaching.empty_schedule_desc": "点击空白时间格,或使用右上角按钮快速创建课程", + "coaching.schedule_conflict": "冲突", "coaching.my_schedule": "我的课表", + "timetable.custom_course": "自定义课程", "coaching.pending_count": "待上 {{count}}", "coaching.select_week": "选择周", "coaching.select_week_day": "选择某一天(跳转到该周)", @@ -2131,5 +2137,51 @@ "lighthouse.load_failed": "加载灯塔数据失败", "review.empty_session": "复习会话或单词为空", "auth.fetch_user_failed": "获取用户信息失败", - "notification.invalid_id": "无效的通知 ID" + "notification.invalid_id": "无效的通知 ID", + "timetable.title": "我的课表", + "timetable.subtitle": "节次网格课表 · 支持单双周与起止周", + "timetable.section": "节次", + "timetable.day_mon": "周一", + "timetable.day_tue": "周二", + "timetable.day_wed": "周三", + "timetable.day_thu": "周四", + "timetable.day_fri": "周五", + "timetable.day_sat": "周六", + "timetable.day_sun": "周日", + "timetable.day_n": "周{{n}}", + "timetable.week_n": "第{{n}}周", + "timetable.prev_week": "上一周", + "timetable.next_week": "下一周", + "timetable.jump_week": "跳转到指定周", + "timetable.back_to_current": "回到本周", + "timetable.add_course": "添加课程", + "timetable.export_excel": "导出 Excel", + "timetable.print": "打印", + "timetable.export_empty": "当前周没有课程,无法导出", + "timetable.export_ok": "已导出 Excel", + "timetable.week_summary": "本周共 {{count}} 门课", + "timetable.add_title": "新增课程", + "timetable.edit_title": "编辑课程", + "timetable.form_desc": "填写课程信息、节次与生效周次", + "timetable.course_name": "课程名称", + "timetable.course_name_ph": "如:高等数学", + "timetable.teacher": "教师", + "timetable.room": "教室", + "timetable.week_day": "星期", + "timetable.start_section": "开始节次", + "timetable.end_section": "结束节次", + "timetable.start_week": "起始周", + "timetable.end_week": "结束周", + "timetable.week_type": "单双周", + "timetable.week_all": "每周", + "timetable.week_single": "单周", + "timetable.week_double": "双周", + "timetable.week_range": "周次", + "timetable.color": "颜色", + "timetable.delete": "删除", + "timetable.confirm_delete": "确定删除该课程吗?", + "timetable.add_ok": "已添加课程", + "timetable.edit_ok": "已更新课程", + "timetable.delete_ok": "已删除课程", + "timetable.save_failed": "保存失败" } diff --git a/web/src/pages/AntiForgetting.tsx b/web/src/pages/AntiForgetting.tsx index df3f99c..52c5e7e 100644 --- a/web/src/pages/AntiForgetting.tsx +++ b/web/src/pages/AntiForgetting.tsx @@ -4,8 +4,6 @@ import { ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"; import { CloudButton } from "../components/cloudsteps"; import { CloudCard, CloudDatePicker, CloudEmpty, CloudSpin } from "../components/cloudsteps/arco"; import { listReviewBooksByDate, type ReviewBookStatRow } from "../api/review"; -import { useAuthStore } from "../stores/authStore"; -import { reviewCurveLabel } from "../utils/reviewCurve"; import { normalizeSnowflakeId } from "../utils/json-snowflake"; import { useTranslation } from "react-i18next"; import i18n from "../i18n"; @@ -84,7 +82,6 @@ export default function AntiForgetting() { const { t } = useTranslation(); const [selectedDate, setSelectedDate] = useState(() => toDateInputValue(new Date())); const navigate = useNavigate(); - const reviewCurvePreset = useAuthStore((s) => s.user?.reviewCurvePreset) || "times5"; const [bookStats, setBookStats] = useState([]); const [loadingBooks, setLoadingBooks] = useState(true); @@ -198,10 +195,6 @@ export default function AntiForgetting() { return (
-

- {t("anti_forgetting.curve", { label: reviewCurveLabel(reviewCurvePreset) })} -

-
) : ( - -
+ +
{selectedDate}
- {timelineGroups.map((group) => ( + {timelineGroups.map((group, groupIdx) => (
- {group.tasks.map((task, idx) => ( + {group.tasks.map((task, idx) => { + const hasNextTask = idx < group.tasks.length - 1 || groupIdx < timelineGroups.length - 1; + return (
-
+
{idx === 0 ? ( -
+
{group.timeSlot}
) : ( @@ -272,9 +267,9 @@ export default function AntiForgetting() { )}
-
+
@@ -308,9 +303,24 @@ export default function AntiForgetting() { {t("anti_forgetting.training_at", { time: task.trainingAt })}

+ + { + e.stopPropagation(); + handleOpenTask(task); + }} + > + {t("practice.start_review")} +
- ))} + ); + })}
))}
diff --git a/web/src/pages/CreateAntiForgetting.tsx b/web/src/pages/CreateAntiForgetting.tsx index 2942bdf..c1232d6 100644 --- a/web/src/pages/CreateAntiForgetting.tsx +++ b/web/src/pages/CreateAntiForgetting.tsx @@ -185,11 +185,8 @@ export default function CreateAntiForgetting() {
-

- {t("create_anti_forgetting.intro")} -

{trainingStudent?.name ? ( -

+

{t("create_anti_forgetting.student", { name: trainingStudent.name })}

) : null} diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index 8e1d06e..9952c82 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -3,8 +3,8 @@ import { CoachingSchedulePanel } from "../components/CoachingSchedulePanel"; import { useAuthStore } from "../stores/authStore"; /** - * 备课页 `/lesson-prep` — 周课表(点天排课 + 时间轴展示)。 - * 教练可排课/上下课;学员只读自己的课表。 + * 备课页 `/lesson-prep` — 周课表(节次网格展示 + 排课/上下课 + 自定义课程)。 + * 教练可排课/上下课/删除排课,并可新增自定义课程;学员只读自己的课表。 */ export default function Home() { const user = useAuthStore((s) => s.user); diff --git a/web/src/pages/PostTrainingCheck.tsx b/web/src/pages/PostTrainingCheck.tsx index d3fded3..aed5268 100644 --- a/web/src/pages/PostTrainingCheck.tsx +++ b/web/src/pages/PostTrainingCheck.tsx @@ -172,14 +172,9 @@ export default function PostTrainingCheck() { setSpellResult(null); }; - const toggleSpellRevealed = (id: string | number, e: React.MouseEvent) => { + const openSpellFromBlock = (word: CheckWord, e: React.MouseEvent) => { e.stopPropagation(); - setSpellRevealed((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); + if (!spellRevealed.has(word.id)) openSpellDialog(word); }; const closeSpellDialog = () => { @@ -260,7 +255,16 @@ export default function PostTrainingCheck() { const handleWordClick = (word: CheckWord) => { if (spellMode) { - openSpellDialog(word); + setWords((prev) => + prev.map((w) => + w.id === word.id + ? { ...w, showTranslation: !w.showTranslation, heard: false } + : { ...w, showTranslation: false, heard: false } + ) + ); + setDetailWord( + detailMode && !word.showTranslation ? { id: word.id, word: word.word } : null + ); return; } const next = nextWordTapState({ @@ -612,7 +616,7 @@ export default function PostTrainingCheck() { {spellMode ? ( toggleSpellRevealed(word.id, e)} + onClick={(e) => openSpellFromBlock(word, e)} > {spellRevealed.has(word.id) ? ( diff --git a/web/src/pages/PreTrainingCheck.tsx b/web/src/pages/PreTrainingCheck.tsx index 40e717d..58d8598 100644 --- a/web/src/pages/PreTrainingCheck.tsx +++ b/web/src/pages/PreTrainingCheck.tsx @@ -52,7 +52,7 @@ export default function PreTrainingCheck() { const role = useAuthStore((s) => s.user?.role) || "user"; const isCoach = role === "user" || role === "admin" || role === "teacher"; const [words, setWords] = useState([]); - const [selectedCount, setSelectedCount] = useState(0); + const wrongCount = useMemo(() => words.filter((w) => w.status === "wrong").length, [words]); const [currentPage, setCurrentPage] = useState(1); const [hasMore, setHasMore] = useState(true); const [loading, setLoading] = useState(false); @@ -235,16 +235,7 @@ export default function PreTrainingCheck() { setWords((prev) => prev.map((word) => { if (word.id === id) { - const wasSelected = word.status !== null; const nextStatus = word.status === newStatus ? null : newStatus; - const isNowSelected = nextStatus !== null; - - if (!wasSelected && isNowSelected) { - setSelectedCount((s) => s + 1); - } else if (wasSelected && !isNowSelected) { - setSelectedCount((s) => s - 1); - } - return { ...word, status: nextStatus }; } return word; @@ -287,7 +278,6 @@ export default function PreTrainingCheck() { shuffleModeRef.current = true; shuffleSeedRef.current = seed; setShuffleMode(true); - setSelectedCount(0); setCardIndex(0); pageRef.current = 1; hasMoreRef.current = true; @@ -303,7 +293,6 @@ export default function PreTrainingCheck() { shuffleModeRef.current = false; shuffleSeedRef.current = 0; setShuffleMode(false); - setSelectedCount(0); setCardIndex(0); pageRef.current = 1; hasMoreRef.current = true; @@ -317,10 +306,8 @@ export default function PreTrainingCheck() { setWords((prev) => { const allSelected = prev.every((word) => word.status !== null); if (allSelected) { - setSelectedCount(0); return prev.map((word) => ({ ...word, status: null as WordItem["status"] })); } - setSelectedCount(prev.length); return prev.map((word) => ({ ...word, status: "wrong" as WordItem["status"] })); }); }, []); @@ -329,14 +316,12 @@ export default function PreTrainingCheck() { setWords((prev) => { const unselected = prev.filter((word) => word.status === null); const toSelect = unselected.slice(0, 5); - const newWords = prev.map((word) => { + return prev.map((word) => { if (toSelect.find((w) => w.id === word.id)) { return { ...word, status: "wrong" as WordItem["status"] }; } return word; }); - setSelectedCount(newWords.filter((w) => w.status !== null).length); - return newWords; }); }, []); @@ -503,7 +488,7 @@ export default function PreTrainingCheck() { ); return ( - + setAnnotationOpen((v) => !v)} - wordCount={selectedCount} + wordCount={wrongCount} onWordPatched={(view) => setWords((prev) => applyUserWordView(prev, view))} /> } @@ -693,17 +678,17 @@ export default function PreTrainingCheck() { size="pill" className="shrink-0 max-sm:px-2.5 max-sm:text-xs tabular-nums" onClick={handleStartLearning} - disabled={selectedCount === 0} + disabled={wrongCount === 0} loading={starting} loadingText={t("practice.starting")} aria-label={ - selectedCount > 0 - ? t("practice.start_learning_count", { count: selectedCount }) + wrongCount > 0 + ? t("practice.start_learning_count", { count: wrongCount }) : t("practice.start_learning") } > - {selectedCount > 0 - ? t("practice.start_learning_count", { count: selectedCount }) + {wrongCount > 0 + ? t("practice.start_learning_count", { count: wrongCount }) : t("practice.start_learning")}
diff --git a/web/src/pages/ReviewWordList.tsx b/web/src/pages/ReviewWordList.tsx index e205d00..7a40aee 100644 --- a/web/src/pages/ReviewWordList.tsx +++ b/web/src/pages/ReviewWordList.tsx @@ -319,7 +319,7 @@ export default function ReviewWordList() { const wrongCount = words.filter((word) => word.status === "wrong").length; return ( - + {/* Split container: word content + note panel on the same layer. */} -
+
{/* Word content pane */}
@@ -524,8 +524,8 @@ export default function ReviewWordList() { onSideChange={setNoteSide} /> )} -
-
+
+
{!viewOnly && ( <> @@ -615,7 +615,7 @@ export default function ReviewWordList() { size="iconRound" onClick={handleSubmit} disabled={!allMarked || submitting} - className="fixed right-3 bottom-16 z-50 size-11 shadow-lg sm:hidden" + className="fixed right-3 bottom-[calc(6.5rem+env(safe-area-inset-bottom,0px))] z-50 size-11 shadow-lg sm:hidden" aria-label={t("practice.submit_review")} > diff --git a/web/src/stores/timetableStore.ts b/web/src/stores/timetableStore.ts new file mode 100644 index 0000000..ebb578c --- /dev/null +++ b/web/src/stores/timetableStore.ts @@ -0,0 +1,142 @@ +/** + * 学习通式课程课表 — 全局状态 + * + * 不使用 zustand persist:持久化由 `timetableRepo`(localStorage)负责, + * 这样未来切到后端 HTTP 实现时,store 逻辑完全不变。 + * 页面挂载时调用 `load()` 从仓储拉取数据。 + */ +import { create } from "zustand"; +import { + timetableRepo, + genCourseId, + COURSE_COLORS, + type Course, + type TimetableConfig, + type WeekType, +} from "../api/timetable"; +import { validateCourse } from "../utils/timetableFilter"; + +interface TimetableState { + courses: Course[]; + config: TimetableConfig; + /** 当前查看的周(1-based),独立于 config.currentWeek,便于临时切换 */ + viewWeek: number; + loaded: boolean; + loading: boolean; + + load: () => Promise; + setCurrentWeek: (week: number) => Promise; + setViewWeek: (week: number) => void; + prevWeek: () => void; + nextWeek: () => void; + updateConfig: (patch: Partial) => Promise; + + addCourse: (input: Omit) => Promise<{ ok: boolean; error?: string }>; + updateCourse: (id: string, input: Omit) => Promise<{ ok: boolean; error?: string }>; + removeCourse: (id: string) => Promise; +} + +function clampWeek(week: number, total: number): number { + return Math.max(1, Math.min(total, Math.round(week))); +} + +/** 新建课程时的默认值 */ +export function blankCourseInput(config: TimetableConfig): Omit { + return { + name: "", + teacher: "", + room: "", + weekDay: 1, + startSection: 1, + endSection: 2, + startWeek: 1, + endWeek: config.totalWeek, + weekType: 0 as WeekType, + color: COURSE_COLORS[0], + }; +} + +export const useTimetableStore = create((set, get) => ({ + courses: [], + config: { currentWeek: 1, totalWeek: 20, sections: [] }, + viewWeek: 1, + loaded: false, + loading: false, + + load: async () => { + if (get().loading) return; + set({ loading: true }); + const [courses, config] = await Promise.all([ + timetableRepo.listCourses(), + timetableRepo.getConfig(), + ]); + set({ + courses, + config, + viewWeek: clampWeek(config.currentWeek, config.totalWeek), + loaded: true, + loading: false, + }); + }, + + setCurrentWeek: async (week) => { + const { config } = get(); + const next = clampWeek(week, config.totalWeek); + const newConfig = { ...config, currentWeek: next }; + await timetableRepo.saveConfig(newConfig); + set({ config: newConfig, viewWeek: next }); + }, + + setViewWeek: (week) => { + const { config } = get(); + set({ viewWeek: clampWeek(week, config.totalWeek) }); + }, + + prevWeek: () => { + const { viewWeek } = get(); + set({ viewWeek: Math.max(1, viewWeek - 1) }); + }, + + nextWeek: () => { + const { viewWeek, config } = get(); + set({ viewWeek: Math.min(config.totalWeek, viewWeek + 1) }); + }, + + updateConfig: async (patch) => { + const { config } = get(); + const newConfig = { ...config, ...patch }; + if (patch.totalWeek != null) { + newConfig.currentWeek = clampWeek(newConfig.currentWeek, newConfig.totalWeek); + } + await timetableRepo.saveConfig(newConfig); + set({ + config: newConfig, + viewWeek: clampWeek(get().viewWeek, newConfig.totalWeek), + }); + }, + + addCourse: async (input) => { + const { config } = get(); + const err = validateCourse(input, config); + if (err) return { ok: false, error: err }; + const course: Course = { ...input, id: genCourseId() }; + await timetableRepo.saveCourse(course); + set({ courses: [...get().courses, course] }); + return { ok: true }; + }, + + updateCourse: async (id, input) => { + const { config } = get(); + const err = validateCourse(input, config); + if (err) return { ok: false, error: err }; + const course: Course = { ...input, id }; + await timetableRepo.saveCourse(course); + set({ courses: get().courses.map((c) => (c.id === id ? course : c)) }); + return { ok: true }; + }, + + removeCourse: async (id) => { + await timetableRepo.deleteCourse(id); + set({ courses: get().courses.filter((c) => c.id !== id) }); + }, +})); diff --git a/web/src/utils/coachingSectionMap.ts b/web/src/utils/coachingSectionMap.ts new file mode 100644 index 0000000..78e3d82 --- /dev/null +++ b/web/src/utils/coachingSectionMap.ts @@ -0,0 +1,58 @@ +/** + * 后端教练预约 → 节次网格映射 + * + * 后端预约用精确时间("08:00"-"09:40"),节次网格用节次序号(1-12)。 + * 这里按 DEFAULT_SECTIONS 时间表,把预约的 startTime/endTime 映射到 + * 起始节次 / 结束节次,让两种数据能在同一个节次网格里合并渲染。 + */ +import { DEFAULT_SECTIONS, type Section } from "../api/timetable"; + +function hmToMinutes(t: string): number { + const m = /^(\d{1,2}):(\d{2})$/.exec((t || "").trim().slice(0, 5)); + if (!m) return -1; + return Number(m[1]) * 60 + Number(m[2]); +} + +/** + * 找到时间点落在哪个节次(按节次开始时间判断)。 + * 早于第1节开始 → 1;晚于最后节次结束 → 最后一节。 + */ +function minuteToSection(min: number, sections: Section[]): number { + if (min < 0) return 1; + for (let i = 0; i < sections.length; i++) { + if (min < hmToMinutes(sections[i].start)) { + return Math.max(1, i + 1 - 1) || 1; + } + } + return sections.length; +} + +/** + * 把预约的 startTime/endTime 映射到 { startSection, endSection }。 + * endSection 包含在内(与 Course.endSection 语义一致)。 + * + * 例:08:00-09:40,节次表 [1]08:00-08:45 [2]08:55-09:40 → {1, 2} + */ +export function timeToSections( + startTime: string, + endTime: string, + sections: Section[] = DEFAULT_SECTIONS, +): { startSection: number; endSection: number } { + const s = hmToMinutes(startTime); + const e = hmToMinutes(endTime); + if (s < 0) return { startSection: 1, endSection: 1 }; + const startSection = minuteToSection(s, sections); + if (e < 0 || e <= s) return { startSection, endSection: startSection }; + // endSection:endTime 落在哪个节次的结束侧 + let endSection = startSection; + for (let i = 0; i < sections.length; i++) { + const secEnd = hmToMinutes(sections[i].end); + if (e <= secEnd) { + endSection = i + 1; + break; + } + endSection = i + 1; + } + if (endSection < startSection) endSection = startSection; + return { startSection, endSection }; +} diff --git a/web/src/utils/timetableFilter.ts b/web/src/utils/timetableFilter.ts new file mode 100644 index 0000000..56f7a2d --- /dev/null +++ b/web/src/utils/timetableFilter.ts @@ -0,0 +1,75 @@ +/** + * 学习通式课表 — 核心过滤逻辑 + * + * 课表灵魂:不是所有课程每周都出现,要按「起止周 + 单双周」判断当前周是否显示。 + */ +import type { Course, TimetableConfig, WeekType } from "../api/timetable"; + +/** + * 判断课程在指定周是否显示。 + * @param course 课程 + * @param weekNo 第几周(1-based) + */ +export function isCourseShow(course: Course, weekNo: number): boolean { + // 不在起止周范围内直接隐藏 + if (weekNo < course.startWeek || weekNo > course.endWeek) { + return false; + } + // 单双周过滤 + if (course.weekType === 1) { + // 单周 + return weekNo % 2 === 1; + } + if (course.weekType === 2) { + // 双周 + return weekNo % 2 === 0; + } + // 0 = 每周 + return true; +} + +/** 单双周文案 */ +export function weekTypeLabel(weekType: WeekType): string { + if (weekType === 1) return "single"; // 单周 + if (weekType === 2) return "double"; // 双周 + return "all"; // 每周 +} + +/** 周范围文案,如 "1-16周" / "1-16周(单)" */ +export function weekRangeLabel(course: Course): string { + const base = `${course.startWeek}-${course.endWeek}周`; + if (course.weekType === 1) return `${base}(单)`; + if (course.weekType === 2) return `${base}(双)`; + return base; +} + +/** 节次范围文案,如 "1-2节" */ +export function sectionRangeLabel(course: Course): string { + return `${course.startSection}-${course.endSection}节`; +} + +/** 把星期 1~7 转成中文 */ +export function weekDayLabel(weekDay: number): string { + return ["一", "二", "三", "四", "五", "六", "日"][weekDay - 1] ?? String(weekDay); +} + +/** 课程在某周是否「正在进行」(用于高亮当前节次,可选) */ +export function isCurrentWeek(course: Course, config: TimetableConfig): boolean { + return isCourseShow(course, config.currentWeek); +} + +/** + * 校验课程数据合法性,返回错误信息(首个错误),合法返回 null。 + * 防止节次/周次倒置、星期越界等常见坑。 + */ +export function validateCourse(course: Omit, config: TimetableConfig): string | null { + if (!course.name.trim()) return "课程名不能为空"; + if (course.weekDay < 1 || course.weekDay > 7) return "星期必须在 1~7 之间"; + if (course.startSection < 1) return "开始节次不能小于 1"; + if (course.endSection < course.startSection) return "结束节次不能小于开始节次"; + if (course.endSection > config.sections.length) return `结束节次不能超过 ${config.sections.length}`; + if (course.startWeek < 1) return "起始周不能小于 1"; + if (course.endWeek < course.startWeek) return "结束周不能小于起始周"; + if (course.endWeek > config.totalWeek) return `结束周不能超过总周数 ${config.totalWeek}`; + return null; +}