diff --git a/web/src/components/CoachingSchedulePanel.tsx b/web/src/components/CoachingSchedulePanel.tsx index 29e0b0e..2f06df4 100644 --- a/web/src/components/CoachingSchedulePanel.tsx +++ b/web/src/components/CoachingSchedulePanel.tsx @@ -43,15 +43,6 @@ 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) => @@ -91,33 +82,38 @@ const EVENT_MIN_H = 32; /** 时间轴相对可视区再拉高约 1/3,课块更易读 */ const AXIS_HEIGHT_SCALE = 4 / 3; -const STATUS_SOFT: Record = { +const STATUS_SOFT: Record = { scheduled: { - bg: "bg-primary/15", + bg: "bg-gradient-to-br from-primary/15 to-primary/5", text: "text-primary", + border: "border-primary", bar: "bg-primary", }, in_progress: { - bg: "bg-sky-100", + bg: "bg-gradient-to-br from-sky-100 to-sky-50", text: "text-sky-700", + border: "border-sky-500", bar: "bg-sky-500", }, completed: { - bg: "bg-muted", - text: "text-muted-foreground", - bar: "bg-muted-foreground/40", + bg: "bg-gradient-to-br from-primary/10 to-primary/5", + text: "text-primary/70", + border: "border-primary/50", + bar: "bg-primary/40", }, cancelled: { - bg: "bg-red-50", + bg: "bg-gradient-to-br from-red-50 to-red-100", text: "text-red-600", + border: "border-red-500", bar: "bg-red-500", }, }; const PAST_SOFT = { - bg: "bg-muted", - text: "text-muted-foreground", - bar: "bg-muted-foreground/40", + bg: "bg-gradient-to-br from-primary/10 to-primary/5", + text: "text-primary/70", + border: "border-primary/50", + bar: "bg-primary/40", }; /** 计划时段已结束(不含进行中) */ @@ -128,23 +124,6 @@ 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); @@ -193,6 +172,8 @@ function layoutDayEvents( axisStart: number, axisEnd: number, axisHeightPx: number, + expandedGroupKey: string | null, + raisedId: number | null, ): Array<{ schedule: CoachingWeekSchedule; topPx: number; @@ -200,6 +181,10 @@ function layoutDayEvents( showDetail: boolean; col: number; colCount: number; + overlapGroupKey: string; + overlapIndex: number; + overlapCount: number; + zIndex: number; }> { const span = Math.max(1, axisEnd - axisStart); const raw = items.map((schedule) => { @@ -210,42 +195,65 @@ function layoutDayEvents( e = Math.max(s + 15, Math.min(e, axisEnd)); return { schedule, start: s, end: e }; }); - const sorted = [...raw].sort((a, b) => a.start - b.start || b.end - a.end); - const cols = new Array(sorted.length).fill(0); - for (let i = 0; i < sorted.length; i++) { - const used = new Set(); - for (let j = 0; j < i; j++) { - if (sorted[j].end > sorted[i].start && sorted[j].start < sorted[i].end) { - used.add(cols[j]); + const groups: Array = []; + + for (const event of sorted) { + const matching = groups.filter((group) => + group.some((other) => other.end > event.start && other.start < event.end), + ); + if (matching.length === 0) { + groups.push([event]); + } else { + const target = matching[0]; + target.push(event); + for (const group of matching.slice(1)) { + target.push(...group); + groups.splice(groups.indexOf(group), 1); } } - let c = 0; - while (used.has(c)) c++; - cols[i] = c; } - const colCounts = new Array(sorted.length).fill(1); - for (let i = 0; i < sorted.length; i++) { - let max = cols[i]; - for (let j = 0; j < sorted.length; j++) { - if (sorted[j].end > sorted[i].start && sorted[j].start < sorted[i].end) { - max = Math.max(max, cols[j]); - } - } - colCounts[i] = max + 1; + const groupById = new Map(); + const groupStartByKey = new Map(); + for (const group of groups) { + const ordered = [...group].sort((a, b) => a.start - b.start || b.end - a.end); + const key = ordered.map((event) => event.schedule.id).sort((a, b) => a - b).join("-"); + groupStartByKey.set(key, ordered[0].start); + ordered.forEach((event, index) => { + groupById.set(event.schedule.id, { key, index, count: ordered.length }); + }); } - return sorted.map((ev, i) => { + return sorted.map((ev) => { const topPx = ((ev.start - axisStart) / span) * axisHeightPx; - const heightPx = Math.max(EVENT_MIN_H, ((ev.end - ev.start) / span) * axisHeightPx); + const normalHeightPx = Math.max(EVENT_MIN_H, ((ev.end - ev.start) / span) * axisHeightPx); + const group = groupById.get(ev.schedule.id) || { key: String(ev.schedule.id), index: 0, count: 1 }; + const expanded = group.count > 1 && group.key === expandedGroupKey; + const collapsedHeightPx = Math.max(EVENT_MIN_H, Math.min(normalHeightPx, 72)); + const heightPx = group.count === 1 + ? normalHeightPx + : expanded + ? Math.max(52, Math.min(normalHeightPx, 88)) + : collapsedHeightPx; + const offsetPx = group.count === 1 + ? 0 + : expanded + ? group.index * Math.min(48, Math.max(32, heightPx * 0.55)) + : group.index * 8; + const collapsedTopPx = ((groupStartByKey.get(group.key) ?? ev.start) - axisStart) / span * axisHeightPx; + return { schedule: ev.schedule, - topPx, + topPx: (!expanded && group.count > 1 ? collapsedTopPx : topPx) + offsetPx, heightPx, - showDetail: heightPx >= 40, - col: cols[i], - colCount: colCounts[i], + showDetail: group.count === 1 ? normalHeightPx >= 40 : expanded || group.index === 0, + col: 0, + colCount: 1, + overlapGroupKey: group.key, + overlapIndex: group.index, + overlapCount: group.count, + zIndex: raisedId === ev.schedule.id ? 30 : 10 + (group.count - group.index), }; }); } @@ -258,6 +266,10 @@ function TimetableBlock({ showDetail, col, colCount, + zIndex, + overlapCount, + overlapIndex, + overlapExpanded, nowTs, onClick, t, @@ -268,6 +280,10 @@ function TimetableBlock({ showDetail: boolean; col: number; colCount: number; + zIndex: number; + overlapCount: number; + overlapIndex: number; + overlapExpanded: boolean; nowTs: number; onClick: () => void; t: TFunction; @@ -276,9 +292,10 @@ function TimetableBlock({ const soft = past ? PAST_SOFT : STATUS_SOFT[schedule.status] || STATUS_SOFT.scheduled; - const { title } = lessonDisplay(t, schedule); + const { title, subtitle } = lessonDisplay(t, schedule); + const studentName = subtitle || (schedule.students?.[0]?.trim() ?? ""); const start = schedule.startTime?.slice(0, 5) || ""; - const end = displayEndLabel(schedule); + const end = schedule.endTime?.slice(0, 5) || ""; const widthPct = 100 / colCount; const leftPct = col * widthPct; @@ -289,31 +306,38 @@ function TimetableBlock({ e.stopPropagation(); onClick(); }} - className={`absolute z-[1] overflow-hidden rounded-lg ${soft.bg} text-left px-1 py-1 shadow-sm active:scale-[0.98] touch-manipulation ${ - past ? "opacity-90" : "" - }`} + className={`absolute overflow-hidden rounded-2xl border ${soft.border} bg-background ${soft.bg} text-left px-1 py-1 shadow-sm active:scale-[0.98] touch-manipulation`} style={{ top: topPx, height: heightPx, + zIndex, left: `calc(${leftPct}% + 2px)`, width: `calc(${widthPct}% - 4px)`, + maxWidth: "min(100%, 280px)", }} > - -
+
{start}{showDetail ? `–${end}` : ""}
{showDetail ? ( -
- {title} -
+ <> + {studentName ? ( +
+ {studentName} +
+ ) : null} +
+ {title} +
+ ) : null}
+ {overlapCount > 1 && !overlapExpanded && overlapIndex === 0 ? ( + + +{overlapCount - 1} + + ) : null} ); } @@ -365,19 +389,12 @@ export function CoachingSchedulePanel({ nowTs, mode = "coach" }: Props) { Record >({}); const [selected, setSelected] = useState(null); + const [expandedOverlapGroup, setExpandedOverlapGroup] = useState(null); + const [raisedOverlapId, setRaisedOverlapId] = useState(null); const [showCellTip, setShowCellTip] = useState(false); 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); @@ -388,8 +405,6 @@ export function CoachingSchedulePanel({ nowTs, mode = "coach" }: Props) { ); const todayYMD = fmtYMD(new Date()); const weekShortLabel = `${fmtMD(weekMon)}–${fmtMD(addDays(weekMon, 6))}`; - const currentWeekMon = useMemo(() => weekMonday(new Date()), []); - const isCurrentCalendarWeek = fmtYMD(weekMon) === fmtYMD(currentWeekMon); const byDay = useMemo(() => { const map: Record = {}; @@ -435,11 +450,11 @@ export function CoachingSchedulePanel({ nowTs, mode = "coach" }: Props) { ); const axisSpan = axisRange ? Math.max(1, axisRange.endMin - axisRange.startMin) : 1; - /** H5:列宽适中,周六日可右滑 */ - const dayColPx = isMobile ? 88 : 64; - const timeGutterPx = 40; - const weekGridMinW = timeGutterPx + dayColPx * 7; - const emptyGridMinW = dayColPx * 7; + /** H5:七天始终收进同一屏,桌面端保持舒适列宽 */ + const dayColPx = 64; + const timeGutterPx = isMobile ? 32 : 40; + const weekGridMinW = isMobile ? 0 : timeGutterPx + dayColPx * 7; + const emptyGridMinW = isMobile ? 0 : dayColPx * 7; const activeCount = useMemo( () => @@ -483,8 +498,7 @@ export function CoachingSchedulePanel({ nowTs, mode = "coach" }: Props) { useEffect(() => { void loadWeek(); void loadQuotas(); - if (!ttLoaded) void ttLoad(); - }, [loadWeek, loadQuotas, ttLoad, ttLoaded]); + }, [loadWeek, loadQuotas]); useLayoutEffect(() => { const el = timetableHostRef.current; @@ -696,111 +710,6 @@ export function CoachingSchedulePanel({ nowTs, mode = "coach" }: Props) { !!parseCoachingSlotEnd(selected.scheduledDate, selected.endTime) && parseCoachingSlotEnd(selected.scheduledDate, selected.endTime)!.getTime() < nowTs; - /** - * 节次网格合并项:后端预约(按时间映射到节次)+ localStorage 自定义课程。 - * 每项带来源标记 kind,点击时按 kind 分流交互。 - */ - const gridItems = useMemo(() => { - const sections = ttConfig.sections.length > 0 ? ttConfig.sections : DEFAULT_SECTIONS; - const items: GridItem[] = []; - - // 后端预约:按 scheduledDate 算出属于本周第几天,时间映射到节次 - for (const s of schedules) { - const ymd = s.scheduledDate?.slice?.(0, 10) || s.scheduledDate; - if (!ymd) continue; - const dayIdx = weekDays.findIndex((d) => fmtYMD(d) === ymd); - if (dayIdx < 0) continue; - const { startSection, endSection } = timeToSections(s.startTime, s.endTime, sections); - const past = isSchedulePast(s, nowTs); - const statusColor = - s.status === "completed" - ? "#F59E0B" - : past - ? "#A78BFA" - : s.status === "in_progress" - ? "#55A3FF" - : s.status === "cancelled" - ? "#E85555" - : "#4ECDC4"; - const { title, subtitle } = lessonDisplay(t, s); - items.push({ - key: `co-${s.id}`, - kind: "coaching", - weekDay: dayIdx + 1, - startSection, - endSection, - color: statusColor, - title, - subtitle, - meta: `${s.startTime?.slice(0, 5)}-${displayEndLabel(s)}`, - typeLabel: t("coaching.schedule_type"), - statusLabel: t(`coaching.status.${s.status}`, { defaultValue: s.status }), - schedule: s, - }); - } - - // localStorage 自定义课程:按 weekDay 归属到本周(不区分具体日期) - for (const c of ttCourses) { - if (c.weekDay < 1 || c.weekDay > 7) continue; - items.push({ - key: `cu-${c.id}`, - kind: "course", - weekDay: c.weekDay, - startSection: c.startSection, - endSection: c.endSection, - color: c.color, - title: c.name, - subtitle: c.room ? `@${c.room}` : undefined, - meta: weekRangeLabel(c), - typeLabel: t("timetable.custom_course"), - course: c, - }); - } - - const byDay = new Map(); - for (const item of items) { - const dayItems = byDay.get(item.weekDay) || []; - dayItems.push(item); - byDay.set(item.weekDay, dayItems); - } - - return Array.from(byDay.values()).flatMap((dayItems) => { - const sorted = [...dayItems].sort( - (a, b) => a.startSection - b.startSection || b.endSection - a.endSection, - ); - const laneEnds: number[] = []; - const assigned = sorted.map((item) => { - let lane = laneEnds.findIndex((end) => end < item.startSection); - if (lane < 0) { - lane = laneEnds.length; - laneEnds.push(item.endSection); - } else { - laneEnds[lane] = item.endSection; - } - return { item, lane }; - }); - const laneCount = Math.max(1, laneEnds.length); - - return assigned.map(({ item, lane }) => ({ - ...item, - lane, - laneCount, - conflict: laneCount > 1 && dayItems.some( - (other) => - other.key !== item.key && - other.startSection <= item.endSection && - other.endSection >= item.startSection, - ), - })); - }); - }, [schedules, ttCourses, ttConfig.sections, weekDays, t, nowTs]); - - function openCourseEditor(course: Course | null, preset: { weekDay: number; startSection: number } | null) { - setEditingCourse(course); - setCoursePreset(preset); - setEditorOpen(true); - } - const weekTrigger = ( + ); + })} + {weekDays.map((d, i) => { + const ymd = fmtYMD(d); + const isToday = ymd === todayYMD; + return ( + + ); + })} +
+ ) : ( +
+
+ {weekDays.map((d, i) => { + const ymd = fmtYMD(d); + const isToday = ymd === todayYMD; + return ( + + ); + })} + +
+ {axisMarks.map((m) => ( +
+ + {fmtMinutes(m).slice(0, 5)} + +
+ ))} +
+ + {weekDays.map((d, dIdx) => { + const ymd = fmtYMD(d); + const isToday = ymd === todayYMD; + const isPastDay = ymd < todayYMD; + const dayItems = byDay[ymd] || []; + const laidOut = layoutDayEvents( + dayItems, + axisRange.startMin, + axisRange.endMin, + axisHeightPx, + expandedOverlapGroup, + raisedOverlapId, + ); + 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 ? ( +
+ ); + })} +
+ )}
)}
@@ -951,7 +1048,7 @@ export function CoachingSchedulePanel({ nowTs, mode = "coach" }: Props) {

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

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

{t("coaching.empty_schedule_title")}

-

{t("coaching.empty_schedule_desc")}

-
-
- )} - - {/* 课程/预约色块 */} - {items.map((item) => { - const col = item.weekDay + 1; - const rowStart = item.startSection + 1; - const rowEnd = item.endSection + 2; - const span = item.endSection - item.startSection + 1; - const showDetail = span >= 2; - return ( - - ); - })}
); }