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 ? (
+
{
+ if (event.key !== "Tab") return;
+ const controls = Array.from(
+ event.currentTarget.querySelectorAll(
+ "select:not(:disabled), button:not(:disabled)",
+ ),
+ );
+ const first = controls[0];
+ const last = controls.at(-1);
+ if (!first || !last) {
+ event.preventDefault();
+ event.currentTarget.focus();
+ return;
+ }
+ if (event.shiftKey && document.activeElement === first) {
+ event.preventDefault();
+ last.focus();
+ } else if (!event.shiftKey && document.activeElement === last) {
+ event.preventDefault();
+ first.focus();
+ }
+ }}
+ >
+
+
+
+ :
+
+
+
+
+
+ ) : 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;