fix: stabilize settings page navigation

This commit is contained in:
zhoujun0601
2026-08-13 08:39:22 -04:00
parent fa9d9bea8a
commit be48e03b3c
7 changed files with 280 additions and 42 deletions
+2 -2
View File
@@ -1,3 +1,3 @@
<script type="module" crossorigin src="/assets/index-D5kXUJ5m.js"></script> <script type="module" crossorigin src="/assets/index-D5QHG9md.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-2I4Pedo2.css"> <link rel="stylesheet" crossorigin href="/assets/index-BpH_Y97H.css">
<div id="root"></div> <div id="root"></div>
+64
View File
@@ -18,6 +18,7 @@
"vite": "8.2.1" "vite": "8.2.1"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.62.1",
"@types/react": "19.1.10", "@types/react": "19.1.10",
"@types/react-dom": "19.1.7", "@types/react-dom": "19.1.7",
"vitest": "3.2.4" "vitest": "3.2.4"
@@ -498,6 +499,22 @@
"url": "https://github.com/sponsors/Boshen" "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": { "node_modules/@reduxjs/toolkit": {
"version": "2.12.0", "version": "2.12.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
@@ -2417,6 +2434,53 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/postcss": {
"version": "8.5.26", "version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+1 -1
View File
@@ -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"}}
+16
View File
@@ -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,
},
});
+71 -36
View File
@@ -492,51 +492,86 @@ const Chart = ({ data }: { data: Point[] }) => (
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
); );
type SettingsTab = "general" | "codex" | "telegram" | "smtp";
const settingsTabs: Array<{
id: SettingsTab;
label: string;
icon: React.ReactNode;
content: React.ReactNode;
}> = [
{ id: "general", label: "通用", icon: <Settings />, content: <General /> },
{ id: "codex", label: "Codex", icon: <Zap />, content: <CodexSettings /> },
{
id: "telegram",
label: "Telegram",
icon: <Bell />,
content: <Telegram />,
},
{ id: "smtp", label: "SMTP 邮件", icon: <Bell />, content: <SMTP /> },
];
function SettingsPage() { function SettingsPage() {
const [tab, setTab] = useState("general"); const [tab, setTab] = useState<SettingsTab>("general");
const selectAdjacentTab = (
event: React.KeyboardEvent<HTMLButtonElement>,
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 ( return (
<> <>
<Header title="设置中心" /> <Header title="设置中心" />
<div className="settings"> <div className="settings">
<div className="tabs"> <div className="tabs" role="tablist" aria-label="设置分类">
{settingsTabs.map((item, index) => (
<button <button
className={tab === "general" ? "active" : ""} key={item.id}
onClick={() => setTab("general")} id={`settings-tab-${item.id}`}
type="button"
role="tab"
aria-selected={tab === item.id}
aria-controls={`settings-panel-${item.id}`}
tabIndex={tab === item.id ? 0 : -1}
className={tab === item.id ? "active" : ""}
onClick={() => setTab(item.id)}
onKeyDown={(event) => selectAdjacentTab(event, index)}
> >
<Settings /> {item.icon}
通用 {item.label}
</button>
<button
className={tab === "codex" ? "active" : ""}
onClick={() => setTab("codex")}
>
<Zap />
Codex
</button>
<button
className={tab === "telegram" ? "active" : ""}
onClick={() => setTab("telegram")}
>
<Bell />
Telegram
</button>
<button
className={tab === "smtp" ? "active" : ""}
onClick={() => setTab("smtp")}
>
<Bell />
SMTP 邮件
</button> </button>
))}
</div>
<div className="settings-content">
{settingsTabs.map((item) => {
const active = tab === item.id;
return (
<section
key={item.id}
id={`settings-panel-${item.id}`}
className={`settings-panel${active ? " active" : ""}`}
role="tabpanel"
aria-labelledby={`settings-tab-${item.id}`}
aria-hidden={!active}
inert={!active}
>
{item.content}
</section>
);
})}
</div> </div>
{tab === "general" ? (
<General />
) : tab === "codex" ? (
<CodexSettings />
) : tab === "telegram" ? (
<Telegram />
) : (
<SMTP />
)}
</div> </div>
</> </>
); );
+20
View File
@@ -398,6 +398,23 @@ header p {
gap: 20px; gap: 20px;
align-items: start; 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 { .tabs {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -545,6 +562,9 @@ a {
.settings { .settings {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.settings-content {
min-height: 900px;
}
.tabs { .tabs {
flex-direction: row; flex-direction: row;
overflow: auto; overflow: auto;
+103
View File
@@ -0,0 +1,103 @@
import { expect, test, type Page } from "@playwright/test";
const responses: Record<string, unknown> = {
"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<HTMLButtonElement>("#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);
});