Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
74d985a
fix(coaching): 点击空白区域收起展开课程卡片
17793426456 Sep 8, 2026
eec9032
fix(coaching): 点击卡片外区域恢复课程堆叠
17793426456 Sep 8, 2026
f8f9ea8
Merge remote-tracking branch 'upstream/main'
17793426456 Sep 9, 2026
1d9d49b
fix(note): 随心记与单词笔记分开,点击随心记时重置为全局笔记
17793426456 Sep 9, 2026
dce8fe7
fix(pre-training): 底栏「全选」「选择5个」改为「✔5个」「❌5个」
17793426456 Sep 9, 2026
feb3b75
fix(pre-training): 删除底栏「全选」按钮
17793426456 Sep 9, 2026
e62f967
fix(anti-forgetting): 同一节课合并显示且训练时间显示为时间段
17793426456 Sep 9, 2026
f691379
fix(pre-training): 按参考样式调整批量标记按钮
17793426456 Sep 9, 2026
6b781ff
fix(word-practice): 人工带读时高亮当前朗读单词
17793426456 Sep 9, 2026
fc17e76
fix(word-practice): 人工带读支持整张单词卡选中
17793426456 Sep 9, 2026
ebb727c
fix(word-practice): 隔离人工带读与自动练习逻辑
17793426456 Sep 9, 2026
ba37958
revert(word-practice): 回退人工带读的选中逻辑改动
17793426456 Sep 9, 2026
d6f8d24
fix(word-practice): 人工带读框选当前朗读单词
17793426456 Sep 9, 2026
0f0770f
fix(coaching): 固定重叠课卡的规律堆叠布局
17793426456 Sep 9, 2026
99d4b3d
fix(coaching): 恢复课卡展开并匹配参考图排版
17793426456 Sep 9, 2026
55e35cb
fix(coaching): 校正课卡堆叠层级和展开尺寸
17793426456 Sep 9, 2026
9cfa6c3
fix(coaching): 收紧重叠课卡并按可视高度分组
17793426456 Sep 9, 2026
0b6118c
fix(coaching): 让衔接课卡统一进入堆叠组
17793426456 Sep 9, 2026
f59afa0
fix(coaching): 仅按真实时间重叠展开课卡
17793426456 Sep 9, 2026
2934f9b
Merge remote-tracking branch 'upstream/main'
17793426456 Sep 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions internal/handlers/review.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,6 @@ func reviewBooksByDateForUsers(db *gorm.DB, userIDs []uint, dayStart, dayEnd tim

type aggKey struct {
StudentID uint
SessionID uint
WordBookID uint
}
type aggVal struct {
Expand Down Expand Up @@ -209,7 +208,7 @@ func reviewBooksByDateForUsers(db *gorm.DB, userIDs []uint, dayStart, dayEnd tim
continue
}

key := aggKey{StudentID: r.StudentID, SessionID: r.SessionID, WordBookID: r.WordBookID}
key := aggKey{StudentID: r.StudentID, WordBookID: r.WordBookID}
cur, ok := aggs[key]
if !ok {
cur = &aggVal{
Expand All @@ -227,6 +226,17 @@ func reviewBooksByDateForUsers(db *gorm.DB, userIDs []uint, dayStart, dayEnd tim
}
aggs[key] = cur
}
// 合并同一学员同词库的多节课:取最早开始、最晚结束
if r.PracticeStartedAt != nil {
if cur.stat.PracticeStartedAt == nil || r.PracticeStartedAt.Before(*cur.stat.PracticeStartedAt) {
cur.stat.PracticeStartedAt = r.PracticeStartedAt
}
}
if r.PracticeEndedAt != nil {
if cur.stat.PracticeEndedAt == nil || r.PracticeEndedAt.After(*cur.stat.PracticeEndedAt) {
cur.stat.PracticeEndedAt = r.PracticeEndedAt
}
}
cur.words[r.WordID] = struct{}{}
}

Expand Down Expand Up @@ -259,7 +269,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)。
Expand Down
66 changes: 45 additions & 21 deletions web/src/components/CoachingSchedulePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ function layoutDayEvents(
zIndex: number;
}> {
const span = Math.max(1, axisEnd - axisStart);
const STACK_OFFSET_PX = 8;
const raw = items.map((schedule) => {
const range = scheduleVisualRange(schedule);
let s = parseHmToMinutes(range.start);
Expand Down Expand Up @@ -258,30 +259,30 @@ function layoutDayEvents(
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 collapsedHeightPx = Math.max(EVENT_MIN_H, Math.min(normalHeightPx, 48));
const heightPx = group.count === 1
? normalHeightPx
: expanded
? Math.max(52, Math.min(normalHeightPx, 88))
? Math.max(56, Math.min(normalHeightPx, 64))
: collapsedHeightPx;
const offsetPx = group.count === 1
? 0
: expanded
? group.index * Math.min(48, Math.max(32, heightPx * 0.55))
: group.index * 8;
? group.index * 44
: group.index * STACK_OFFSET_PX;
const collapsedTopPx = ((groupStartByKey.get(group.key) ?? ev.start) - axisStart) / span * axisHeightPx;

return {
schedule: ev.schedule,
topPx: (!expanded && group.count > 1 ? collapsedTopPx : topPx) + offsetPx,
topPx: (group.count > 1 ? collapsedTopPx : topPx) + offsetPx,
heightPx,
showDetail: group.count === 1 ? normalHeightPx >= 40 : expanded || group.index === 0,
showDetail: group.count === 1 ? normalHeightPx >= 40 : expanded || group.index === group.count - 1,
col: 0,
colCount: 1,
overlapGroupKey: group.key,
overlapIndex: group.index,
overlapCount: group.count,
zIndex: raisedId === ev.schedule.id ? 30 : 10 + (group.count - group.index),
zIndex: raisedId === ev.schedule.id ? 30 : 10 + group.index,
};
});
}
Expand Down Expand Up @@ -331,11 +332,12 @@ function TimetableBlock({
return (
<button
type="button"
data-timetable-block
onClick={(e) => {
e.stopPropagation();
onClick();
}}
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`}
className={`absolute overflow-hidden rounded-[24px] border ${soft.border} bg-background text-left px-2.5 py-2 shadow-[0_2px_6px_rgba(0,0,0,0.16)] active:scale-[0.98] touch-manipulation`}
style={{
top: topPx,
height: heightPx,
Expand All @@ -345,25 +347,24 @@ function TimetableBlock({
maxWidth: "min(100%, 280px)",
}}
>
<div className="min-w-0 h-full flex flex-col justify-center">
<div className={`text-[10px] font-semibold tabular-nums leading-tight ${soft.text}`}>
{start}{showDetail ? `–${end}` : ""}
</div>
<div className="flex h-full min-w-0 flex-col items-center justify-center text-center">
{showDetail ? (
<>
{studentName ? (
<div className="text-[11px] font-medium leading-snug line-clamp-1 mt-0.5 text-foreground">
{studentName}
</div>
) : null}
<div className={`text-[11px] font-medium leading-snug line-clamp-2 mt-0.5 ${past ? "text-muted-foreground" : "text-foreground"}`}>
{title}
<div className="line-clamp-1 text-sm font-medium leading-tight text-foreground">
{studentName && title && studentName !== title ? `${studentName}(${title})` : studentName || title}
</div>
<div className={`mt-0.5 text-sm font-medium leading-tight tabular-nums ${past ? "text-muted-foreground" : soft.text}`}>
{start}–{end}
</div>
</>
) : null}
) : (
<div className={`text-sm font-medium leading-tight tabular-nums ${past ? "text-muted-foreground" : soft.text}`}>
{start}
</div>
)}
</div>
{overlapCount > 1 && !overlapExpanded && overlapIndex === 0 ? (
<span className="absolute right-1 top-1 rounded-full bg-foreground/10 px-1 text-[9px] font-semibold text-foreground/70">
<span className="absolute right-2 top-1 rounded-full bg-foreground/10 px-1.5 text-[10px] font-semibold text-foreground/70">
+{overlapCount - 1}
</span>
) : null}
Expand Down Expand Up @@ -427,6 +428,18 @@ export function CoachingSchedulePanel({ nowTs, mode = "coach" }: Props) {
const timetableHostRef = useRef<HTMLDivElement>(null);
const [axisHeightPx, setAxisHeightPx] = useState(280);

useEffect(() => {
if (!expandedOverlapGroup) return;
const collapseOnOutsidePointer = (event: PointerEvent) => {
const target = event.target;
if (target instanceof Element && target.closest("[data-timetable-block]")) return;
setExpandedOverlapGroup(null);
setRaisedOverlapId(null);
};
document.addEventListener("pointerdown", collapseOnOutsidePointer);
return () => document.removeEventListener("pointerdown", collapseOnOutsidePointer);
}, [expandedOverlapGroup]);

const weekMon = useMemo(() => weekMonday(weekAnchor), [weekAnchor]);
const weekDays = useMemo(
() => Array.from({ length: 7 }, (_, i) => addDays(weekMon, i)),
Expand Down Expand Up @@ -993,6 +1006,12 @@ export function CoachingSchedulePanel({ nowTs, mode = "coach" }: Props) {
dIdx < 6 ? "border-r border-border/30" : ""
}`}
style={{ height: axisHeightPx }}
onClick={() => {
if (expandedOverlapGroup) {
setExpandedOverlapGroup(null);
setRaisedOverlapId(null);
}
}}
>
{isPastDay ? (
<div
Expand Down Expand Up @@ -1022,6 +1041,11 @@ export function CoachingSchedulePanel({ nowTs, mode = "coach" }: Props) {
className="absolute inset-0 z-0 touch-manipulation"
onClick={() => {
dismissCellTip();
if (expandedOverlapGroup) {
setExpandedOverlapGroup(null);
setRaisedOverlapId(null);
return;
}
openScheduleForDay(d);
}}
/>
Expand Down
8 changes: 8 additions & 0 deletions web/src/components/NoteContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ type NoteContextValue = ReturnType<typeof useSplitScreenNote> & {
storageKey: string;
setStorageKey: (key: string) => void;
openNote: (key: string, title?: string) => void;
openDefaultNote: () => void;
noteTitle: string;
setNoteTitle: (title: string) => void;
defaultTitle: string;
Expand All @@ -27,13 +28,20 @@ export function NoteProvider({ children }: { children: ReactNode }) {
note.setOpen(true);
};

const openDefaultNote = () => {
setStorageKey("");
setNoteTitle(defaultTitle);
note.setOpen(true);
};

return (
<NoteContext.Provider
value={{
...note,
storageKey,
setStorageKey,
openNote,
openDefaultNote,
noteTitle,
setNoteTitle,
defaultTitle,
Expand Down
2 changes: 2 additions & 0 deletions web/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1524,6 +1524,8 @@
"practice.simple_tip_on": "Simple mode: tap for full expand",
"practice.simple_tip_off": "Full mode: tap for simple",
"practice.select_five": "Select 5",
"practice.mark_correct_5": "5 correct",
"practice.mark_wrong_5": "5 incorrect",
"practice.start_learning": "Start learning",
"practice.start_learning_count": "Learn {{count}} words",
"practice.screened_count": "{{count}} screened",
Expand Down
2 changes: 2 additions & 0 deletions web/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1234,6 +1234,8 @@
"practice.simple_tip_on": "当前简易:点击查看全部拓展",
"practice.simple_tip_off": "当前全部:点击切回简易",
"practice.select_five": "选择5个",
"practice.mark_correct_5": "5个正确",
"practice.mark_wrong_5": "5个错误",
"practice.start_learning": "开始识记",
"practice.start_learning_count": "记 {{count}} 个",
"practice.screened_count": "已筛 {{count}} 个",
Expand Down
16 changes: 13 additions & 3 deletions web/src/pages/AntiForgetting.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type ReviewTask = {
timeSort: number;
trainingAt: string;
practiceStartedAt?: string;
practiceEndedAt?: string | null;
};

type TimeSlotGroup = {
Expand All @@ -36,7 +37,7 @@ function studentDisplayName(row: ReviewBookStatRow): string {
return id ? i18n.t("student_detail.student_fallback", { id }) : i18n.t("anti_forgetting.current_user");
}

function clockParts(iso: string | null | undefined, tz: string) {
function clockParts(iso: string | null | undefined, endIso: string | null | undefined, tz: string) {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
Expand All @@ -58,10 +59,18 @@ function clockParts(iso: string | null | undefined, tz: string) {
const day = parts.find((p) => p.type === "day")?.value ?? "";
const slot = timeFmt.format(d);
const [hh, mm] = slot.split(":").map((x) => Number(x));
// 结束时间:有 endedAt 则用,否则同开始时间(显示 17:05~17:05)
let endSlot = slot;
if (endIso) {
const endD = new Date(endIso);
if (!Number.isNaN(endD.getTime())) {
endSlot = timeFmt.format(endD);
}
}
return {
slot,
sort: (Number.isFinite(hh) ? hh : 0) * 60 + (Number.isFinite(mm) ? mm : 0),
trainingAt: `${y}-${mo}-${day} ${slot}`,
trainingAt: `${y}-${mo}-${day} ${slot}~${endSlot}`,
};
}

Expand Down Expand Up @@ -118,7 +127,7 @@ export default function AntiForgetting() {
const studentId = normalizeSnowflakeId(b.studentId) || "self";
const wordBookId = normalizeSnowflakeId(b.wordBookId);
const sessionId = normalizeSnowflakeId(b.sessionId);
const clock = clockParts(b.practiceStartedAt, timeZone);
const clock = clockParts(b.practiceStartedAt, b.practiceEndedAt, timeZone);
return {
id: `${studentId}-${wordBookId}-${sessionId || "0"}`,
studentId,
Expand All @@ -132,6 +141,7 @@ export default function AntiForgetting() {
timeSort: clock?.sort ?? 9999,
trainingAt: clock?.trainingAt || "—",
practiceStartedAt: b.practiceStartedAt,
practiceEndedAt: b.practiceEndedAt,
};
});
}, [bookStats, timeZone]);
Expand Down
2 changes: 1 addition & 1 deletion web/src/pages/PostTrainingCheck.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -737,7 +737,7 @@ export default function PostTrainingCheck() {
type="button"
variant={note.open ? "brand" : "outline"}
size="pill"
onClick={() => note.setOpen((value) => !value)}
onClick={() => (note.open ? note.setOpen(false) : note.openDefaultNote())}
aria-label={t("practice.open_free_note")}
title={t("practice.open_free_note")}
className="max-sm:px-2 max-sm:text-xs"
Expand Down
34 changes: 13 additions & 21 deletions web/src/pages/PreTrainingCheck.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -303,23 +303,13 @@ export default function PreTrainingCheck() {
void loadWords(1, true);
}, [loadWords]);

const handleSelectAll = useCallback(() => {
setWords((prev) => {
const allSelected = prev.every((word) => word.status !== null);
if (allSelected) {
return prev.map((word) => ({ ...word, status: null as WordItem["status"] }));
}
return prev.map((word) => ({ ...word, status: "wrong" as WordItem["status"] }));
});
}, []);

const handleSelect5 = useCallback(() => {
const handleSelect5 = useCallback((status: "correct" | "wrong") => {
setWords((prev) => {
const unselected = prev.filter((word) => word.status === null);
const toSelect = unselected.slice(0, 5);
return prev.map((word) => {
if (toSelect.find((w) => w.id === word.id)) {
return { ...word, status: "wrong" as WordItem["status"] };
return { ...word, status };
}
return word;
});
Expand Down Expand Up @@ -606,7 +596,7 @@ export default function PreTrainingCheck() {
type="button"
variant={note.open ? "brand" : "outline"}
size="pill"
onClick={() => note.setOpen((value) => !value)}
onClick={() => (note.open ? note.setOpen(false) : note.openDefaultNote())}
aria-label={t("practice.open_free_note")}
title={t("practice.open_free_note")}
className="shrink-0 max-sm:px-2 max-sm:text-xs"
Expand Down Expand Up @@ -681,20 +671,22 @@ export default function PreTrainingCheck() {
</CloudButton>
)}
<CloudButton
variant="outline"
variant="mint"
size="pill"
onClick={handleSelectAll}
className="shrink-0 max-sm:px-2 max-sm:text-xs"
className="h-10 shrink-0 px-4 text-sm font-normal max-sm:px-3"
onClick={() => handleSelect5("correct")}
>
{t("practice.select_all")}
<Check size={16} />
{t("practice.mark_correct_5")}
</CloudButton>
<CloudButton
variant="brandOutline"
variant="destructive"
size="pill"
className="shrink-0 max-sm:px-2 max-sm:text-xs"
onClick={handleSelect5}
className="h-10 shrink-0 px-4 text-sm font-normal max-sm:px-3"
onClick={() => handleSelect5("wrong")}
>
{t("practice.select_five")}
<X size={16} />
{t("practice.mark_wrong_5")}
</CloudButton>
</div>
<CloudButton
Expand Down
2 changes: 1 addition & 1 deletion web/src/pages/ReviewCheck.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ export default function ReviewCheck() {
type="button"
variant={note.open ? "brand" : "outline"}
size="pill"
onClick={() => note.setOpen((value) => !value)}
onClick={() => (note.open ? note.setOpen(false) : note.openDefaultNote())}
aria-label={t("practice.open_free_note")}
title={t("practice.open_free_note")}
className="max-sm:px-2 max-sm:text-xs"
Expand Down
Loading
Loading