From be48e03b3c9a34b3ff1c4bccbc86daf1b9ae73c2 Mon Sep 17 00:00:00 2001 From: zhoujun0601 Date: Thu, 13 Aug 2026 08:39:22 -0400 Subject: [PATCH] fix: stabilize settings page navigation --- backend/internal/web/dist/index.html | 4 +- frontend/package-lock.json | 64 ++++++++++++++ frontend/package.json | 2 +- frontend/playwright.config.ts | 16 ++++ frontend/src/main.tsx | 113 ++++++++++++++++--------- frontend/src/styles.css | 20 +++++ frontend/tests/settings-layout.spec.ts | 103 ++++++++++++++++++++++ 7 files changed, 280 insertions(+), 42 deletions(-) create mode 100644 frontend/playwright.config.ts create mode 100644 frontend/tests/settings-layout.spec.ts diff --git a/backend/internal/web/dist/index.html b/backend/internal/web/dist/index.html index 4ef4a74..984f803 100644 --- a/backend/internal/web/dist/index.html +++ b/backend/internal/web/dist/index.html @@ -1,3 +1,3 @@ - - + +
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 02dc729..34dbccc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,7 @@ "vite": "8.2.1" }, "devDependencies": { + "@playwright/test": "^1.62.1", "@types/react": "19.1.10", "@types/react-dom": "19.1.7", "vitest": "3.2.4" @@ -498,6 +499,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.12.0", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", @@ -2417,6 +2434,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", diff --git a/frontend/package.json b/frontend/package.json index 81e6a5f..0a54ffd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1 +1 @@ -{"name":"codex-helper-ui","private":true,"version":"0.1.0","type":"module","scripts":{"dev":"vite","build":"tsc -b && vite build","test":"vitest run"},"dependencies":{"@vitejs/plugin-react":"latest","vite":"8.2.1","typescript":"7.0.2","react":"19.2.7","react-dom":"19.2.7","react-router-dom":"7.18.2","recharts":"3.1.2","lucide-react":"0.539.0"},"devDependencies":{"@types/react":"19.1.10","@types/react-dom":"19.1.7","vitest":"3.2.4"}} +{"name":"codex-helper-ui","private":true,"version":"0.1.0","type":"module","scripts":{"dev":"vite","build":"tsc -b && vite build","test":"vitest run --passWithNoTests --exclude 'tests/**'","test:e2e":"playwright test"},"dependencies":{"@vitejs/plugin-react":"latest","lucide-react":"0.539.0","react":"19.2.7","react-dom":"19.2.7","react-router-dom":"7.18.2","recharts":"3.1.2","typescript":"7.0.2","vite":"8.2.1"},"devDependencies":{"@playwright/test":"^1.62.1","@types/react":"19.1.10","@types/react-dom":"19.1.7","vitest":"3.2.4"}} diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..a90a387 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,16 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests", + fullyParallel: true, + use: { baseURL: "http://127.0.0.1:4173", trace: "retain-on-failure" }, + projects: [ + { name: "desktop", use: { ...devices["Desktop Chrome"] } }, + { name: "mobile", use: { ...devices["Pixel 7"] } }, + ], + webServer: { + command: "npm run dev -- --host 127.0.0.1 --port 4173", + url: "http://127.0.0.1:4173", + reuseExistingServer: true, + }, +}); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 6c8b9ab..f1dea0c 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -492,51 +492,86 @@ const Chart = ({ data }: { data: Point[] }) => ( ); +type SettingsTab = "general" | "codex" | "telegram" | "smtp"; + +const settingsTabs: Array<{ + id: SettingsTab; + label: string; + icon: React.ReactNode; + content: React.ReactNode; +}> = [ + { id: "general", label: "通用", icon: , content: }, + { id: "codex", label: "Codex", icon: , content: }, + { + id: "telegram", + label: "Telegram", + icon: , + content: , + }, + { id: "smtp", label: "SMTP 邮件", icon: , content: }, +]; + function SettingsPage() { - const [tab, setTab] = useState("general"); + const [tab, setTab] = useState("general"); + const selectAdjacentTab = ( + event: React.KeyboardEvent, + currentIndex: number, + ) => { + let nextIndex: number | undefined; + if (event.key === "ArrowRight" || event.key === "ArrowDown") + nextIndex = (currentIndex + 1) % settingsTabs.length; + if (event.key === "ArrowLeft" || event.key === "ArrowUp") + nextIndex = (currentIndex - 1 + settingsTabs.length) % settingsTabs.length; + if (event.key === "Home") nextIndex = 0; + if (event.key === "End") nextIndex = settingsTabs.length - 1; + if (nextIndex === undefined) return; + + event.preventDefault(); + const nextTab = settingsTabs[nextIndex].id; + setTab(nextTab); + document.getElementById(`settings-tab-${nextTab}`)?.focus(); + }; return ( <>
-
- - - - +
+ {settingsTabs.map((item, index) => ( + + ))} +
+
+ {settingsTabs.map((item) => { + const active = tab === item.id; + return ( +
+ {item.content} +
+ ); + })}
- {tab === "general" ? ( - - ) : tab === "codex" ? ( - - ) : tab === "telegram" ? ( - - ) : ( - - )}
); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 8573507..1879c8c 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -398,6 +398,23 @@ header p { gap: 20px; align-items: start; } +.settings-content { + display: grid; + width: 100%; + max-width: 960px; + min-width: 0; + min-height: 640px; +} +.settings-panel { + grid-area: 1 / 1; + min-width: 0; + visibility: hidden; + pointer-events: none; +} +.settings-panel.active { + visibility: visible; + pointer-events: auto; +} .tabs { display: flex; flex-direction: column; @@ -545,6 +562,9 @@ a { .settings { grid-template-columns: 1fr; } + .settings-content { + min-height: 900px; + } .tabs { flex-direction: row; overflow: auto; diff --git a/frontend/tests/settings-layout.spec.ts b/frontend/tests/settings-layout.spec.ts new file mode 100644 index 0000000..9d76dee --- /dev/null +++ b/frontend/tests/settings-layout.spec.ts @@ -0,0 +1,103 @@ +import { expect, test, type Page } from "@playwright/test"; + +const responses: Record = { + "system/status": { initialized: true, appServer: true, version: "test" }, + "auth/me": { username: "admin" }, + "settings/general": { + timezone: "America/New_York", + theme: "system", + syncMinutes: 5, + retentionDays: 90, + beforeMinutes: 30, + notifyBefore: true, + notifyAfter: true, + }, + accounts: [{ id: 1, displayName: "默认账号", email: "test@example.com", planType: "Plus", connected: true }], + "settings/telegram": { token: "", configured: true, enabled: true, menuEnabled: true }, + "settings/smtp": { + host: "smtp.example.com", + port: 587, + username: "mailer", + password: "", + configured: true, + security: "starttls", + fromName: "Codex Helper", + from: "sender@example.com", + to: "recipient@example.com", + enabled: true, + }, +}; + +async function openSettings(page: Page) { + await page.route("**/api/v1/**", async (route) => { + const key = new URL(route.request().url()).pathname.replace("/api/v1/", ""); + await new Promise((resolve) => setTimeout(resolve, 40)); + await route.fulfill({ json: responses[key] ?? {} }); + }); + await page.goto("/settings"); + await expect(page.locator("#settings-panel-smtp h2", { hasText: "SMTP 邮件" })).toBeAttached(); + await expect(page.locator(".settings-loading")).toHaveCount(0); +} + +async function layout(page: Page) { + return page.evaluate(() => { + const box = (selector: string) => { + const rect = document.querySelector(selector)!.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; + }; + return { content: box(".settings-content"), tabs: box(".tabs"), heading: box("header h1"), scrollY: window.scrollY }; + }); +} + +test("switching every settings panel keeps the layout stable", async ({ page }) => { + await openSettings(page); + const initial = await layout(page); + for (const name of ["Codex", "Telegram", "SMTP 邮件", "通用"]) { + await page.getByRole("tab", { name }).click(); + await expect.poll(() => layout(page)).toEqual(initial); + } +}); + +test("switching panels preserves form state and isolates hidden controls", async ({ page }) => { + await openSettings(page); + const timezone = page.getByLabel("时区"); + await timezone.fill("Asia/Shanghai"); + await page.getByRole("tab", { name: "SMTP 邮件" }).click(); + await expect(timezone).toHaveValue("Asia/Shanghai"); + await expect(timezone).not.toBeVisible(); + await timezone.evaluate((element) => element.focus()); + await expect(timezone).not.toBeFocused(); + await page.getByRole("tab", { name: "通用" }).click(); + await expect(timezone).toBeVisible(); + await expect(timezone).toHaveValue("Asia/Shanghai"); +}); + +test("arrow, Home, and End keys move focus and activate tabs", async ({ page }) => { + await openSettings(page); + const general = page.getByRole("tab", { name: "通用" }); + await general.focus(); + + await page.keyboard.press("ArrowRight"); + await expect(page.getByRole("tab", { name: "Codex" })).toBeFocused(); + await expect(page.getByRole("tab", { name: "Codex" })).toHaveAttribute("aria-selected", "true"); + + await page.keyboard.press("End"); + await expect(page.getByRole("tab", { name: "SMTP 邮件" })).toBeFocused(); + await expect(page.getByRole("heading", { name: "SMTP 邮件" })).toBeVisible(); + + await page.keyboard.press("ArrowRight"); + await expect(general).toBeFocused(); + await page.keyboard.press("End"); + await page.keyboard.press("Home"); + await expect(general).toBeFocused(); + await expect(general).toHaveAttribute("aria-selected", "true"); +}); + +test("programmatic tab changes do not clamp an existing scroll position", async ({ page }) => { + await openSettings(page); + await page.evaluate(() => window.scrollTo(0, 240)); + const before = await layout(page); + await page.evaluate(() => document.querySelector("#settings-tab-telegram")!.click()); + await expect.poll(async () => (await layout(page)).scrollY).toBe(before.scrollY); + await expect.poll(async () => (await layout(page)).content).toEqual(before.content); +});