From 1f5390e727dc847d7a0999ca8cacaf1eb069bf1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Thu, 3 Sep 2026 10:46:21 +0800 Subject: [PATCH 01/27] =?UTF-8?q?feat(web):=20=E6=96=B0=E5=A2=9E=E5=AD=A6?= =?UTF-8?q?=E4=B9=A0=E9=80=9A=E5=BC=8F=E8=AF=BE=E7=A8=8B=E8=AF=BE=E8=A1=A8?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按节次网格 + 单双周 + 起止周过滤实现仿学习通课表: - CSS Grid 节次网格,课程色块跨行渲染 - 周切换、回到本周、点击空白格快速新增 - 课程增删改查弹窗,含校验 - localStorage 持久化,仓储抽象层预留后端 HTTP 实现 - 导出 Excel(xlsx 动态导入拆 chunk)、打印 - 中英 i18n、侧栏/底栏导航入口 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- web/src/api/timetable.ts | 157 ++++++++++ web/src/components/Layout.tsx | 2 + .../timetable/CourseEditorDialog.tsx | 292 ++++++++++++++++++ .../components/timetable/CourseTimetable.tsx | 150 +++++++++ .../components/timetable/TimetableToolbar.tsx | 116 +++++++ web/src/i18n/locales/en.json | 49 ++- web/src/i18n/locales/zh-CN.json | 49 ++- web/src/pages/Timetable.tsx | 101 ++++++ web/src/router/routes.tsx | 2 + web/src/stores/timetableStore.ts | 142 +++++++++ web/src/utils/timetableFilter.ts | 75 +++++ 11 files changed, 1133 insertions(+), 2 deletions(-) create mode 100644 web/src/api/timetable.ts create mode 100644 web/src/components/timetable/CourseEditorDialog.tsx create mode 100644 web/src/components/timetable/CourseTimetable.tsx create mode 100644 web/src/components/timetable/TimetableToolbar.tsx create mode 100644 web/src/pages/Timetable.tsx create mode 100644 web/src/stores/timetableStore.ts create mode 100644 web/src/utils/timetableFilter.ts 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/Layout.tsx b/web/src/components/Layout.tsx index 7afdef3..b0fbe48 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -9,6 +9,7 @@ import { BookOpen, Menu, X, + CalendarDays, } from "lucide-react"; import { Header } from "./header"; import { NavMenu } from "./NavMenu"; @@ -23,6 +24,7 @@ import { teacherAvatarSrc } from "../utils/avatar"; const navItemDefs = [ { path: "/", labelKey: "nav.home", icon: Home }, { path: "/lesson-prep", labelKey: "nav.lesson_prep", icon: BookOpen }, + { path: "/timetable", labelKey: "nav.timetable", icon: CalendarDays }, { path: "/anti-forgetting", labelKey: "nav.anti_forgetting", icon: RefreshCw }, { path: "/coach-center", labelKey: "nav.coach_center", icon: Users }, ] as const; diff --git a/web/src/components/timetable/CourseEditorDialog.tsx b/web/src/components/timetable/CourseEditorDialog.tsx new file mode 100644 index 0000000..0170957 --- /dev/null +++ b/web/src/components/timetable/CourseEditorDialog.tsx @@ -0,0 +1,292 @@ +/** + * 学习通式课表 — 课程新增/编辑弹窗 + */ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Trash2 } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../ui/dialog"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { COURSE_COLORS, type Course, type TimetableConfig, type WeekType } from "../../api/timetable"; +import { blankCourseInput, useTimetableStore } from "../../stores/timetableStore"; +import { showToast } from "../../utils/toast"; + +interface CourseEditorDialogProps { + open: boolean; + onOpenChange: (open: boolean) => 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 6b06fcd..278a004 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1980,5 +1980,52 @@ "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", + "nav.timetable": "Timetable", + "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 954815f..7ed69b0 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -1980,5 +1980,52 @@ "lighthouse.load_failed": "加载灯塔数据失败", "review.empty_session": "复习会话或单词为空", "auth.fetch_user_failed": "获取用户信息失败", - "notification.invalid_id": "无效的通知 ID" + "notification.invalid_id": "无效的通知 ID", + "nav.timetable": "课表", + "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/Timetable.tsx b/web/src/pages/Timetable.tsx new file mode 100644 index 0000000..7155539 --- /dev/null +++ b/web/src/pages/Timetable.tsx @@ -0,0 +1,101 @@ +/** + * 学习通式课程课表页面 + * + * 功能:周视图节次网格、课程色块、单双周/起止周过滤、周切换、 + * 课程增删改查、Excel 导出、打印。数据 localStorage 持久化。 + */ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { CalendarDays } from "lucide-react"; +import { PageTitle } from "../components/PageTitle"; +import { CourseTimetable } from "../components/timetable/CourseTimetable"; +import { TimetableToolbar } from "../components/timetable/TimetableToolbar"; +import { CourseEditorDialog } from "../components/timetable/CourseEditorDialog"; +import { CloudSpin } from "../components/cloudsteps/arco"; +import { useTimetableStore } from "../stores/timetableStore"; +import { isCourseShow } from "../utils/timetableFilter"; +import type { Course } from "../api/timetable"; + +export default function Timetable() { + const { t } = useTranslation(); + const load = useTimetableStore((s) => s.load); + const loaded = useTimetableStore((s) => s.loaded); + const loading = useTimetableStore((s) => s.loading); + const courses = useTimetableStore((s) => s.courses); + const config = useTimetableStore((s) => s.config); + const viewWeek = useTimetableStore((s) => s.viewWeek); + + const [editorOpen, setEditorOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [preset, setPreset] = useState<{ weekDay: number; startSection: number } | null>(null); + + useEffect(() => { + if (!loaded) load(); + }, [loaded, load]); + + const visibleCount = useMemo( + () => courses.filter((c) => isCourseShow(c, viewWeek)).length, + [courses, viewWeek], + ); + + function openAdd() { + setEditing(null); + setPreset(null); + setEditorOpen(true); + } + + function openEdit(course: Course) { + setEditing(course); + setPreset(null); + setEditorOpen(true); + } + + function pickSlot(weekDay: number, section: number) { + setEditing(null); + setPreset({ weekDay, startSection: section }); + setEditorOpen(true); + } + + if (!loaded || loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+ + + + {t("timetable.title")} + + +
+ + + +
+ {t("timetable.week_summary", { count: visibleCount })} +
+ + + + +
+ ); +} diff --git a/web/src/router/routes.tsx b/web/src/router/routes.tsx index 655547b..8f44b59 100644 --- a/web/src/router/routes.tsx +++ b/web/src/router/routes.tsx @@ -52,6 +52,7 @@ import WordBookShelf from "../pages/WordBookShelf"; import CreateCustomWordBook from "../pages/CreateCustomWordBook"; import LighthouseWords from "../pages/LighthouseWords"; import TrainingRecords from "../pages/TrainingRecords"; +import Timetable from "../pages/Timetable"; export const router = createBrowserRouter( [ @@ -102,6 +103,7 @@ export const router = createBrowserRouter( { path: "word-books/custom/new", element: }, { path: "word-books/:id", element: }, { path: "training-records", element: }, + { path: "timetable", element: }, { path: "anti-forgetting", element: }, { path: "coach-center", element: }, { 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/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; +} From 4cd0b7d98f2b9e2e436087428003c9b325e96c78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Thu, 3 Sep 2026 11:32:20 +0800 Subject: [PATCH 02/27] =?UTF-8?q?refactor(web):=20=E5=A4=87=E8=AF=BE?= =?UTF-8?q?=E9=A1=B5=E6=8E=92=E8=AF=BE=E6=9B=BF=E6=8D=A2=E4=B8=BA=E5=AD=A6?= =?UTF-8?q?=E4=B9=A0=E9=80=9A=E5=BC=8F=E8=AF=BE=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 /lesson-prep(备课)原 CoachingSchedulePanel 时间轴排课替换为 节次网格课表,撤掉上一提交新增的独立 /timetable 路由与导航项, 避免重复入口。备课导航即课表入口。 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- web/src/components/Layout.tsx | 2 - web/src/i18n/locales/en.json | 1 - web/src/i18n/locales/zh-CN.json | 1 - web/src/pages/Home.tsx | 100 ++++++++++++++++++++++++++----- web/src/pages/Timetable.tsx | 101 -------------------------------- web/src/router/routes.tsx | 2 - 6 files changed, 86 insertions(+), 121 deletions(-) delete mode 100644 web/src/pages/Timetable.tsx diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index b0fbe48..7afdef3 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -9,7 +9,6 @@ import { BookOpen, Menu, X, - CalendarDays, } from "lucide-react"; import { Header } from "./header"; import { NavMenu } from "./NavMenu"; @@ -24,7 +23,6 @@ import { teacherAvatarSrc } from "../utils/avatar"; const navItemDefs = [ { path: "/", labelKey: "nav.home", icon: Home }, { path: "/lesson-prep", labelKey: "nav.lesson_prep", icon: BookOpen }, - { path: "/timetable", labelKey: "nav.timetable", icon: CalendarDays }, { path: "/anti-forgetting", labelKey: "nav.anti_forgetting", icon: RefreshCw }, { path: "/coach-center", labelKey: "nav.coach_center", icon: Users }, ] as const; diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 278a004..f9062a1 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1981,7 +1981,6 @@ "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", - "nav.timetable": "Timetable", "timetable.title": "My Timetable", "timetable.subtitle": "Section-grid timetable · supports odd/even weeks and week ranges", "timetable.section": "Section", diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index 7ed69b0..8838cb3 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -1981,7 +1981,6 @@ "review.empty_session": "复习会话或单词为空", "auth.fetch_user_failed": "获取用户信息失败", "notification.invalid_id": "无效的通知 ID", - "nav.timetable": "课表", "timetable.title": "我的课表", "timetable.subtitle": "节次网格课表 · 支持单双周与起止周", "timetable.section": "节次", diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index 8e1d06e..d3c1699 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -1,27 +1,99 @@ -import { useEffect, useState } from "react"; -import { CoachingSchedulePanel } from "../components/CoachingSchedulePanel"; -import { useAuthStore } from "../stores/authStore"; +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { CalendarDays } from "lucide-react"; +import { CourseTimetable } from "../components/timetable/CourseTimetable"; +import { TimetableToolbar } from "../components/timetable/TimetableToolbar"; +import { CourseEditorDialog } from "../components/timetable/CourseEditorDialog"; +import { CloudSpin } from "../components/cloudsteps/arco"; +import { useTimetableStore } from "../stores/timetableStore"; +import { isCourseShow } from "../utils/timetableFilter"; +import type { Course } from "../api/timetable"; /** - * 备课页 `/lesson-prep` — 周课表(点天排课 + 时间轴展示)。 - * 教练可排课/上下课;学员只读自己的课表。 + * 备课页 `/lesson-prep` — 学习通式节次网格课表。 + * 支持周切换、单双周/起止周过滤、课程增删改查、Excel 导出、打印。 + * 数据 localStorage 持久化,仓储抽象层预留后端 HTTP 实现。 */ export default function Home() { - const user = useAuthStore((s) => s.user); - const [nowTs, setNowTs] = useState(() => Date.now()); + const { t } = useTranslation(); + const load = useTimetableStore((s) => s.load); + const loaded = useTimetableStore((s) => s.loaded); + const loading = useTimetableStore((s) => s.loading); + const courses = useTimetableStore((s) => s.courses); + const config = useTimetableStore((s) => s.config); + const viewWeek = useTimetableStore((s) => s.viewWeek); - const role = (user as { role?: string } | null)?.role || "user"; - const isStudent = role === "student"; - const mode = isStudent ? "student" : "coach"; + const [editorOpen, setEditorOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [preset, setPreset] = useState<{ weekDay: number; startSection: number } | null>(null); useEffect(() => { - const t = window.setInterval(() => setNowTs(Date.now()), 60_000); - return () => window.clearInterval(t); - }, []); + if (!loaded) load(); + }, [loaded, load]); + + const visibleCount = useMemo( + () => courses.filter((c) => isCourseShow(c, viewWeek)).length, + [courses, viewWeek], + ); + + function openAdd() { + setEditing(null); + setPreset(null); + setEditorOpen(true); + } + + function openEdit(course: Course) { + setEditing(course); + setPreset(null); + setEditorOpen(true); + } + + function pickSlot(weekDay: number, section: number) { + setEditing(null); + setPreset({ weekDay, startSection: section }); + setEditorOpen(true); + } + + if (!loaded || loading) { + return ( +
+ +
+ ); + } return (
- + {/* 顶部标题 + 工具栏 */} +
+
+ + {t("timetable.title")} +
+ +
+ {t("timetable.week_summary", { count: visibleCount })} +
+
+ + {/* 网格主体:可滚动 */} +
+ +
+ +
); } diff --git a/web/src/pages/Timetable.tsx b/web/src/pages/Timetable.tsx deleted file mode 100644 index 7155539..0000000 --- a/web/src/pages/Timetable.tsx +++ /dev/null @@ -1,101 +0,0 @@ -/** - * 学习通式课程课表页面 - * - * 功能:周视图节次网格、课程色块、单双周/起止周过滤、周切换、 - * 课程增删改查、Excel 导出、打印。数据 localStorage 持久化。 - */ -import { useEffect, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { CalendarDays } from "lucide-react"; -import { PageTitle } from "../components/PageTitle"; -import { CourseTimetable } from "../components/timetable/CourseTimetable"; -import { TimetableToolbar } from "../components/timetable/TimetableToolbar"; -import { CourseEditorDialog } from "../components/timetable/CourseEditorDialog"; -import { CloudSpin } from "../components/cloudsteps/arco"; -import { useTimetableStore } from "../stores/timetableStore"; -import { isCourseShow } from "../utils/timetableFilter"; -import type { Course } from "../api/timetable"; - -export default function Timetable() { - const { t } = useTranslation(); - const load = useTimetableStore((s) => s.load); - const loaded = useTimetableStore((s) => s.loaded); - const loading = useTimetableStore((s) => s.loading); - const courses = useTimetableStore((s) => s.courses); - const config = useTimetableStore((s) => s.config); - const viewWeek = useTimetableStore((s) => s.viewWeek); - - const [editorOpen, setEditorOpen] = useState(false); - const [editing, setEditing] = useState(null); - const [preset, setPreset] = useState<{ weekDay: number; startSection: number } | null>(null); - - useEffect(() => { - if (!loaded) load(); - }, [loaded, load]); - - const visibleCount = useMemo( - () => courses.filter((c) => isCourseShow(c, viewWeek)).length, - [courses, viewWeek], - ); - - function openAdd() { - setEditing(null); - setPreset(null); - setEditorOpen(true); - } - - function openEdit(course: Course) { - setEditing(course); - setPreset(null); - setEditorOpen(true); - } - - function pickSlot(weekDay: number, section: number) { - setEditing(null); - setPreset({ weekDay, startSection: section }); - setEditorOpen(true); - } - - if (!loaded || loading) { - return ( -
- -
- ); - } - - return ( -
-
- - - - {t("timetable.title")} - - -
- - - -
- {t("timetable.week_summary", { count: visibleCount })} -
- - - - -
- ); -} diff --git a/web/src/router/routes.tsx b/web/src/router/routes.tsx index 8f44b59..655547b 100644 --- a/web/src/router/routes.tsx +++ b/web/src/router/routes.tsx @@ -52,7 +52,6 @@ import WordBookShelf from "../pages/WordBookShelf"; import CreateCustomWordBook from "../pages/CreateCustomWordBook"; import LighthouseWords from "../pages/LighthouseWords"; import TrainingRecords from "../pages/TrainingRecords"; -import Timetable from "../pages/Timetable"; export const router = createBrowserRouter( [ @@ -103,7 +102,6 @@ export const router = createBrowserRouter( { path: "word-books/custom/new", element: }, { path: "word-books/:id", element: }, { path: "training-records", element: }, - { path: "timetable", element: }, { path: "anti-forgetting", element: }, { path: "coach-center", element: }, { From 87cb0529be3a74898d407d4bddfc6fa38c3a83b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Thu, 3 Sep 2026 13:11:09 +0800 Subject: [PATCH 03/27] =?UTF-8?q?refactor(web):=20=E5=A4=87=E8=AF=BE?= =?UTF-8?q?=E9=A1=B5=E8=AF=BE=E8=A1=A8=E6=94=B9=E4=B8=BA=E8=8A=82=E6=AC=A1?= =?UTF-8?q?=E7=BD=91=E6=A0=BC=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 CoachingSchedulePanel 的时间轴周历改为学习通式节次网格: - 左侧节次 1-12,课程/预约色块跨节渲染(CSS Grid) - 后端教练预约按 startTime/endTime 自动映射到节次 - localStorage 自定义课程与后端预约合并显示在同一网格 - 点后端预约块走原弹窗(上下课/删除),点自定义课程块走编辑弹窗 - 点空白格新增自定义课程,点表头仍跳转创建排课 - 周切换保持真实日历周,保留全部排课/上下课功能 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- web/src/components/CoachingSchedulePanel.tsx | 486 ++++++++++--------- web/src/pages/Home.tsx | 100 +--- web/src/utils/coachingSectionMap.ts | 58 +++ 3 files changed, 326 insertions(+), 318 deletions(-) create mode 100644 web/src/utils/coachingSectionMap.ts diff --git a/web/src/components/CoachingSchedulePanel.tsx b/web/src/components/CoachingSchedulePanel.tsx index 29b8073..1c2eb0f 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) => @@ -343,6 +352,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); @@ -446,7 +464,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 +677,64 @@ 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 soft = past ? PAST_SOFT : STATUS_SOFT[s.status] || STATUS_SOFT.scheduled; + const { title, subtitle } = lessonDisplay(t, s); + items.push({ + key: `co-${s.id}`, + kind: "coaching", + weekDay: dayIdx + 1, + startSection, + endSection, + color: soft.bar.replace("bg-", "").startsWith("muted") ? "#7A8A99" : "#4ECDC4", + title, + subtitle, + meta: `${s.startTime?.slice(0, 5)}-${s.endTime?.slice(0, 5)}`, + 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), + course: c, + }); + } + + return items; + }, [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); + } + }} + />
)}
@@ -1159,6 +1032,155 @@ export function CoachingSchedulePanel({ nowTs, mode = "coach" }: Props) {
)} + + +
+ ); +} + +// ===== 节次网格子组件 ===== + +type GridItem = { + key: string; + kind: "coaching" | "course"; + weekDay: number; + startSection: number; + endSection: number; + color: string; + title: string; + subtitle?: string; + meta?: string; + 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 totalRows = sections.length; + const gridStyle = { + gridTemplateColumns: `48px repeat(7, minmax(0, 1fr))`, + gridTemplateRows: `40px repeat(${totalRows}, 60px)`, + } 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.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/pages/Home.tsx b/web/src/pages/Home.tsx index d3c1699..9952c82 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -1,99 +1,27 @@ -import { useEffect, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { CalendarDays } from "lucide-react"; -import { CourseTimetable } from "../components/timetable/CourseTimetable"; -import { TimetableToolbar } from "../components/timetable/TimetableToolbar"; -import { CourseEditorDialog } from "../components/timetable/CourseEditorDialog"; -import { CloudSpin } from "../components/cloudsteps/arco"; -import { useTimetableStore } from "../stores/timetableStore"; -import { isCourseShow } from "../utils/timetableFilter"; -import type { Course } from "../api/timetable"; +import { useEffect, useState } from "react"; +import { CoachingSchedulePanel } from "../components/CoachingSchedulePanel"; +import { useAuthStore } from "../stores/authStore"; /** - * 备课页 `/lesson-prep` — 学习通式节次网格课表。 - * 支持周切换、单双周/起止周过滤、课程增删改查、Excel 导出、打印。 - * 数据 localStorage 持久化,仓储抽象层预留后端 HTTP 实现。 + * 备课页 `/lesson-prep` — 周课表(节次网格展示 + 排课/上下课 + 自定义课程)。 + * 教练可排课/上下课/删除排课,并可新增自定义课程;学员只读自己的课表。 */ export default function Home() { - const { t } = useTranslation(); - const load = useTimetableStore((s) => s.load); - const loaded = useTimetableStore((s) => s.loaded); - const loading = useTimetableStore((s) => s.loading); - const courses = useTimetableStore((s) => s.courses); - const config = useTimetableStore((s) => s.config); - const viewWeek = useTimetableStore((s) => s.viewWeek); + const user = useAuthStore((s) => s.user); + const [nowTs, setNowTs] = useState(() => Date.now()); - const [editorOpen, setEditorOpen] = useState(false); - const [editing, setEditing] = useState(null); - const [preset, setPreset] = useState<{ weekDay: number; startSection: number } | null>(null); + const role = (user as { role?: string } | null)?.role || "user"; + const isStudent = role === "student"; + const mode = isStudent ? "student" : "coach"; useEffect(() => { - if (!loaded) load(); - }, [loaded, load]); - - const visibleCount = useMemo( - () => courses.filter((c) => isCourseShow(c, viewWeek)).length, - [courses, viewWeek], - ); - - function openAdd() { - setEditing(null); - setPreset(null); - setEditorOpen(true); - } - - function openEdit(course: Course) { - setEditing(course); - setPreset(null); - setEditorOpen(true); - } - - function pickSlot(weekDay: number, section: number) { - setEditing(null); - setPreset({ weekDay, startSection: section }); - setEditorOpen(true); - } - - if (!loaded || loading) { - return ( -
- -
- ); - } + const t = window.setInterval(() => setNowTs(Date.now()), 60_000); + return () => window.clearInterval(t); + }, []); return (
- {/* 顶部标题 + 工具栏 */} -
-
- - {t("timetable.title")} -
- -
- {t("timetable.week_summary", { count: visibleCount })} -
-
- - {/* 网格主体:可滚动 */} -
- -
- - +
); } 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 }; +} From 19ce9eadede71aab4fadede295a0f2253716476a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Thu, 3 Sep 2026 13:43:48 +0800 Subject: [PATCH 04/27] =?UTF-8?q?fix(web):=20=E4=BF=AE=E5=A4=8D=E5=A4=87?= =?UTF-8?q?=E8=AF=BE=E8=AF=BE=E8=A1=A8=E8=8A=82=E6=AC=A1=E7=BD=91=E6=A0=BC?= =?UTF-8?q?=E9=94=99=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为表头、节次标签和空白单元格指定显式网格行列,避免课程色块占位后触发 CSS Grid 自动布局,导致第 4 节及之后的节次标签跑到其他星期列。同步使用明确的预约状态颜色映射。 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- web/src/components/CoachingSchedulePanel.tsx | 23 ++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/web/src/components/CoachingSchedulePanel.tsx b/web/src/components/CoachingSchedulePanel.tsx index 1c2eb0f..fd2c943 100644 --- a/web/src/components/CoachingSchedulePanel.tsx +++ b/web/src/components/CoachingSchedulePanel.tsx @@ -693,7 +693,14 @@ export function CoachingSchedulePanel({ nowTs, mode = "coach" }: Props) { if (dayIdx < 0) continue; const { startSection, endSection } = timeToSections(s.startTime, s.endTime, sections); const past = isSchedulePast(s, nowTs); - const soft = past ? PAST_SOFT : STATUS_SOFT[s.status] || STATUS_SOFT.scheduled; + const statusColor = + past || s.status === "completed" + ? "#7A8A99" + : s.status === "in_progress" + ? "#55A3FF" + : s.status === "cancelled" + ? "#E85555" + : "#4ECDC4"; const { title, subtitle } = lessonDisplay(t, s); items.push({ key: `co-${s.id}`, @@ -701,7 +708,7 @@ export function CoachingSchedulePanel({ nowTs, mode = "coach" }: Props) { weekDay: dayIdx + 1, startSection, endSection, - color: soft.bar.replace("bg-", "").startsWith("muted") ? "#7A8A99" : "#4ECDC4", + color: statusColor, title, subtitle, meta: `${s.startTime?.slice(0, 5)}-${s.endTime?.slice(0, 5)}`, @@ -1096,7 +1103,10 @@ function SectionGrid({ return (
{/* 左上角 */} -
+
{t("timetable.section")}
{/* 表头:7 天 */} @@ -1112,6 +1122,7 @@ function SectionGrid({ className={`flex flex-col items-center justify-center border-b border-border px-0.5 text-center touch-manipulation ${ isToday ? "bg-primary-soft/70" : "bg-surface-soft" } ${isCoach ? "active:bg-primary/10" : ""} ${i < 6 ? "border-r border-border/40" : ""}`} + style={{ gridColumn: i + 2, gridRow: 1 }} > {t("coaching.weekday_prefix", { day: t(`coaching.weekday.${i}`) })} @@ -1126,7 +1137,10 @@ function SectionGrid({ {/* 节次标签 + 空白格 */} {sections.map((sec) => (
-
+
{sec.no} {sec.start} {sec.end} @@ -1137,6 +1151,7 @@ function SectionGrid({ type="button" onClick={() => onCellClick(dayIdx + 1, sec.no)} className={`border-b border-border ${dayIdx < 6 ? "border-r" : ""} hover:bg-accent/40 transition-colors`} + style={{ gridColumn: dayIdx + 2, gridRow: sec.no + 1 }} aria-label={`${t(`coaching.weekday.${dayIdx}`)} ${sec.no}`} /> ))} From bd973cd25ab790cea2a95e2af034e7c1825d9d94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Thu, 3 Sep 2026 14:01:21 +0800 Subject: [PATCH 05/27] =?UTF-8?q?fix(web):=20=E5=8E=8B=E7=BC=A9=E6=89=8B?= =?UTF-8?q?=E6=9C=BA=E7=AB=AF=E8=AF=BE=E8=A1=A8=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 降低手机端课表的最小宽度、节次行高、表头与课程文字尺寸,并收紧周切换控件,让七天课表尽量在手机屏幕内完整展示,减少横向滚动和过大的视觉占用。桌面端布局保持不变。 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- web/src/components/CoachingSchedulePanel.tsx | 38 ++++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/web/src/components/CoachingSchedulePanel.tsx b/web/src/components/CoachingSchedulePanel.tsx index fd2c943..e821eef 100644 --- a/web/src/components/CoachingSchedulePanel.tsx +++ b/web/src/components/CoachingSchedulePanel.tsx @@ -756,7 +756,7 @@ export function CoachingSchedulePanel({ nowTs, mode = "coach" }: Props) {
{/* 紧凑顶栏:标题 + 周切换同一行 */}
-

+

{isCoach ? t("coaching.schedule_title") : t("coaching.my_schedule")}

@@ -768,14 +768,14 @@ export function CoachingSchedulePanel({ nowTs, mode = "coach" }: Props) { setWeekAnchor(addDays(weekAnchor, -7))} > -
+
{isMobile ? ( setWeekAnchor(addDays(weekAnchor, 7))} > @@ -1094,14 +1094,22 @@ function SectionGrid({ onCellClick: (weekDay: number, section: number) => void; onItemClick: (item: GridItem) => void; }) { + const isMobile = useIsMobile(); const totalRows = sections.length; const gridStyle = { - gridTemplateColumns: `48px repeat(7, minmax(0, 1fr))`, - gridTemplateRows: `40px repeat(${totalRows}, 60px)`, + gridTemplateColumns: isMobile + ? `32px repeat(7, minmax(40px, 1fr))` + : `48px repeat(7, minmax(0, 1fr))`, + gridTemplateRows: isMobile + ? `36px repeat(${totalRows}, 52px)` + : `40px repeat(${totalRows}, 60px)`, } as const; return ( -
+
{/* 左上角 */}
- + {t("coaching.weekday_prefix", { day: t(`coaching.weekday.${i}`) })} - + {fmtMD(d)} @@ -1141,9 +1149,9 @@ function SectionGrid({ className="flex flex-col items-center justify-center border-b border-r border-border px-0.5 text-center" style={{ gridColumn: 1, gridRow: sec.no + 1 }} > - {sec.no} - {sec.start} - {sec.end} + {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; @@ -1181,26 +1244,41 @@ function SectionGrid({ e.stopPropagation(); onItemClick(item); }} - className={`group relative m-0.5 flex flex-col overflow-hidden text-left text-white shadow-sm transition-transform hover:z-10 hover:scale-[1.02] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/70 ${isMobile ? "rounded-[4px] p-0.5" : "rounded-md p-1"}`} + className={`group relative m-0.5 flex flex-col overflow-hidden text-left shadow-sm transition-[transform,box-shadow] hover:z-10 hover:scale-[1.015] focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 ${isMobile ? "rounded-[4px] p-0.5" : "rounded-md p-2"} ${item.conflict ? "border-2" : "border"}`} style={{ gridColumn: col, gridRow: `${rowStart} / ${rowEnd}`, - backgroundColor: item.color, - borderColor: hexToRgba(item.color, 0.5), + color: "#2D3748", + backgroundColor: hexToRgba(item.color, 0.14), + borderColor: item.conflict ? "#EF4444" : hexToRgba(item.color, 0.55), + boxShadow: item.conflict ? "0 0 0 1px rgba(239,68,68,0.12)" : undefined, }} title={`${item.title}${item.subtitle ? " " + item.subtitle : ""}${item.meta ? " · " + item.meta : ""}`} > - {item.title} + + {item.typeLabel} + + + {item.title} + {showDetail && !isMobile && ( <> {item.subtitle && ( - {item.subtitle} + {item.subtitle} )} {item.meta && ( - {item.meta} + {item.meta} + )} + {item.statusLabel && ( + {item.statusLabel} )} )} + {item.conflict && ( + + {t("coaching.schedule_conflict")} + + )} ); })} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 1619d41..8d6904e 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)", diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index 578e4e3..6f2b4ea 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -1876,7 +1876,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": "选择某一天(跳转到该周)", From a2e12df9a77a8ac90024c216fe48d811a7ffc48c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Sun, 6 Sep 2026 16:26:25 +0800 Subject: [PATCH 09/27] =?UTF-8?q?feat(web):=20=E6=8A=97=E9=81=97=E5=BF=98?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E5=8F=B3=E4=BE=A7=E6=B7=BB=E5=8A=A0=E5=BC=80?= =?UTF-8?q?=E5=A7=8B=E5=A4=8D=E4=B9=A0=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 每个复习任务卡片右侧增加开始复习按钮 - 复用 practice.start_review 文案,与课后检测一致 - 无可复习单词时按钮禁用 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- web/src/pages/AntiForgetting.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/web/src/pages/AntiForgetting.tsx b/web/src/pages/AntiForgetting.tsx index df3f99c..2438027 100644 --- a/web/src/pages/AntiForgetting.tsx +++ b/web/src/pages/AntiForgetting.tsx @@ -278,8 +278,23 @@ export default function AntiForgetting() { aria-hidden /> -
- {task.student} +
+
+ {task.student} +
+ { + e.stopPropagation(); + handleOpenTask(task); + }} + > + {t("practice.start_review")} +
- ))} + ); + })}
))}
From db213fcbff74911bf0e9686cb7437c3dbfec4876 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Sun, 6 Sep 2026 18:16:19 +0800 Subject: [PATCH 16/27] =?UTF-8?q?style(web):=20=E5=A2=9E=E5=BC=BA=E6=8A=97?= =?UTF-8?q?=E9=81=97=E5=BF=98=E5=A4=8D=E4=B9=A0=E9=A1=B9=E5=88=86=E9=9A=94?= =?UTF-8?q?=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将复习项之间的分隔线提高为 2px 实色主题边框 - 提升相邻复习任务的视觉区分度 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- web/src/pages/AntiForgetting.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/pages/AntiForgetting.tsx b/web/src/pages/AntiForgetting.tsx index 734b976..ef8201e 100644 --- a/web/src/pages/AntiForgetting.tsx +++ b/web/src/pages/AntiForgetting.tsx @@ -262,7 +262,7 @@ export default function AntiForgetting() { return (
{idx === 0 ? ( From 9ec1014038195e602962c00202e2ef4a9e99336d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Sun, 6 Sep 2026 18:20:33 +0800 Subject: [PATCH 17/27] =?UTF-8?q?style(web):=20=E5=BB=B6=E4=BC=B8=E6=8A=97?= =?UTF-8?q?=E9=81=97=E5=BF=98=E5=A4=8D=E4=B9=A0=E9=A1=B9=E5=88=86=E9=9A=94?= =?UTF-8?q?=E7=BA=BF=E8=87=B3=E5=86=85=E5=AE=B9=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 抵消时间轴容器的水平内边距 - 保持内容位置不变,让分隔线贴近卡片左右边界 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- web/src/pages/AntiForgetting.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/pages/AntiForgetting.tsx b/web/src/pages/AntiForgetting.tsx index ef8201e..b9f0013 100644 --- a/web/src/pages/AntiForgetting.tsx +++ b/web/src/pages/AntiForgetting.tsx @@ -262,7 +262,7 @@ export default function AntiForgetting() { return (
{idx === 0 ? ( From 3ec1589b559ddebb5a1e78a3c19444299177a2ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Sun, 6 Sep 2026 18:25:16 +0800 Subject: [PATCH 18/27] =?UTF-8?q?fix(web):=20=E4=BC=98=E5=8C=96=E8=AF=BE?= =?UTF-8?q?=E8=A1=A8=E9=87=8D=E5=8F=A0=E8=AF=BE=E7=A8=8B=E5=B8=83=E5=B1=80?= =?UTF-8?q?=E5=B9=B6=E7=A7=BB=E9=99=A4=E7=81=B0=E8=89=B2=E5=8D=A1=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 同日同节次重叠课程自动分栏显示,避免色块堆叠 - 已完成和过去课程改用彩色状态色,不再使用灰色背景 - 提高课程背景和边框颜色的可见度 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- web/src/components/CoachingSchedulePanel.tsx | 73 ++++++++++++++------ 1 file changed, 53 insertions(+), 20 deletions(-) diff --git a/web/src/components/CoachingSchedulePanel.tsx b/web/src/components/CoachingSchedulePanel.tsx index 9886d31..566b752 100644 --- a/web/src/components/CoachingSchedulePanel.tsx +++ b/web/src/components/CoachingSchedulePanel.tsx @@ -696,13 +696,15 @@ export function CoachingSchedulePanel({ nowTs, mode = "coach" }: Props) { const { startSection, endSection } = timeToSections(s.startTime, s.endTime, sections); const past = isSchedulePast(s, nowTs); const statusColor = - past || s.status === "completed" - ? "#7A8A99" - : s.status === "in_progress" - ? "#55A3FF" - : s.status === "cancelled" - ? "#E85555" - : "#4ECDC4"; + 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}`, @@ -738,16 +740,42 @@ export function CoachingSchedulePanel({ nowTs, mode = "coach" }: Props) { }); } - return items.map((item) => ({ - ...item, - conflict: items.some( - (other) => - other.key !== item.key && - other.weekDay === item.weekDay && - other.startSection <= item.endSection && - other.endSection >= item.startSection, - ), - })); + 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) { @@ -1106,6 +1134,8 @@ type GridItem = { typeLabel: string; statusLabel?: string; conflict?: boolean; + lane?: number; + laneCount?: number; schedule?: CoachingWeekSchedule; course?: Course; }; @@ -1244,13 +1274,16 @@ function SectionGrid({ e.stopPropagation(); onItemClick(item); }} - className={`group relative m-0.5 flex flex-col overflow-hidden text-left shadow-sm transition-[transform,box-shadow] hover:z-10 hover:scale-[1.015] focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 ${isMobile ? "rounded-[4px] p-0.5" : "rounded-md p-2"} ${item.conflict ? "border-2" : "border"}`} + className={`group relative flex flex-col overflow-hidden text-left shadow-sm transition-[transform,box-shadow] hover:z-10 hover:scale-[1.015] focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 ${isMobile ? "rounded-[4px] p-0.5" : "rounded-md p-2"} ${item.conflict ? "border-2" : "border"}`} style={{ gridColumn: col, gridRow: `${rowStart} / ${rowEnd}`, + width: `calc(${100 / (item.laneCount || 1)}% - 4px)`, + marginLeft: `calc(${((item.lane || 0) * 100) / (item.laneCount || 1)}% + 2px)`, + marginRight: 2, color: "#2D3748", - backgroundColor: hexToRgba(item.color, 0.14), - borderColor: item.conflict ? "#EF4444" : hexToRgba(item.color, 0.55), + backgroundColor: hexToRgba(item.color, 0.18), + borderColor: item.conflict ? "#EF4444" : hexToRgba(item.color, 0.65), boxShadow: item.conflict ? "0 0 0 1px rgba(239,68,68,0.12)" : undefined, }} title={`${item.title}${item.subtitle ? " " + item.subtitle : ""}${item.meta ? " · " + item.meta : ""}`} From ec7048f22f0ddeb117b685788bc2fd8b5dc72d85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Sun, 6 Sep 2026 18:30:13 +0800 Subject: [PATCH 19/27] =?UTF-8?q?style(web):=20=E4=BC=98=E5=8C=96=E6=8A=97?= =?UTF-8?q?=E9=81=97=E5=BF=98=E5=A4=8D=E4=B9=A0=E6=8C=89=E9=92=AE=E5=B8=83?= =?UTF-8?q?=E5=B1=80=E4=B8=8E=E4=BA=A4=E4=BA=92=E5=8F=8D=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将开始复习按钮移至整条记录右侧并垂直居中 - 增加复习记录悬停背景反馈 - 增加按钮 hover 变深和 active 按下反馈 - 为内容预留按钮空间,避免信息重叠 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- web/src/pages/AntiForgetting.tsx | 37 ++++++++++++++++---------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/web/src/pages/AntiForgetting.tsx b/web/src/pages/AntiForgetting.tsx index b9f0013..c14ab83 100644 --- a/web/src/pages/AntiForgetting.tsx +++ b/web/src/pages/AntiForgetting.tsx @@ -262,7 +262,7 @@ export default function AntiForgetting() { return (
{idx === 0 ? ( @@ -274,29 +274,14 @@ export default function AntiForgetting() { )}
-
+
-
-
- {task.student} -
- { - e.stopPropagation(); - handleOpenTask(task); - }} - > - {t("practice.start_review")} - +
+ {task.student}
+ + { + e.stopPropagation(); + handleOpenTask(task); + }} + > + {t("practice.start_review")} +
); From 31a5fc902daa20ab927c873a3ea6035a81d7cd05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Sun, 6 Sep 2026 18:35:14 +0800 Subject: [PATCH 20/27] =?UTF-8?q?style(web):=20=E4=BC=98=E5=8C=96=E6=8A=97?= =?UTF-8?q?=E9=81=97=E5=BF=98=E6=97=B6=E9=97=B4=E8=BD=B4=E5=B8=83=E5=B1=80?= =?UTF-8?q?=E5=B9=B6=E8=B7=9F=E9=9A=8F=E4=B8=BB=E9=A2=98=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 增加复习记录上下留白,改善时间轴内容密度 - 分割线、竖线、连接线和时间方块统一使用主题 primary 色 - 调整日期卡片边框和背景,增强整体层次 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- web/src/pages/AntiForgetting.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/web/src/pages/AntiForgetting.tsx b/web/src/pages/AntiForgetting.tsx index c14ab83..8ee4349 100644 --- a/web/src/pages/AntiForgetting.tsx +++ b/web/src/pages/AntiForgetting.tsx @@ -242,15 +242,15 @@ export default function AntiForgetting() { ) : ( - -
+ +
{selectedDate}
@@ -262,11 +262,11 @@ export default function AntiForgetting() { return (
{idx === 0 ? ( -
+
{group.timeSlot}
) : ( @@ -276,7 +276,7 @@ export default function AntiForgetting() {
From 37d462c21a933ccb2b25b79b0c57e6a9539bcd7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Mon, 7 Sep 2026 10:18:10 +0800 Subject: [PATCH 21/27] =?UTF-8?q?fix(anti-forgetting):=20=E7=B2=BE?= =?UTF-8?q?=E7=AE=80=E6=8A=97=E9=81=97=E5=BF=98=E8=AE=BE=E7=BD=AE=E9=A1=B5?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E4=B8=8E=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将标题改为「抗遗忘时间设置」,移除冗长的介绍段落,仅保留学员信息。 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- web/src/i18n/locales/en.json | 4 ++-- web/src/i18n/locales/zh-CN.json | 4 ++-- web/src/pages/CreateAntiForgetting.tsx | 5 +---- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 8d6904e..b853de5 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1680,8 +1680,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", diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index 6f2b4ea..1159435 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -1392,8 +1392,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": "开始时间", diff --git a/web/src/pages/CreateAntiForgetting.tsx b/web/src/pages/CreateAntiForgetting.tsx index cb6f2bb..f815b98 100644 --- a/web/src/pages/CreateAntiForgetting.tsx +++ b/web/src/pages/CreateAntiForgetting.tsx @@ -166,11 +166,8 @@ export default function CreateAntiForgetting() {
-

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

{trainingStudent?.name ? ( -

+

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

) : null} From 0d2aa5b789f99f4e483014ef01456680e5888a04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Mon, 7 Sep 2026 10:36:22 +0800 Subject: [PATCH 22/27] =?UTF-8?q?fix(anti-forgetting):=20=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E6=8A=97=E9=81=97=E5=BF=98=E7=95=8C=E9=9D=A2=E7=9A=84?= =?UTF-8?q?=E6=9B=B2=E7=BA=BF=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除「曲线:…」文本及相关未使用的导入与变量。 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- web/src/pages/AntiForgetting.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/web/src/pages/AntiForgetting.tsx b/web/src/pages/AntiForgetting.tsx index 8ee4349..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) })} -

-
Date: Mon, 7 Sep 2026 10:54:24 +0800 Subject: [PATCH 23/27] =?UTF-8?q?feat(billing):=20=E5=AD=A6=E5=91=98?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E6=94=B9=E4=B8=BA=E6=8C=89=E8=AF=BE=E6=97=B6?= =?UTF-8?q?=E8=AE=A1=E8=B4=B9=EF=BC=8C=E5=AE=8C=E6=88=90=E8=AE=AD=E5=90=8E?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=E6=B6=88=E8=80=971=E8=AF=BE=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端:StudySession 新增 BilledMinutes 字段用于幂等控制 - 后端:handleStudySessionComplete 完成训后检测时扣减 60 分钟(1课时) - 后端:coachingCompleteAppointment 不再扣减学员额度,仅保留老师计费 - 前端(web):学员额度展示从分钟改为课时(÷60),新增 formatLessons - 前端(admin):额度面板展示改为课时,输入按课时×60换算 - i18n:学员管理相关文案从"分钟"改为"课时" - 同时提交此前未提交的课堂评价精简改动 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- admin/src/features/coaching/quota-display.ts | 4 +- .../coaching/student-quotas-panel.tsx | 19 ++++---- internal/handlers/coaching_session_logic.go | 27 ++---------- internal/handlers/study.go | 35 ++++++++++++++- internal/handlers/study_session_report.go | 43 +++++++++---------- internal/models/study_sessions.go | 1 + web/src/components/AddStudentPanel.tsx | 10 +++-- web/src/i18n/locales/en.json | 19 ++++---- web/src/i18n/locales/zh-CN.json | 23 +++++----- web/src/pages/CoachCompletedSessions.tsx | 4 +- web/src/pages/MyStudents.tsx | 6 +-- web/src/pages/StudentDetail.tsx | 40 ++++++++--------- web/src/utils/formatMinutes.ts | 18 ++++++++ 13 files changed, 143 insertions(+), 106 deletions(-) diff --git a/admin/src/features/coaching/quota-display.ts b/admin/src/features/coaching/quota-display.ts index f8254e4..7027734 100644 --- a/admin/src/features/coaching/quota-display.ts +++ b/admin/src/features/coaching/quota-display.ts @@ -18,7 +18,9 @@ export function personLabel( export function formatMinutes(mins: number): string { if (!Number.isFinite(mins)) return '—' - return `${Math.max(0, Math.round(mins))} 分钟` + const lessons = Math.max(0, Math.round(mins)) / 60 + const n = Math.round(lessons * 10) / 10 + return `${n} 课时` } export function capLabel(cap: number): string { diff --git a/admin/src/features/coaching/student-quotas-panel.tsx b/admin/src/features/coaching/student-quotas-panel.tsx index 212efb0..3a750bc 100644 --- a/admin/src/features/coaching/student-quotas-panel.tsx +++ b/admin/src/features/coaching/student-quotas-panel.tsx @@ -68,7 +68,7 @@ export function StudentQuotasPanel() { mode: 'create', teacherId: teacherFilter.trim(), studentId: studentFilter.trim(), - remainingMinutes: '60', + remainingMinutes: '1', }) } @@ -77,7 +77,7 @@ export function StudentQuotasPanel() { mode: 'edit', teacherId: String(row.teacherId), studentId: String(row.studentId), - remainingMinutes: String(row.remainingMinutes), + remainingMinutes: String(row.remainingMinutes / 60), }) } @@ -85,15 +85,16 @@ export function StudentQuotasPanel() { if (!edit) return const teacherId = Number(edit.teacherId) const studentId = Number(edit.studentId) - const remainingMinutes = Number(edit.remainingMinutes) + const lessons = Number(edit.remainingMinutes) if (!teacherId || !studentId) { toast.error('请填写老师 ID 与学员 ID') return } - if (!Number.isFinite(remainingMinutes) || remainingMinutes < 0) { - toast.error('剩余分钟不能为负') + if (!Number.isFinite(lessons) || lessons < 0) { + toast.error('剩余课时不能为负') return } + const remainingMinutes = Math.round(lessons * 60) setSaving(true) try { await put('/coaching/quotas', { @@ -114,7 +115,7 @@ export function StudentQuotasPanel() { return (

- 学员在某老师名下的陪练剩余时长。开始上课时会从学员剩余分钟中扣减。 + 学员在某老师名下的陪练剩余课时。完成训后检测时消耗 1 课时。

@@ -161,7 +162,7 @@ export function StudentQuotasPanel() { 老师 学员 - 剩余 + 剩余课时 累计分配 操作 @@ -221,7 +222,7 @@ export function StudentQuotasPanel() { - {edit?.mode === 'create' ? '新建师生额度' : '调整学员剩余时长'} + {edit?.mode === 'create' ? '新建师生额度' : '调整学员剩余课时'} {edit && ( @@ -247,7 +248,7 @@ export function StudentQuotasPanel() { />
- + 0 && session.BilledMinutes == 0 { + billedMinutes = 60 + _ = db.Transaction(func(tx *gorm.DB) error { + var q models.StudentTeacherCoachingQuota + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("teacher_id = ? AND student_id = ?", user.ID, session.StudentID). + First(&q).Error; err != nil { + return err + } + deduct := billedMinutes + if q.RemainingMinutes < deduct { + deduct = q.RemainingMinutes + } + res := tx.Model(&models.StudentTeacherCoachingQuota{}). + Where("id = ? AND version = ?", q.ID, q.Version). + Updates(map[string]any{ + "remaining_minutes": q.RemainingMinutes - deduct, + "version": q.Version + 1, + }) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return errors.New("额度更新冲突,请重试") + } + return tx.Model(&session).Update("billed_minutes", billedMinutes).Error + }) + } + var remainCount int64 _ = db.Model(&models.UserWordState{}). Where("user_id = ? AND word_book_id = ? AND screen_result = ? AND learn_status = ?", user.ID, session.WordBookID, "unknown", "pending"). @@ -1196,7 +1229,7 @@ func (h *Handlers) handleStudySessionsPracticeTime(c *gin.Context) { } response.SuccessI18n(c, "common.success", gin.H{ - "updated": res.RowsAffected, + "updated": res.RowsAffected, "sessionIds": ids, }) } diff --git a/internal/handlers/study_session_report.go b/internal/handlers/study_session_report.go index 61b102e..f7a9379 100644 --- a/internal/handlers/study_session_report.go +++ b/internal/handlers/study_session_report.go @@ -162,15 +162,7 @@ func loadSessionForgotWordLabels(db *gorm.DB, session *models.StudySession) []st if !ok || strings.TrimSpace(w.Word) == "" { continue } - gloss := strings.TrimSpace(w.TranslationShort) - if gloss == "" { - gloss = models.FormatTranslationShort(w.Translation) - } - if gloss != "" { - out = append(out, fmt.Sprintf("%s(%s)", w.Word, gloss)) - } else { - out = append(out, w.Word) - } + out = append(out, w.Word) if len(out) >= 12 { break } @@ -179,29 +171,24 @@ func loadSessionForgotWordLabels(db *gorm.DB, session *models.StudySession) []st } 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, "、") } - 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本节识记:%d\n记住:%d\n未记住:%d\n正确率:%.0f%%\n未记住单词:%s\n请只输出课堂评价正文,不要加标题、序号或括号。", name, - fallbackDash(report.WordBookName), - report.DurationMinutes, - screenTotal, - report.ScreenedKnownCount, - report.ScreenedUnknownCount, report.WordCount, report.CorrectCount, report.ForgotCount, report.AccuracyPercent, - report.RemainPending, forgotLine, ) return systemPrompt, userPrompt @@ -231,7 +218,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/internal/models/study_sessions.go b/internal/models/study_sessions.go index 767c5f6..cc631e7 100644 --- a/internal/models/study_sessions.go +++ b/internal/models/study_sessions.go @@ -21,6 +21,7 @@ type StudySession struct { ScreenedKnownCount int `json:"screenedKnownCount" gorm:"default:0;comment:本课筛词熟词数"` ScreenedUnknownCount int `json:"screenedUnknownCount" gorm:"default:0;comment:本课筛词生词/新词数"` ReportSummary string `json:"reportSummary,omitempty" gorm:"type:text;comment:课堂报告 AI 摘要缓存"` + BilledMinutes int `json:"billedMinutes" gorm:"default:0;comment:本节已扣减的课时分钟数(0=未扣,60=已扣1课时)"` } func (StudySession) TableName() string { return constants.TABLE_STUDY_SESSIONS } diff --git a/web/src/components/AddStudentPanel.tsx b/web/src/components/AddStudentPanel.tsx index b3cee96..bf6884f 100644 --- a/web/src/components/AddStudentPanel.tsx +++ b/web/src/components/AddStudentPanel.tsx @@ -10,6 +10,7 @@ import { } from "../api/coaching"; import { normalizeSnowflakeId } from "../utils/json-snowflake"; import { showToast } from "../utils/toast"; +import { lessonsToMinutes } from "../utils/formatMinutes"; type Props = { open: boolean; @@ -24,7 +25,7 @@ export function AddStudentPanel({ open, onClose, onAdded }: Props) { const [searching, setSearching] = useState(false); const [searchResults, setSearchResults] = useState([]); const [picked, setPicked] = useState(null); - const [quotaMinutes, setQuotaMinutes] = useState("120"); + const [quotaMinutes, setQuotaMinutes] = useState("2"); const [adding, setAdding] = useState(false); if (!open) return null; @@ -53,11 +54,12 @@ export function AddStudentPanel({ open, onClose, onAdded }: Props) { showToast.warning(t("coaching.select_student_first")); return; } - const mins = Number(quotaMinutes); - if (Number.isNaN(mins) || mins < 0) { - showToast.warning(t("coaching.invalid_minutes")); + const lessons = Number(quotaMinutes); + if (Number.isNaN(lessons) || lessons < 0) { + showToast.warning(t("coaching.invalid_lessons")); return; } + const mins = lessonsToMinutes(lessons); setAdding(true); try { const res = await addTeacherCoachingStudent({ diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index b853de5..4a3a142 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1228,6 +1228,7 @@ "ui.minutes": "{{count}} min", "ui.minutes_short": "{{count}} m", "ui.minutes_range": "1–180 min", + "ui.lessons": "{{count}} lessons", "ui.student_fallback": "Student", "ui.student_id": "Student #{{id}}", "nav.home": "Home", @@ -1285,11 +1286,11 @@ "coaching.user_not_found": "No users found", "coaching.search_failed": "Search failed", "coaching.select_student_first": "Please select a student first", - "coaching.invalid_minutes": "Invalid remaining minutes", + "coaching.invalid_lessons": "Invalid lesson count", "coaching.add_failed": "Failed to add", "coaching.add_success": "Student added", "coaching.search_placeholder": "Search username, nickname, or phone", - "coaching.initial_quota": "Initial coaching quota (minutes)", + "coaching.initial_quota": "Initial coaching quota (lessons)", "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", @@ -1830,11 +1831,11 @@ "student_detail.allocated_summary": "Total allocated {{total}} · Remaining {{remaining}}", "student_detail.edit_quota": "Edit quota", "student_detail.configure_quota": "Configure coaching quota", - "student_detail.quota_desc": "Add or adjust available minutes. Cannot start sessions when quota is exhausted.", - "student_detail.add_minutes": "Add minutes", + "student_detail.quota_desc": "Add or adjust available lessons. 1 lesson is consumed per completed post-training check.", + "student_detail.add_lessons": "Add lessons", "student_detail.set_remaining": "Set remaining", - "student_detail.add_minutes_label": "Minutes to add", - "student_detail.remaining_minutes_label": "Remaining minutes", + "student_detail.add_lessons_label": "Lessons to add", + "student_detail.remaining_lessons_label": "Remaining lessons", "student_detail.review_times": "Anti-forgetting count", "student_detail.review_desc": "Ebbinghaus schedule (lesson day = day 1): first review same day, then on day N headers; list shows study time. Current: {{label}}", "student_detail.quick_actions": "Quick actions", @@ -1866,9 +1867,9 @@ "student_detail.review_updated": "Anti-forgetting count updated", "student_detail.added_wordbook": "Wordbook added", "student_detail.removed_wordbook": "Removed", - "student_detail.added_minutes": "Added {{n}} minutes, remaining {{remaining}}", - "student_detail.set_minutes": "Remaining set to {{remaining}}", - "student_detail.invalid_minutes": "Enter a non-negative integer", + "student_detail.added_lessons": "Added {{n}} lessons, remaining {{remaining}}", + "student_detail.set_lessons": "Remaining set to {{remaining}}", + "student_detail.invalid_lessons": "Enter a non-negative integer", "student_detail.student_fallback": "Student #{{id}}", "create_appointment.title": "Add session", "create_appointment.student_section": "Student info", diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index 1159435..3c12c06 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -1517,7 +1517,7 @@ "create_student.name_placeholder": "请输入学生姓名", "create_student.name_hint": "登录账号将自动生成(姓名 + 随机数字),初始密码 {{pwd}}", "create_student.hours_label": "学时", - "create_student.hours_hint": "1 学时 = 60 分钟陪练额度", + "create_student.hours_hint": "1 课时 = 完成一次训后检测", "create_student.link_existing": "已有账号?去关联添加", "create_student.submit": "创建学生", "create_student.success_title": "创建成功", @@ -1542,11 +1542,11 @@ "student_detail.allocated_summary": "累计分配 {{total}} · 剩余 {{remaining}}", "student_detail.edit_quota": "编辑额度", "student_detail.configure_quota": "配置陪练额度", - "student_detail.quota_desc": "为该学员追加或调整可上课分钟数。额度用尽后将无法开课计时。", - "student_detail.add_minutes": "追加分钟", + "student_detail.quota_desc": "为该学员追加或调整可上课课时数。完成训后检测时消耗 1 课时。", + "student_detail.add_lessons": "追加课时", "student_detail.set_remaining": "设为剩余", - "student_detail.add_minutes_label": "追加分钟数", - "student_detail.remaining_minutes_label": "剩余分钟数", + "student_detail.add_lessons_label": "追加课时数", + "student_detail.remaining_lessons_label": "剩余课时数", "student_detail.review_times": "抗遗忘次数", "student_detail.review_desc": "按艾宾浩斯曲线排期(开课日=第 1 天):学完当天即第 1 次复习,后续落在「第 N 天」表头日期;列表显示识记时段。当前:{{label}}", "student_detail.quick_actions": "快捷操作", @@ -1578,9 +1578,9 @@ "student_detail.review_updated": "抗遗忘次数已更新", "student_detail.added_wordbook": "已添加词库", "student_detail.removed_wordbook": "已移除", - "student_detail.added_minutes": "已追加 {{n}} 分钟,剩余 {{remaining}}", - "student_detail.set_minutes": "剩余额度已设为 {{remaining}}", - "student_detail.invalid_minutes": "请输入非负整数分钟", + "student_detail.added_lessons": "已追加 {{n}} 课时,剩余 {{remaining}}", + "student_detail.set_lessons": "剩余额度已设为 {{remaining}}", + "student_detail.invalid_lessons": "请输入非负整数课时", "student_detail.student_fallback": "学员 #{{id}}", "create_appointment.title": "添加课程", "create_appointment.student_section": "学生信息", @@ -1631,7 +1631,7 @@ "coach_sessions.no_pagination": "暂无分页", "coach_sessions.page": "第 {{page}}/{{total}} 页", "coach_sessions.schedule_fallback": "排课 #{{id}}", - "coach_sessions.actual_billed": "实际 {{actual}} 分钟 · 学员扣减 {{billed}} 分钟", + "coach_sessions.actual_billed": "实际 {{actual}} 分钟 · 学员扣减 {{billed}} 课时", "coach_sessions.teacher_credited": " · 计入老师 {{minutes}} 分钟", "coach_sessions.detail_title": "课程详情", "coach_sessions.course_title": "课程标题", @@ -1812,6 +1812,7 @@ "ui.minutes": "{{count}} 分钟", "ui.minutes_short": "{{count}} 分", "ui.minutes_range": "1–180 分钟", + "ui.lessons": "{{count}} 课时", "ui.student_fallback": "学员", "ui.student_id": "学员 #{{id}}", "nav.home": "首页", @@ -1869,11 +1870,11 @@ "coaching.user_not_found": "未找到用户", "coaching.search_failed": "搜索失败", "coaching.select_student_first": "请先选择学员", - "coaching.invalid_minutes": "剩余分钟数无效", + "coaching.invalid_lessons": "课时数无效", "coaching.add_failed": "添加失败", "coaching.add_success": "已添加学员", "coaching.search_placeholder": "搜索用户名、昵称或手机号", - "coaching.initial_quota": "初始陪练额度(分钟)", + "coaching.initial_quota": "初始陪练额度(课时)", "coaching.confirm_add": "确认添加", "coaching.schedule_title": "陪练排课", "coaching.schedule_hint": "点击空白节次可快速新增课程,点击已有课程查看详情", diff --git a/web/src/pages/CoachCompletedSessions.tsx b/web/src/pages/CoachCompletedSessions.tsx index 4925305..a7483a0 100644 --- a/web/src/pages/CoachCompletedSessions.tsx +++ b/web/src/pages/CoachCompletedSessions.tsx @@ -161,7 +161,7 @@ export default function CoachCompletedSessions() {
{s.session?.billedMinutes != null && (

-{t("coach_sessions.actual_billed", { actual: s.session.actualMinutes ?? "-", billed: s.session.billedMinutes })} +{t("coach_sessions.actual_billed", { actual: s.session.actualMinutes ?? "-", billed: Math.round((s.session.billedMinutes ?? 0) / 60) })} {s.session.teacherCreditedMinutes != null && ( <>{t("coach_sessions.teacher_credited", { minutes: s.session.teacherCreditedMinutes })} )} @@ -262,7 +262,7 @@ export default function CoachCompletedSessions() {

{t("coach_sessions.student_deduct")} - {t("create_appointment.duration_min", { n: detail.session?.billedMinutes ?? "-" })} + {t("ui.lessons", { count: Math.round((detail.session?.billedMinutes ?? 0) / 60) })}
{t("coach_sessions.teacher_credit")} diff --git a/web/src/pages/MyStudents.tsx b/web/src/pages/MyStudents.tsx index 81c1633..153c8b2 100644 --- a/web/src/pages/MyStudents.tsx +++ b/web/src/pages/MyStudents.tsx @@ -30,7 +30,7 @@ import { resolveMediaUrl } from "../utils/mediaUrl"; import { useTranslation } from "react-i18next"; import i18n from "../i18n"; import { formatApiMessage } from "../utils/apiMessage"; -import { formatTeachingMinutes } from "../utils/formatMinutes"; +import { formatLessons } from "../utils/formatMinutes"; const DEFAULT_PASSWORD = "student123"; const PAGE_LIMIT = 20; @@ -314,7 +314,7 @@ export default function MyStudents() { ) : ( rows.map((r) => { - const low = (r.remainingMinutes || 0) < 30; + const low = (r.remainingMinutes || 0) < 60; const account = loginAccount(r); const avatar = studentAvatarUrl(r); return ( @@ -348,7 +348,7 @@ export default function MyStudents() { }`} > - {formatTeachingMinutes(r.remainingMinutes || 0)} + {formatLessons(r.remainingMinutes || 0)}

diff --git a/web/src/pages/StudentDetail.tsx b/web/src/pages/StudentDetail.tsx index 1238861..8528e83 100644 --- a/web/src/pages/StudentDetail.tsx +++ b/web/src/pages/StudentDetail.tsx @@ -49,7 +49,7 @@ import { normalizeReviewCurvePreset, reviewCurveLabel, } from "../utils/reviewCurve"; -import { formatTeachingMinutes } from "../utils/formatMinutes"; +import { formatLessons, lessonsToMinutes, minutesToLessons } from "../utils/formatMinutes"; import { isValidSnowflakeId, normalizeSnowflakeId, sameSnowflakeId } from "../utils/json-snowflake"; const DEFAULT_PASSWORD = "student123"; @@ -193,11 +193,11 @@ export default function StudentDetail() { if (!quota) return; const n = Number(quotaInput); if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) { - showToast.error(t("student_detail.invalid_minutes")); + showToast.error(t("student_detail.invalid_lessons")); return; } const nextRemaining = - quotaMode === "add" ? Math.max(0, remaining) + n : n; + quotaMode === "add" ? Math.max(0, remaining) + lessonsToMinutes(n) : lessonsToMinutes(n); setQuotaSaving(true); try { const res = await addTeacherCoachingStudent({ @@ -219,10 +219,10 @@ export default function StudentDetail() { ); showToast.success( quotaMode === "add" - ? t("student_detail.added_minutes", { n, remaining: formatTeachingMinutes(nextRemaining) }) - : t("student_detail.set_minutes", { remaining: formatTeachingMinutes(nextRemaining) }) + ? t("student_detail.added_lessons", { n, remaining: formatLessons(nextRemaining) }) + : t("student_detail.set_lessons", { remaining: formatLessons(nextRemaining) }) ); - setQuotaInput(quotaMode === "add" ? "60" : String(nextRemaining)); + setQuotaInput(quotaMode === "add" ? "1" : String(minutesToLessons(nextRemaining))); setQuotaEditOpen(false); } catch (e: unknown) { const msg = @@ -460,7 +460,7 @@ export default function StudentDetail() { const avatar = resolveMediaUrl(quota?.student?.avatar); const remaining = quota?.remainingMinutes ?? 0; const total = quota?.totalAllocatedMinutes ?? 0; - const low = remaining < 30; + const low = remaining < 60; if (!isValidSnowflakeId(studentId)) { return ( @@ -550,13 +550,13 @@ export default function StudentDetail() { low ? "text-destructive" : "text-foreground" }`} > - {formatTeachingMinutes(remaining)} + {formatLessons(remaining)}

{t("student_detail.allocated_summary", { - total: formatTeachingMinutes(total), - remaining: formatTeachingMinutes(remaining), + total: formatLessons(total), + remaining: formatLessons(remaining), })}
{ setQuotaMode("add"); - setQuotaInput("60"); + setQuotaInput("1"); setQuotaEditOpen(true); }} aria-label={t("student_detail.edit_quota")} @@ -785,8 +785,8 @@ export default function StudentDetail() { {t("student_detail.edit_quota")} {t("student_detail.allocated_summary", { - total: formatTeachingMinutes(total), - remaining: formatTeachingMinutes(remaining), + total: formatLessons(total), + remaining: formatLessons(remaining), })} @@ -798,10 +798,10 @@ export default function StudentDetail() { variant={quotaMode === "add" ? "brand" : "outline"} onClick={() => { setQuotaMode("add"); - setQuotaInput("60"); + setQuotaInput("1"); }} > - {t("student_detail.add_minutes")} + {t("student_detail.add_lessons")} { setQuotaMode("set"); - setQuotaInput(String(Math.max(0, remaining))); + setQuotaInput(String(minutesToLessons(Math.max(0, remaining)))); }} > {t("student_detail.set_remaining")} @@ -818,8 +818,8 @@ export default function StudentDetail() {
- {(quotaMode === "add" ? [30, 60, 120, 180] : [0, 60, 120, 240]).map((n) => ( + {(quotaMode === "add" ? [1, 2, 3, 5] : [0, 1, 2, 4]).map((n) => ( setQuotaInput(String(n))} > - {quotaMode === "add" ? `+${n}` : t("create_appointment.duration_min", { n })} + {quotaMode === "add" ? `+${n}` : t("ui.lessons", { count: n })} ))}
diff --git a/web/src/utils/formatMinutes.ts b/web/src/utils/formatMinutes.ts index 25a6979..d482bef 100644 --- a/web/src/utils/formatMinutes.ts +++ b/web/src/utils/formatMinutes.ts @@ -6,3 +6,21 @@ export function formatTeachingMinutes(mins: number): string { const n = Math.max(0, Math.round(mins)); return i18n.t("ui.minutes", { count: n }); } + +/** Format student quota minutes as 课时 (1 课时 = 60 分钟). */ +export function formatLessons(mins: number): string { + if (!Number.isFinite(mins)) return "—"; + const lessons = Math.max(0, Math.round(mins)) / 60; + const n = Math.round(lessons * 10) / 10; + return i18n.t("ui.lessons", { count: n }); +} + +/** Convert 课时 input to minutes for API. */ +export function lessonsToMinutes(lessons: number): number { + return Math.round(lessons * 60); +} + +/** Convert minutes to 课时 for display in input fields. */ +export function minutesToLessons(mins: number): number { + return Math.round((mins / 60) * 10) / 10; +} From a6bd238fbe8d595bb9ffd1d902cdea2a7e0e8705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Mon, 7 Sep 2026 11:11:03 +0800 Subject: [PATCH 24/27] =?UTF-8?q?fix(review):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=8A=97=E9=81=97=E5=BF=98=E5=88=97=E8=A1=A8=E8=AF=8D=E6=95=B0?= =?UTF-8?q?=E4=B8=8E=E7=82=B9=E5=BC=80=E8=AF=8D=E6=95=B0=E4=B8=8D=E4=B8=80?= =?UTF-8?q?=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /review/today 在非今日日期过滤时,anchor 口径与 /review/books-by-date 不一致: 列表侧使用 first_learned_at → practiceStartedAt → dueAt, 而取词侧仅使用 first_learned_at → dueAt,缺少 practiceStartedAt 回退。 当 first_learned_at 为空时,两侧曲线投影到不同日期,导致列表显示 10 词但点开 0 词。 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- internal/handlers/review.go | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/internal/handlers/review.go b/internal/handlers/review.go index f85ec94..b724623 100644 --- a/internal/handlers/review.go +++ b/internal/handlers/review.go @@ -276,7 +276,6 @@ func reviewBooksByDateForUsers(db *gorm.DB, userIDs []uint, dayStart, dayEnd tim return stats, nil } - // handleReviewToday GET /review/today?wordBookId=1&date=YYYY-MM-DD&timeZone=Asia/Shanghai&all=true // 取词口径与 /review/books-by-date 对齐:今日含逾期至本地明日 0 点前;其它日仅该日 due。 // all=true 时忽略日期,返回所有已学待复习单词(不限 due_at)。 @@ -371,11 +370,44 @@ func (h *Handlers) handleReviewToday(c *gin.Context) { for _, r := range learned { learnedMap[r.WordID] = r.FirstLearnedAt } + + // 与 /review/books-by-date 的 anchor 口径保持一致: + // first_learned_at → practiceStartedAt(识记课次开始时间)→ dueAt + type practiceAtRow struct { + WordID uint `gorm:"column:word_id"` + PracticeStartedAt *time.Time `gorm:"column:practice_started_at"` + } + var practiceRows []practiceAtRow + if len(wordIDsAll) > 0 { + practiceSQL := ` + SELECT rq.word_id, ss.started_at AS practice_started_at + FROM review_queue rq + LEFT JOIN study_sessions ss ON ss.id = CASE + WHEN rq.source_session_id > 0 THEN rq.source_session_id + ELSE ( + SELECT ss2.id FROM study_sessions ss2 + INNER JOIN session_words sw2 ON sw2.session_id = ss2.id AND sw2.word_id = rq.word_id AND sw2.remembered = 1 + WHERE ss2.user_id = rq.user_id AND ss2.word_book_id = rq.word_book_id + ORDER BY COALESCE(ss2.completed_at, ss2.started_at) DESC, ss2.id DESC + LIMIT 1 + ) + END + WHERE rq.user_id = ? AND rq.word_id IN ? + ` + _ = db.Raw(practiceSQL, targetUser.ID, wordIDsAll).Scan(&practiceRows).Error + } + practiceMap := map[uint]*time.Time{} + for _, r := range practiceRows { + practiceMap[r.WordID] = r.PracticeStartedAt + } + filtered := make([]models.ReviewQueue, 0, len(items)) for _, it := range items { anchor := it.DueAt if t := learnedMap[it.WordID]; t != nil && !t.IsZero() { anchor = *t + } else if p := practiceMap[it.WordID]; p != nil && !p.IsZero() { + anchor = *p } if models.ReviewRemainingDueFallsOnDay(anchor, it.Stage, preset, dayStart, loc) { filtered = append(filtered, it) From ba4b535c119b7bc9b1ac68ca6f3bfba50e1a0e20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Mon, 7 Sep 2026 11:16:25 +0800 Subject: [PATCH 25/27] =?UTF-8?q?fix(coaching):=20=E6=8E=92=E8=AF=BE?= =?UTF-8?q?=E8=BF=9B=E8=A1=8C=E4=B8=AD=E4=B8=8D=E6=98=BE=E7=A4=BA=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E7=BB=93=E6=9D=9F=E6=97=B6=E9=97=B4=EF=BC=8C=E4=B8=8B?= =?UTF-8?q?=E8=AF=BE=E5=90=8E=E6=98=BE=E7=A4=BA=E7=9C=9F=E5=AE=9E=E6=97=B6?= =?UTF-8?q?=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 进行中(in_progress):只显示「21:00-」,不再默认显示计划结束时间 - 已完成(completed):显示 session.endedAt 的实际结束时间 - 已排定(scheduled):保持显示计划时段 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- web/src/components/CoachingSchedulePanel.tsx | 23 +++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/web/src/components/CoachingSchedulePanel.tsx b/web/src/components/CoachingSchedulePanel.tsx index 566b752..29e0b0e 100644 --- a/web/src/components/CoachingSchedulePanel.tsx +++ b/web/src/components/CoachingSchedulePanel.tsx @@ -128,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); @@ -261,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; @@ -715,7 +732,7 @@ export function CoachingSchedulePanel({ nowTs, mode = "coach" }: Props) { color: statusColor, title, subtitle, - meta: `${s.startTime?.slice(0, 5)}-${s.endTime?.slice(0, 5)}`, + meta: `${s.startTime?.slice(0, 5)}-${displayEndLabel(s)}`, typeLabel: t("coaching.schedule_type"), statusLabel: t(`coaching.status.${s.status}`, { defaultValue: s.status }), schedule: s, @@ -934,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)}

From 5533f52789c07ca0bd68e1ae17161875f0356339 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Mon, 7 Sep 2026 11:46:27 +0800 Subject: [PATCH 27/27] =?UTF-8?q?refactor(billing):=20=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E6=8C=89=E5=88=86=E9=92=9F=E8=AE=A1=E8=B4=B9=EF=BC=8C=E4=B8=8B?= =?UTF-8?q?=E8=AF=BE=E6=89=A3=E5=AD=A6=E5=91=98=E6=97=B6=E9=95=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端:下课(coachingCompleteAppointment)时按实际分钟扣减学员额度 - 后端:移除训后检测完成时扣1课时的逻辑 - 后端:移除 StudySession.BilledMinutes 字段 - 后端:简化 review.go 的 due 锚点计算,移除 practiceStartedAt 联查 - 前端/admin:额度显示从「课时」改回「分钟」 - 前端:移除 formatLessons/lessonsToMinutes/minutesToLessons 工具函数 - i18n:更新课时相关文案为分钟 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- admin/src/features/coaching/quota-display.ts | 4 +- .../coaching/student-quotas-panel.tsx | 19 +++++---- internal/handlers/coaching_session_logic.go | 27 +++++++++++-- internal/handlers/review.go | 34 +--------------- internal/handlers/study.go | 35 +--------------- internal/models/study_sessions.go | 1 - web/src/components/AddStudentPanel.tsx | 10 ++--- web/src/i18n/locales/en.json | 19 +++++---- web/src/i18n/locales/zh-CN.json | 23 +++++------ web/src/pages/CoachCompletedSessions.tsx | 4 +- web/src/pages/MyStudents.tsx | 6 +-- web/src/pages/StudentDetail.tsx | 40 +++++++++---------- web/src/utils/formatMinutes.ts | 18 --------- 13 files changed, 85 insertions(+), 155 deletions(-) diff --git a/admin/src/features/coaching/quota-display.ts b/admin/src/features/coaching/quota-display.ts index 7027734..f8254e4 100644 --- a/admin/src/features/coaching/quota-display.ts +++ b/admin/src/features/coaching/quota-display.ts @@ -18,9 +18,7 @@ export function personLabel( export function formatMinutes(mins: number): string { if (!Number.isFinite(mins)) return '—' - const lessons = Math.max(0, Math.round(mins)) / 60 - const n = Math.round(lessons * 10) / 10 - return `${n} 课时` + return `${Math.max(0, Math.round(mins))} 分钟` } export function capLabel(cap: number): string { diff --git a/admin/src/features/coaching/student-quotas-panel.tsx b/admin/src/features/coaching/student-quotas-panel.tsx index 3a750bc..212efb0 100644 --- a/admin/src/features/coaching/student-quotas-panel.tsx +++ b/admin/src/features/coaching/student-quotas-panel.tsx @@ -68,7 +68,7 @@ export function StudentQuotasPanel() { mode: 'create', teacherId: teacherFilter.trim(), studentId: studentFilter.trim(), - remainingMinutes: '1', + remainingMinutes: '60', }) } @@ -77,7 +77,7 @@ export function StudentQuotasPanel() { mode: 'edit', teacherId: String(row.teacherId), studentId: String(row.studentId), - remainingMinutes: String(row.remainingMinutes / 60), + remainingMinutes: String(row.remainingMinutes), }) } @@ -85,16 +85,15 @@ export function StudentQuotasPanel() { if (!edit) return const teacherId = Number(edit.teacherId) const studentId = Number(edit.studentId) - const lessons = Number(edit.remainingMinutes) + const remainingMinutes = Number(edit.remainingMinutes) if (!teacherId || !studentId) { toast.error('请填写老师 ID 与学员 ID') return } - if (!Number.isFinite(lessons) || lessons < 0) { - toast.error('剩余课时不能为负') + if (!Number.isFinite(remainingMinutes) || remainingMinutes < 0) { + toast.error('剩余分钟不能为负') return } - const remainingMinutes = Math.round(lessons * 60) setSaving(true) try { await put('/coaching/quotas', { @@ -115,7 +114,7 @@ export function StudentQuotasPanel() { return (

- 学员在某老师名下的陪练剩余课时。完成训后检测时消耗 1 课时。 + 学员在某老师名下的陪练剩余时长。开始上课时会从学员剩余分钟中扣减。

@@ -162,7 +161,7 @@ export function StudentQuotasPanel() { 老师 学员 - 剩余课时 + 剩余 累计分配 操作 @@ -222,7 +221,7 @@ export function StudentQuotasPanel() { - {edit?.mode === 'create' ? '新建师生额度' : '调整学员剩余课时'} + {edit?.mode === 'create' ? '新建师生额度' : '调整学员剩余时长'} {edit && ( @@ -248,7 +247,7 @@ export function StudentQuotasPanel() { />
- + 0 { - practiceSQL := ` - SELECT rq.word_id, ss.started_at AS practice_started_at - FROM review_queue rq - LEFT JOIN study_sessions ss ON ss.id = CASE - WHEN rq.source_session_id > 0 THEN rq.source_session_id - ELSE ( - SELECT ss2.id FROM study_sessions ss2 - INNER JOIN session_words sw2 ON sw2.session_id = ss2.id AND sw2.word_id = rq.word_id AND sw2.remembered = 1 - WHERE ss2.user_id = rq.user_id AND ss2.word_book_id = rq.word_book_id - ORDER BY COALESCE(ss2.completed_at, ss2.started_at) DESC, ss2.id DESC - LIMIT 1 - ) - END - WHERE rq.user_id = ? AND rq.word_id IN ? - ` - _ = db.Raw(practiceSQL, targetUser.ID, wordIDsAll).Scan(&practiceRows).Error - } - practiceMap := map[uint]*time.Time{} - for _, r := range practiceRows { - practiceMap[r.WordID] = r.PracticeStartedAt - } - filtered := make([]models.ReviewQueue, 0, len(items)) for _, it := range items { anchor := it.DueAt if t := learnedMap[it.WordID]; t != nil && !t.IsZero() { anchor = *t - } else if p := practiceMap[it.WordID]; p != nil && !p.IsZero() { - anchor = *p } if models.ReviewRemainingDueFallsOnDay(anchor, it.Stage, preset, dayStart, loc) { filtered = append(filtered, it) diff --git a/internal/handlers/study.go b/internal/handlers/study.go index e1c1b17..23591ed 100644 --- a/internal/handlers/study.go +++ b/internal/handlers/study.go @@ -1,8 +1,6 @@ package handlers import ( - "errors" - auth "github.com/LingByte/CloudStepsGo/pkg/middlewares" lbconstants "github.com/LingByte/ling-base/common/constants" @@ -488,37 +486,6 @@ func (h *Handlers) handleStudySessionComplete(c *gin.Context) { _ = db.Model(&session).Updates(map[string]any{"status": "completed", "completed_at": &now, "correct_count": correctCount}).Error invalidateLighthouseCacheForUser(user.ID) - // 课时扣减:完成训后检测时扣 1 课时(60 分钟),仅限老师代练的 learn 课且未扣过 - billedMinutes := 0 - if session.SessionType == "learn" && session.StudentID > 0 && session.BilledMinutes == 0 { - billedMinutes = 60 - _ = db.Transaction(func(tx *gorm.DB) error { - var q models.StudentTeacherCoachingQuota - if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). - Where("teacher_id = ? AND student_id = ?", user.ID, session.StudentID). - First(&q).Error; err != nil { - return err - } - deduct := billedMinutes - if q.RemainingMinutes < deduct { - deduct = q.RemainingMinutes - } - res := tx.Model(&models.StudentTeacherCoachingQuota{}). - Where("id = ? AND version = ?", q.ID, q.Version). - Updates(map[string]any{ - "remaining_minutes": q.RemainingMinutes - deduct, - "version": q.Version + 1, - }) - if res.Error != nil { - return res.Error - } - if res.RowsAffected == 0 { - return errors.New("额度更新冲突,请重试") - } - return tx.Model(&session).Update("billed_minutes", billedMinutes).Error - }) - } - var remainCount int64 _ = db.Model(&models.UserWordState{}). Where("user_id = ? AND word_book_id = ? AND screen_result = ? AND learn_status = ?", user.ID, session.WordBookID, "unknown", "pending"). @@ -1229,7 +1196,7 @@ func (h *Handlers) handleStudySessionsPracticeTime(c *gin.Context) { } response.SuccessI18n(c, "common.success", gin.H{ - "updated": res.RowsAffected, + "updated": res.RowsAffected, "sessionIds": ids, }) } diff --git a/internal/models/study_sessions.go b/internal/models/study_sessions.go index cc631e7..767c5f6 100644 --- a/internal/models/study_sessions.go +++ b/internal/models/study_sessions.go @@ -21,7 +21,6 @@ type StudySession struct { ScreenedKnownCount int `json:"screenedKnownCount" gorm:"default:0;comment:本课筛词熟词数"` ScreenedUnknownCount int `json:"screenedUnknownCount" gorm:"default:0;comment:本课筛词生词/新词数"` ReportSummary string `json:"reportSummary,omitempty" gorm:"type:text;comment:课堂报告 AI 摘要缓存"` - BilledMinutes int `json:"billedMinutes" gorm:"default:0;comment:本节已扣减的课时分钟数(0=未扣,60=已扣1课时)"` } func (StudySession) TableName() string { return constants.TABLE_STUDY_SESSIONS } diff --git a/web/src/components/AddStudentPanel.tsx b/web/src/components/AddStudentPanel.tsx index bf6884f..b3cee96 100644 --- a/web/src/components/AddStudentPanel.tsx +++ b/web/src/components/AddStudentPanel.tsx @@ -10,7 +10,6 @@ import { } from "../api/coaching"; import { normalizeSnowflakeId } from "../utils/json-snowflake"; import { showToast } from "../utils/toast"; -import { lessonsToMinutes } from "../utils/formatMinutes"; type Props = { open: boolean; @@ -25,7 +24,7 @@ export function AddStudentPanel({ open, onClose, onAdded }: Props) { const [searching, setSearching] = useState(false); const [searchResults, setSearchResults] = useState([]); const [picked, setPicked] = useState(null); - const [quotaMinutes, setQuotaMinutes] = useState("2"); + const [quotaMinutes, setQuotaMinutes] = useState("120"); const [adding, setAdding] = useState(false); if (!open) return null; @@ -54,12 +53,11 @@ export function AddStudentPanel({ open, onClose, onAdded }: Props) { showToast.warning(t("coaching.select_student_first")); return; } - const lessons = Number(quotaMinutes); - if (Number.isNaN(lessons) || lessons < 0) { - showToast.warning(t("coaching.invalid_lessons")); + const mins = Number(quotaMinutes); + if (Number.isNaN(mins) || mins < 0) { + showToast.warning(t("coaching.invalid_minutes")); return; } - const mins = lessonsToMinutes(lessons); setAdding(true); try { const res = await addTeacherCoachingStudent({ diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 4a3a142..b853de5 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1228,7 +1228,6 @@ "ui.minutes": "{{count}} min", "ui.minutes_short": "{{count}} m", "ui.minutes_range": "1–180 min", - "ui.lessons": "{{count}} lessons", "ui.student_fallback": "Student", "ui.student_id": "Student #{{id}}", "nav.home": "Home", @@ -1286,11 +1285,11 @@ "coaching.user_not_found": "No users found", "coaching.search_failed": "Search failed", "coaching.select_student_first": "Please select a student first", - "coaching.invalid_lessons": "Invalid lesson count", + "coaching.invalid_minutes": "Invalid remaining minutes", "coaching.add_failed": "Failed to add", "coaching.add_success": "Student added", "coaching.search_placeholder": "Search username, nickname, or phone", - "coaching.initial_quota": "Initial coaching quota (lessons)", + "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", @@ -1831,11 +1830,11 @@ "student_detail.allocated_summary": "Total allocated {{total}} · Remaining {{remaining}}", "student_detail.edit_quota": "Edit quota", "student_detail.configure_quota": "Configure coaching quota", - "student_detail.quota_desc": "Add or adjust available lessons. 1 lesson is consumed per completed post-training check.", - "student_detail.add_lessons": "Add lessons", + "student_detail.quota_desc": "Add or adjust available minutes. Cannot start sessions when quota is exhausted.", + "student_detail.add_minutes": "Add minutes", "student_detail.set_remaining": "Set remaining", - "student_detail.add_lessons_label": "Lessons to add", - "student_detail.remaining_lessons_label": "Remaining lessons", + "student_detail.add_minutes_label": "Minutes to add", + "student_detail.remaining_minutes_label": "Remaining minutes", "student_detail.review_times": "Anti-forgetting count", "student_detail.review_desc": "Ebbinghaus schedule (lesson day = day 1): first review same day, then on day N headers; list shows study time. Current: {{label}}", "student_detail.quick_actions": "Quick actions", @@ -1867,9 +1866,9 @@ "student_detail.review_updated": "Anti-forgetting count updated", "student_detail.added_wordbook": "Wordbook added", "student_detail.removed_wordbook": "Removed", - "student_detail.added_lessons": "Added {{n}} lessons, remaining {{remaining}}", - "student_detail.set_lessons": "Remaining set to {{remaining}}", - "student_detail.invalid_lessons": "Enter a non-negative integer", + "student_detail.added_minutes": "Added {{n}} minutes, remaining {{remaining}}", + "student_detail.set_minutes": "Remaining set to {{remaining}}", + "student_detail.invalid_minutes": "Enter a non-negative integer", "student_detail.student_fallback": "Student #{{id}}", "create_appointment.title": "Add session", "create_appointment.student_section": "Student info", diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index 3c12c06..1159435 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -1517,7 +1517,7 @@ "create_student.name_placeholder": "请输入学生姓名", "create_student.name_hint": "登录账号将自动生成(姓名 + 随机数字),初始密码 {{pwd}}", "create_student.hours_label": "学时", - "create_student.hours_hint": "1 课时 = 完成一次训后检测", + "create_student.hours_hint": "1 学时 = 60 分钟陪练额度", "create_student.link_existing": "已有账号?去关联添加", "create_student.submit": "创建学生", "create_student.success_title": "创建成功", @@ -1542,11 +1542,11 @@ "student_detail.allocated_summary": "累计分配 {{total}} · 剩余 {{remaining}}", "student_detail.edit_quota": "编辑额度", "student_detail.configure_quota": "配置陪练额度", - "student_detail.quota_desc": "为该学员追加或调整可上课课时数。完成训后检测时消耗 1 课时。", - "student_detail.add_lessons": "追加课时", + "student_detail.quota_desc": "为该学员追加或调整可上课分钟数。额度用尽后将无法开课计时。", + "student_detail.add_minutes": "追加分钟", "student_detail.set_remaining": "设为剩余", - "student_detail.add_lessons_label": "追加课时数", - "student_detail.remaining_lessons_label": "剩余课时数", + "student_detail.add_minutes_label": "追加分钟数", + "student_detail.remaining_minutes_label": "剩余分钟数", "student_detail.review_times": "抗遗忘次数", "student_detail.review_desc": "按艾宾浩斯曲线排期(开课日=第 1 天):学完当天即第 1 次复习,后续落在「第 N 天」表头日期;列表显示识记时段。当前:{{label}}", "student_detail.quick_actions": "快捷操作", @@ -1578,9 +1578,9 @@ "student_detail.review_updated": "抗遗忘次数已更新", "student_detail.added_wordbook": "已添加词库", "student_detail.removed_wordbook": "已移除", - "student_detail.added_lessons": "已追加 {{n}} 课时,剩余 {{remaining}}", - "student_detail.set_lessons": "剩余额度已设为 {{remaining}}", - "student_detail.invalid_lessons": "请输入非负整数课时", + "student_detail.added_minutes": "已追加 {{n}} 分钟,剩余 {{remaining}}", + "student_detail.set_minutes": "剩余额度已设为 {{remaining}}", + "student_detail.invalid_minutes": "请输入非负整数分钟", "student_detail.student_fallback": "学员 #{{id}}", "create_appointment.title": "添加课程", "create_appointment.student_section": "学生信息", @@ -1631,7 +1631,7 @@ "coach_sessions.no_pagination": "暂无分页", "coach_sessions.page": "第 {{page}}/{{total}} 页", "coach_sessions.schedule_fallback": "排课 #{{id}}", - "coach_sessions.actual_billed": "实际 {{actual}} 分钟 · 学员扣减 {{billed}} 课时", + "coach_sessions.actual_billed": "实际 {{actual}} 分钟 · 学员扣减 {{billed}} 分钟", "coach_sessions.teacher_credited": " · 计入老师 {{minutes}} 分钟", "coach_sessions.detail_title": "课程详情", "coach_sessions.course_title": "课程标题", @@ -1812,7 +1812,6 @@ "ui.minutes": "{{count}} 分钟", "ui.minutes_short": "{{count}} 分", "ui.minutes_range": "1–180 分钟", - "ui.lessons": "{{count}} 课时", "ui.student_fallback": "学员", "ui.student_id": "学员 #{{id}}", "nav.home": "首页", @@ -1870,11 +1869,11 @@ "coaching.user_not_found": "未找到用户", "coaching.search_failed": "搜索失败", "coaching.select_student_first": "请先选择学员", - "coaching.invalid_lessons": "课时数无效", + "coaching.invalid_minutes": "剩余分钟数无效", "coaching.add_failed": "添加失败", "coaching.add_success": "已添加学员", "coaching.search_placeholder": "搜索用户名、昵称或手机号", - "coaching.initial_quota": "初始陪练额度(课时)", + "coaching.initial_quota": "初始陪练额度(分钟)", "coaching.confirm_add": "确认添加", "coaching.schedule_title": "陪练排课", "coaching.schedule_hint": "点击空白节次可快速新增课程,点击已有课程查看详情", diff --git a/web/src/pages/CoachCompletedSessions.tsx b/web/src/pages/CoachCompletedSessions.tsx index a7483a0..4925305 100644 --- a/web/src/pages/CoachCompletedSessions.tsx +++ b/web/src/pages/CoachCompletedSessions.tsx @@ -161,7 +161,7 @@ export default function CoachCompletedSessions() {
{s.session?.billedMinutes != null && (

-{t("coach_sessions.actual_billed", { actual: s.session.actualMinutes ?? "-", billed: Math.round((s.session.billedMinutes ?? 0) / 60) })} +{t("coach_sessions.actual_billed", { actual: s.session.actualMinutes ?? "-", billed: s.session.billedMinutes })} {s.session.teacherCreditedMinutes != null && ( <>{t("coach_sessions.teacher_credited", { minutes: s.session.teacherCreditedMinutes })} )} @@ -262,7 +262,7 @@ export default function CoachCompletedSessions() {

{t("coach_sessions.student_deduct")} - {t("ui.lessons", { count: Math.round((detail.session?.billedMinutes ?? 0) / 60) })} + {t("create_appointment.duration_min", { n: detail.session?.billedMinutes ?? "-" })}
{t("coach_sessions.teacher_credit")} diff --git a/web/src/pages/MyStudents.tsx b/web/src/pages/MyStudents.tsx index 153c8b2..81c1633 100644 --- a/web/src/pages/MyStudents.tsx +++ b/web/src/pages/MyStudents.tsx @@ -30,7 +30,7 @@ import { resolveMediaUrl } from "../utils/mediaUrl"; import { useTranslation } from "react-i18next"; import i18n from "../i18n"; import { formatApiMessage } from "../utils/apiMessage"; -import { formatLessons } from "../utils/formatMinutes"; +import { formatTeachingMinutes } from "../utils/formatMinutes"; const DEFAULT_PASSWORD = "student123"; const PAGE_LIMIT = 20; @@ -314,7 +314,7 @@ export default function MyStudents() { ) : ( rows.map((r) => { - const low = (r.remainingMinutes || 0) < 60; + const low = (r.remainingMinutes || 0) < 30; const account = loginAccount(r); const avatar = studentAvatarUrl(r); return ( @@ -348,7 +348,7 @@ export default function MyStudents() { }`} > - {formatLessons(r.remainingMinutes || 0)} + {formatTeachingMinutes(r.remainingMinutes || 0)}

diff --git a/web/src/pages/StudentDetail.tsx b/web/src/pages/StudentDetail.tsx index 8528e83..1238861 100644 --- a/web/src/pages/StudentDetail.tsx +++ b/web/src/pages/StudentDetail.tsx @@ -49,7 +49,7 @@ import { normalizeReviewCurvePreset, reviewCurveLabel, } from "../utils/reviewCurve"; -import { formatLessons, lessonsToMinutes, minutesToLessons } from "../utils/formatMinutes"; +import { formatTeachingMinutes } from "../utils/formatMinutes"; import { isValidSnowflakeId, normalizeSnowflakeId, sameSnowflakeId } from "../utils/json-snowflake"; const DEFAULT_PASSWORD = "student123"; @@ -193,11 +193,11 @@ export default function StudentDetail() { if (!quota) return; const n = Number(quotaInput); if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) { - showToast.error(t("student_detail.invalid_lessons")); + showToast.error(t("student_detail.invalid_minutes")); return; } const nextRemaining = - quotaMode === "add" ? Math.max(0, remaining) + lessonsToMinutes(n) : lessonsToMinutes(n); + quotaMode === "add" ? Math.max(0, remaining) + n : n; setQuotaSaving(true); try { const res = await addTeacherCoachingStudent({ @@ -219,10 +219,10 @@ export default function StudentDetail() { ); showToast.success( quotaMode === "add" - ? t("student_detail.added_lessons", { n, remaining: formatLessons(nextRemaining) }) - : t("student_detail.set_lessons", { remaining: formatLessons(nextRemaining) }) + ? t("student_detail.added_minutes", { n, remaining: formatTeachingMinutes(nextRemaining) }) + : t("student_detail.set_minutes", { remaining: formatTeachingMinutes(nextRemaining) }) ); - setQuotaInput(quotaMode === "add" ? "1" : String(minutesToLessons(nextRemaining))); + setQuotaInput(quotaMode === "add" ? "60" : String(nextRemaining)); setQuotaEditOpen(false); } catch (e: unknown) { const msg = @@ -460,7 +460,7 @@ export default function StudentDetail() { const avatar = resolveMediaUrl(quota?.student?.avatar); const remaining = quota?.remainingMinutes ?? 0; const total = quota?.totalAllocatedMinutes ?? 0; - const low = remaining < 60; + const low = remaining < 30; if (!isValidSnowflakeId(studentId)) { return ( @@ -550,13 +550,13 @@ export default function StudentDetail() { low ? "text-destructive" : "text-foreground" }`} > - {formatLessons(remaining)} + {formatTeachingMinutes(remaining)}

{t("student_detail.allocated_summary", { - total: formatLessons(total), - remaining: formatLessons(remaining), + total: formatTeachingMinutes(total), + remaining: formatTeachingMinutes(remaining), })}
{ setQuotaMode("add"); - setQuotaInput("1"); + setQuotaInput("60"); setQuotaEditOpen(true); }} aria-label={t("student_detail.edit_quota")} @@ -785,8 +785,8 @@ export default function StudentDetail() { {t("student_detail.edit_quota")} {t("student_detail.allocated_summary", { - total: formatLessons(total), - remaining: formatLessons(remaining), + total: formatTeachingMinutes(total), + remaining: formatTeachingMinutes(remaining), })} @@ -798,10 +798,10 @@ export default function StudentDetail() { variant={quotaMode === "add" ? "brand" : "outline"} onClick={() => { setQuotaMode("add"); - setQuotaInput("1"); + setQuotaInput("60"); }} > - {t("student_detail.add_lessons")} + {t("student_detail.add_minutes")} { setQuotaMode("set"); - setQuotaInput(String(minutesToLessons(Math.max(0, remaining)))); + setQuotaInput(String(Math.max(0, remaining))); }} > {t("student_detail.set_remaining")} @@ -818,8 +818,8 @@ export default function StudentDetail() {
- {(quotaMode === "add" ? [1, 2, 3, 5] : [0, 1, 2, 4]).map((n) => ( + {(quotaMode === "add" ? [30, 60, 120, 180] : [0, 60, 120, 240]).map((n) => ( setQuotaInput(String(n))} > - {quotaMode === "add" ? `+${n}` : t("ui.lessons", { count: n })} + {quotaMode === "add" ? `+${n}` : t("create_appointment.duration_min", { n })} ))}
diff --git a/web/src/utils/formatMinutes.ts b/web/src/utils/formatMinutes.ts index d482bef..25a6979 100644 --- a/web/src/utils/formatMinutes.ts +++ b/web/src/utils/formatMinutes.ts @@ -6,21 +6,3 @@ export function formatTeachingMinutes(mins: number): string { const n = Math.max(0, Math.round(mins)); return i18n.t("ui.minutes", { count: n }); } - -/** Format student quota minutes as 课时 (1 课时 = 60 分钟). */ -export function formatLessons(mins: number): string { - if (!Number.isFinite(mins)) return "—"; - const lessons = Math.max(0, Math.round(mins)) / 60; - const n = Math.round(lessons * 10) / 10; - return i18n.t("ui.lessons", { count: n }); -} - -/** Convert 课时 input to minutes for API. */ -export function lessonsToMinutes(lessons: number): number { - return Math.round(lessons * 60); -} - -/** Convert minutes to 课时 for display in input fields. */ -export function minutesToLessons(mins: number): number { - return Math.round((mins / 60) * 10) / 10; -}