diff --git a/memhub/apps/web/src/MemoryModeSettings.test.tsx b/memhub/apps/web/src/MemoryModeSettings.test.tsx index 7566a09..47a849b 100644 --- a/memhub/apps/web/src/MemoryModeSettings.test.tsx +++ b/memhub/apps/web/src/MemoryModeSettings.test.tsx @@ -300,13 +300,131 @@ it("shows the default nightly evolve controls in ultra", async () => { "aria-checked", ), ).toBe("true"); - expect((screen.getByLabelText("执行时间") as HTMLInputElement).value).toBe( - "03:00", - ); + const timePicker = screen.getByRole("button", { + name: "执行时间,03:00", + }); + expect(timePicker.tagName).toBe("BUTTON"); + expect(timePicker.textContent).toContain("03:00"); expect(screen.getByText(/只运行这一次,不改变每天的计划/)).toBeTruthy(); expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy(); }); +it("opens the time picker from the whole control and saves a complete time", async () => { + prepare("ultra"); + vi.mocked(setEvolveSchedule).mockResolvedValue({ + enabled: true, + time: "04:30", + }); + const user = userEvent.setup(); + render(); + + const trigger = await screen.findByRole("button", { + name: "执行时间,03:00", + }); + await user.click(trigger); + + const picker = screen.getByRole("dialog", { name: "执行时间" }); + await user.selectOptions( + within(picker).getByRole("listbox", { name: "执行时间 HH" }), + "04", + ); + await user.selectOptions( + within(picker).getByRole("listbox", { name: "执行时间 MM" }), + "30", + ); + expect(setEvolveSchedule).not.toHaveBeenCalled(); + + await user.click(within(picker).getByRole("button", { name: "保存修改" })); + await waitFor(() => + expect(setEvolveSchedule).toHaveBeenCalledExactlyOnceWith({ + time: "04:30", + }), + ); + await waitFor(() => + expect(screen.getByRole("button", { name: "执行时间,04:30" })).toBe( + document.activeElement, + ), + ); + expect(screen.queryByRole("dialog", { name: "执行时间" })).toBeNull(); +}); + +it("keeps Tab inside the time picker and restores focus on Escape", async () => { + prepare("ultra"); + const user = userEvent.setup(); + render(); + + const trigger = await screen.findByRole("button", { + name: "执行时间,03:00", + }); + await user.click(trigger); + const picker = screen.getByRole("dialog", { name: "执行时间" }); + const hours = within(picker).getByRole("listbox", { + name: "执行时间 HH", + }); + const save = within(picker).getByRole("button", { name: "保存修改" }); + expect(document.activeElement).toBe(hours); + await user.keyboard("{Shift>}{Tab}{/Shift}"); + expect(document.activeElement).toBe(save); + await user.keyboard("{Tab}"); + expect(document.activeElement).toBe(hours); + + await user.keyboard("{Escape}"); + + expect(screen.queryByRole("dialog", { name: "执行时间" })).toBeNull(); + expect(document.activeElement).toBe(trigger); +}); + +it("keeps the selected time open for retry when saving fails", async () => { + prepare("ultra"); + vi.mocked(setEvolveSchedule).mockRejectedValue(new Error("offline")); + const user = userEvent.setup(); + render(); + + await user.click( + await screen.findByRole("button", { name: "执行时间,03:00" }), + ); + const picker = screen.getByRole("dialog", { name: "执行时间" }); + const minutes = within(picker).getByRole("listbox", { + name: "执行时间 MM", + }); + await user.selectOptions(minutes, "30"); + await user.click(within(picker).getByRole("button", { name: "保存修改" })); + + expect( + await screen.findByText("自动整合设置未能保存,请重试。"), + ).toBeTruthy(); + expect(screen.getByRole("dialog", { name: "执行时间" })).toBeTruthy(); + expect((minutes as HTMLSelectElement).value).toBe("30"); + await waitFor(() => + expect(document.activeElement).toBe( + within(picker).getByRole("button", { name: "保存修改" }), + ), + ); +}); + +it("keeps the time dialog and focus stable while saving is pending", async () => { + prepare("ultra"); + vi.mocked(setEvolveSchedule).mockReturnValue(new Promise(() => {})); + const user = userEvent.setup(); + render(); + + await user.click( + await screen.findByRole("button", { name: "执行时间,03:00" }), + ); + const picker = screen.getByRole("dialog", { name: "执行时间" }); + await user.selectOptions( + within(picker).getByRole("listbox", { name: "执行时间 MM" }), + "30", + ); + await user.click(within(picker).getByRole("button", { name: "保存修改" })); + await waitFor(() => expect(setEvolveSchedule).toHaveBeenCalledTimes(1)); + + expect(document.activeElement).toBe(picker); + await user.keyboard("{Escape}"); + expect(screen.getByRole("dialog", { name: "执行时间" })).toBe(picker); + expect(document.activeElement).toBe(picker); +}); + it("hides the time field when automatic evolve is off and keeps the saved time visible", async () => { prepare("ultra"); vi.mocked(setEvolveSchedule).mockResolvedValue({ @@ -322,7 +440,7 @@ it("hides the time field when automatic evolve is off and keeps the saved time v await user.click(automatic); await screen.findByText(/重新开启后仍按 03:00 运行/); - expect(screen.queryByLabelText("执行时间")).toBeNull(); + expect(screen.queryByRole("button", { name: /执行时间/ })).toBeNull(); expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy(); }); diff --git a/memhub/apps/web/src/MemoryModeSettings.tsx b/memhub/apps/web/src/MemoryModeSettings.tsx index c09634d..d7c630c 100644 --- a/memhub/apps/web/src/MemoryModeSettings.tsx +++ b/memhub/apps/web/src/MemoryModeSettings.tsx @@ -29,6 +29,7 @@ import { MemoryModeChoiceList } from "./MemoryModeChoiceList"; import { useOnboardingTranslation } from "./onboarding-language"; import { R6Button, R6Switch } from "./R6Controls"; import { R6StateRecovery } from "./R6StateRecovery"; +import { TimePicker } from "./TimePicker"; const MODE_COPY: Record< MemoryPipelineMode, @@ -332,8 +333,10 @@ export function MemoryModeSettings({ setError(""); try { setScheduleState(await setEvolveSchedule(update)); + return true; } catch { setError("自动整合设置未能保存,请重试。"); + return false; } finally { setBusy(false); } @@ -455,14 +458,12 @@ export function MemoryModeSettings({

{text("使用这台设备的本地时间。")}

- - void changeSchedule({ time: event.target.value }) - } + onChange={(time) => changeSchedule({ time })} /> ) : null} diff --git a/memhub/apps/web/src/TimePicker.tsx b/memhub/apps/web/src/TimePicker.tsx new file mode 100644 index 0000000..b424806 --- /dev/null +++ b/memhub/apps/web/src/TimePicker.tsx @@ -0,0 +1,265 @@ +import { Check, ChevronDown, Clock3 } from "lucide-react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { useOnboardingTranslation } from "./onboarding-language"; +import { R6Button } from "./R6Controls"; + +const HOURS = Array.from({ length: 24 }, (_, value) => + value.toString().padStart(2, "0"), +); +const MINUTES = Array.from({ length: 60 }, (_, value) => + value.toString().padStart(2, "0"), +); + +function splitTime(value: string): [string, string] { + const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value); + return match ? [match[1], match[2]] : ["00", "00"]; +} + +export function TimePicker({ + id, + label, + value, + disabled = false, + onChange, +}: { + id: string; + label: string; + value: string; + disabled?: boolean; + onChange(value: string): Promise; +}) { + const { locale, text } = useOnboardingTranslation(); + const [open, setOpen] = useState(false); + const [saving, setSaving] = useState(false); + const [placement, setPlacement] = useState<"above" | "below">("above"); + const [hour, setHour] = useState(() => splitTime(value)[0]); + const [minute, setMinute] = useState(() => splitTime(value)[1]); + const root = useRef(null); + const trigger = useRef(null); + const panel = useRef(null); + const hourSelect = useRef(null); + const minuteSelect = useRef(null); + const restoreTriggerFocus = useRef(false); + const restoreSaveFocus = useRef(false); + const savingRef = useRef(false); + savingRef.current = saving; + const panelId = `${id}-picker`; + + const close = useCallback((restoreFocus = true) => { + setOpen(false); + restoreTriggerFocus.current = restoreFocus; + }, []); + + function show() { + const [nextHour, nextMinute] = splitTime(value); + setHour(nextHour); + setMinute(nextMinute); + setOpen(true); + } + + useEffect(() => { + if (!open) return; + const closeOutside = (event: MouseEvent) => { + if (savingRef.current) return; + const target = event.target as Element | null; + const label = target?.closest("label"); + if (label instanceof HTMLLabelElement && label.htmlFor === id) return; + if (!root.current?.contains(target)) close(false); + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key !== "Escape" || savingRef.current) return; + event.preventDefault(); + close(); + }; + document.addEventListener("mousedown", closeOutside); + document.addEventListener("keydown", closeOnEscape); + hourSelect.current?.focus(); + hourSelect.current?.selectedOptions[0]?.scrollIntoView?.({ + block: "nearest", + }); + minuteSelect.current?.selectedOptions[0]?.scrollIntoView?.({ + block: "nearest", + }); + return () => { + document.removeEventListener("mousedown", closeOutside); + document.removeEventListener("keydown", closeOnEscape); + }; + }, [close, id, open]); + + useEffect(() => { + if (disabled && open && !saving) close(false); + if (!disabled && !open && restoreTriggerFocus.current) { + restoreTriggerFocus.current = false; + trigger.current?.focus(); + } + if (!disabled && open && restoreSaveFocus.current) { + restoreSaveFocus.current = false; + panel.current + ?.querySelector("[data-time-picker-save]") + ?.focus(); + } + if (saving && open) panel.current?.focus(); + }, [close, disabled, open, saving]); + + useLayoutEffect(() => { + if (!open) return; + const place = () => { + const anchor = trigger.current?.getBoundingClientRect(); + const picker = panel.current?.getBoundingClientRect(); + if (!anchor || !picker) return; + const scroller = trigger.current?.closest( + ".app-content, .onboarding-frame", + ); + const bounds = scroller?.getBoundingClientRect(); + const topEdge = (bounds ? Math.max(0, bounds.top) : 0) + 12; + const bottomEdge = + (bounds + ? Math.min(window.innerHeight, bounds.bottom) + : window.innerHeight) - 12; + const above = anchor.top - topEdge; + const below = bottomEdge - anchor.bottom; + setPlacement( + above >= picker.height || above >= below ? "above" : "below", + ); + }; + place(); + window.addEventListener("resize", place); + window.addEventListener("scroll", place, true); + return () => { + window.removeEventListener("resize", place); + window.removeEventListener("scroll", place, true); + }; + }, [open]); + + async function save() { + const next = `${hour}:${minute}`; + if (next === value) { + close(); + return; + } + setSaving(true); + const saved = await onChange(next); + setSaving(false); + if (saved) close(); + else restoreSaveFocus.current = true; + } + + return ( +
+ + {open ? ( + + ) : null} +
+ ); +} diff --git a/memhub/apps/web/src/r6-settings.css b/memhub/apps/web/src/r6-settings.css index 38481e2..c3d77c7 100644 --- a/memhub/apps/web/src/r6-settings.css +++ b/memhub/apps/web/src/r6-settings.css @@ -295,7 +295,7 @@ padding-top: 16px; border-top: 1px solid #e1e8ed; } -.r6-settings .r6-evolve-time label { +.r6-settings .r6-evolve-time > div > label { color: #304757; font-size: 13px; font-weight: 600; @@ -304,20 +304,146 @@ .r6-evolve-time p { margin-top: 1px; } -.r6-evolve-settings input[type="time"] { +.r6-time-picker { + position: relative; + width: 180px; +} +.r6-time-picker-trigger { box-sizing: border-box; width: 180px; - min-height: 44px; - padding: 0 10px; + height: 44px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0 12px 0 14px; border: 1px solid #c6d2da; border-radius: 8px; color: #304757; background: #fff; + font: + 500 14px / 22px "Noto Sans SC", + "PingFang SC", + sans-serif; + font-variant-numeric: tabular-nums; + cursor: pointer; + transition: + border-color 140ms ease-out, + background-color 140ms ease-out, + box-shadow 140ms ease-out; } -.r6-evolve-settings input[type="time"]:focus-visible { +.r6-time-picker-trigger:hover:not(:disabled) { + border-color: #9eafbb; + background: #f8fafb; +} +.r6-time-picker-trigger[aria-expanded="true"] { + border-color: #7892a6; + box-shadow: 0 0 0 3px rgb(85 124 147 / 12%); +} +.r6-time-picker-trigger:focus-visible { outline: 2px solid var(--r6-focus); outline-offset: 2px; } +.r6-time-picker-trigger:disabled { + color: #71808b; + background: #edf0f3; + cursor: default; +} +.r6-time-picker-trigger:active:not(:disabled) { + transform: none; +} +.r6-time-picker-icons { + display: inline-flex; + align-items: center; + gap: 5px; + color: #607888; +} +.r6-time-picker-trigger[aria-expanded="true"] + .r6-time-picker-icons + svg:last-child { + transform: rotate(180deg); +} +.r6-time-picker-panel { + box-sizing: border-box; + position: absolute; + z-index: 1200; + right: 0; + bottom: calc(100% + 8px); + width: min(280px, calc(100vw - 32px)); + padding: 14px; + border: 1px solid #c6d2da; + border-radius: 10px; + background: #fff; + box-shadow: 0 14px 36px rgb(48 71 87 / 16%); +} +.r6-time-picker-panel[data-placement="below"] { + top: calc(100% + 8px); + bottom: auto; +} +.r6-time-picker-columns { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: end; + gap: 8px; +} +.r6-time-picker-column { + display: grid; + gap: 6px; + color: #5e6e7a; + font-size: 11px; + font-weight: 600; + line-height: 18px; + text-align: center; +} +.r6-time-picker-columns select { + box-sizing: border-box; + width: 100%; + height: 174px; + padding: 4px; + border: 1px solid #d5dfe8; + border-radius: 7px; + color: #304757; + background: #f8fafb; + font: + 500 14px / 24px "Noto Sans SC", + "PingFang SC", + sans-serif; + font-variant-numeric: tabular-nums; + scrollbar-width: thin; + scrollbar-color: #b6c3cc transparent; +} +.r6-time-picker-columns select:focus-visible { + outline: 2px solid var(--r6-focus); + outline-offset: 1px; +} +.r6-time-picker-columns option { + min-height: 28px; + padding: 5px 8px; + border-radius: 5px; +} +.r6-time-picker-columns option:checked { + color: #304757; + background: #dfe9ef; +} +.r6-time-picker-separator { + align-self: center; + margin-top: 22px; + color: #7892a6; + font-size: 18px; + font-weight: 600; +} +.r6-time-picker-panel footer { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid #e1e8ed; + flex-wrap: wrap; +} +.r6-time-picker-panel footer .r6-button { + min-width: 68px; +} .r6-state-recovery { display: flex; align-items: center; @@ -1059,9 +1185,14 @@ .r6-evolve-time { gap: 8px; } - .r6-evolve-settings input[type="time"] { + .r6-time-picker, + .r6-time-picker-trigger { width: min(220px, 100%); } + .r6-time-picker-panel { + right: auto; + left: 0; + } .r6-state-recovery { align-items: flex-start; flex-direction: column;