From 5e605388a55c8dfc47ea4c8a1f151e21b0bb2b24 Mon Sep 17 00:00:00 2001 From: zhoujun0601 Date: Thu, 13 Aug 2026 13:01:15 -0400 Subject: [PATCH] feat: optimize frontend for mobile browsers --- README.md | 1 + docs/frontend/application.md | 4 +- frontend/index.html | 5 +- frontend/playwright.config.ts | 6 +- frontend/src/main.tsx | 118 ++++++++++-- frontend/src/styles.css | 255 +++++++++++++++++++++---- frontend/src/usage-chart.tsx | 32 +++- frontend/tests/settings-layout.spec.ts | 55 +++++- 8 files changed, 408 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index df50ac7..f4d0344 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ - 在限额重置前、重置后发送 Telegram 或邮件提醒 - 通过 Telegram 菜单查询当前用量、重置时间、历史概览和账户信息 - 所有运行期配置均可通过前端完成 +- 前端适配 320px 起的手机浏览器,支持移动底部导航、iOS 安全区与深浅主题 - React 前端、Go 后端、Codex CLI 和 SQLite 运行在同一个容器中 ## 运行要求 diff --git a/docs/frontend/application.md b/docs/frontend/application.md index 8790920..5e4cbb9 100644 --- a/docs/frontend/application.md +++ b/docs/frontend/application.md @@ -8,6 +8,8 @@ 主题以服务端通用设置为持久来源,`localStorage` 仅用于首屏缓存;system 模式会跟随系统主题变化。当前账号 ID 保存到 `localStorage`。总览先加载账号列表,为当前账号加载 Dashboard,并在每轮请求完成 30 秒后刷新;切换账号会取消旧请求并校验响应账号,避免迟到响应覆盖当前账号。手动刷新调用账号级 sync 后重新读取 Dashboard。 +桌面端使用固定侧栏,`800px` 及以下改为紧凑顶栏和固定底部主导航;GitHub、主题和退出位于顶栏辅助菜单。移动布局最低支持 320px,使用动态视口高度和 CSS safe-area 环境变量避开 iOS 浏览器工具栏与设备安全区。内容必须为底部导航保留空间,表单控件避免 iOS 聚焦缩放,主要交互保持至少 44px 触控区域。 + ## API 客户端 所有请求使用相对 `/api/v1/`、`credentials: same-origin` 和 `X-Requested-With: codex-helper`,仅 JSON body 设置 content type。请求支持取消和 timeout;非 2xx 响应转换为保留 status 的 `ApiError`,401 会统一回到登录界面。JSON 响应先作为 `unknown`,经端点 decoder 校验后进入组件。新增下载或非 JSON 响应不能直接套用当前 `api` helper。 @@ -22,4 +24,4 @@ API 类型精确区分后端 `null` 与 optional,并为秘密设置拆分读 ## 验证重点 -纯逻辑和组件测试使用 Vitest;Oxlint 检查 TypeScript/React 正确性,Prettier 检查格式,构建门禁限制单个 JavaScript chunk 不超过 500 KB。浏览器测试使用 Playwright 覆盖设置布局、键盘操作、隐藏表单隔离、账号删除、窄屏溢出、邮箱掩码、session 失效和跨账号竞态。修改 effect、轮询或异步加载时应覆盖卸载清理、错误状态及旧响应覆盖新状态;修改 CSS 时同时跑 desktop 和 mobile projects。 +纯逻辑和组件测试使用 Vitest;Oxlint 检查 TypeScript/React 正确性,Prettier 检查格式,构建门禁限制单个 JavaScript chunk 不超过 500 KB。浏览器测试使用 Playwright 的 desktop Chrome、Android Chrome 和 iOS WebKit 项目,覆盖设置布局、移动导航、键盘操作、隐藏表单隔离、账号删除、窄屏溢出、邮箱掩码、session 失效和跨账号竞态。修改 effect、轮询或异步加载时应覆盖卸载清理、错误状态及旧响应覆盖新状态;修改 CSS 时同时跑三个浏览器项目。 diff --git a/frontend/index.html b/frontend/index.html index 0bc44bf..263affa 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,10 @@ - + Codex Helper diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index a90a387..7c9d936 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -6,7 +6,11 @@ export default defineConfig({ 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"] } }, + { + name: "mobile-chrome", + use: { ...devices["Pixel 7"], viewport: { width: 320, height: 640 } }, + }, + { name: "mobile-webkit", use: { ...devices["iPhone 13"] } }, ], webServer: { command: "npm run dev -- --host 127.0.0.1 --port 4173", diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index fef3d05..742dabc 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -5,6 +5,7 @@ import { Navigate, Route, Routes, + useLocation, useNavigate, } from "react-router-dom"; import { @@ -14,6 +15,7 @@ import { Github, LogOut, Moon, + MoreHorizontal, RefreshCw, Settings, Sun, @@ -201,51 +203,109 @@ function Login({ version }: { version: string }) { function Shell({ version }: { version: string }) { const { logout } = useAuth(); const nav = useNavigate(); + const location = useLocation(); const { theme, saveTheme, error: themeError } = useTheme(); const [shellError, setShellError] = useState(""); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const mobileMenuRef = useRef(null); + const mobileMenuButtonRef = useRef(null); + useEffect(() => setMobileMenuOpen(false), [location.pathname]); + useEffect(() => { + if (!mobileMenuOpen) return; + const close = (event: PointerEvent) => { + if (!mobileMenuRef.current?.contains(event.target as Node)) + setMobileMenuOpen(false); + }; + const escape = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + setMobileMenuOpen(false); + mobileMenuButtonRef.current?.focus(); + }; + document.addEventListener("pointerdown", close); + document.addEventListener("keydown", escape); + return () => { + document.removeEventListener("pointerdown", close); + document.removeEventListener("keydown", escape); + }; + }, [mobileMenuOpen]); + const changeTheme = () => + void saveTheme(theme === "dark" ? "light" : "dark").catch(() => undefined); + const signOut = async () => { + if (!confirm("确定要退出 Codex Helper 管理后台吗?")) return; + try { + setShellError(""); + await logout(); + } catch (error) { + setShellError(toErrorMessage(error)); + } + }; return (
+
+ +
+ + {mobileMenuOpen && ( +
+ + + + {(themeError || shellError) && ( + {themeError || shellError} + )} +
+ )} +
+
} /> @@ -253,6 +313,24 @@ function Shell({ version }: { version: string }) { } />
+
); } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 5afcc3e..8b5f17d 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -31,6 +31,7 @@ html[data-theme="dark"] { } body { margin: 0; + min-width: 320px; background: radial-gradient( circle at 85% 5%, @@ -47,6 +48,7 @@ select { } .center { min-height: 100vh; + min-height: 100dvh; display: grid; place-content: center; gap: 24px; @@ -76,6 +78,7 @@ select { .logo svg { color: var(--accent); width: 22px; + height: 22px; } .version-badge { padding: 2px 6px; @@ -90,6 +93,7 @@ select { } .setup { min-height: 100vh; + min-height: 100dvh; display: grid; grid-template-columns: 1.1fr 0.9fr; align-items: center; @@ -215,6 +219,11 @@ button svg { display: grid; grid-template-columns: 230px 1fr; min-height: 100vh; + min-height: 100dvh; +} +.mobile-topbar, +.mobile-bottom-nav { + display: none; } aside { border-right: 1px solid var(--border); @@ -247,6 +256,8 @@ aside button, color: var(--muted); } aside button:hover, +.aside-bottom a:hover, +aside nav button.active, .tabs button:hover, .tabs button.active { background: var(--track); @@ -415,6 +426,7 @@ header p { } .chart { padding: 24px; + min-width: 0; } .chart h3 { margin: 0 0 5px; @@ -621,9 +633,17 @@ a { color: var(--accent); } @media (max-width: 800px) { + :root { + scrollbar-gutter: auto; + } + body { + overflow-wrap: anywhere; + } .setup { grid-template-columns: 1fr; - padding: 24px; + align-items: center; + padding: max(20px, env(safe-area-inset-top)) 16px + max(20px, env(safe-area-inset-bottom)); } .hero { display: none; @@ -632,44 +652,104 @@ a { display: block; } .app { - grid-template-columns: 1fr; + display: block; + padding-bottom: calc(70px + env(safe-area-inset-bottom)); } aside { - position: static; - height: auto; - border: 0; - padding: 14px; - flex-direction: row; - align-items: center; - overflow: auto; - } - aside .logo { - padding: 0; - margin-right: auto; - } - aside nav { - flex-direction: row; - } - .aside-bottom { - margin: 0; - flex-direction: row; - } - aside .github-link { - padding: 10px; - } - aside .github-link span { display: none; } - aside button { - font-size: 0; - padding: 10px; + .mobile-topbar { + position: sticky; + z-index: 20; + top: 0; + display: flex; + align-items: center; + justify-content: space-between; + min-height: calc(58px + env(safe-area-inset-top)); + padding: env(safe-area-inset-top) 12px 0 16px; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--bg) 92%, transparent); + backdrop-filter: blur(15px); + } + .mobile-topbar .logo { + min-width: 0; + gap: 7px; + } + .mobile-topbar .logo svg { + flex: none; + } + .mobile-menu { + position: relative; + flex: none; + } + .mobile-menu-trigger { + width: 44px; + height: 44px; + padding: 0; + } + .mobile-menu-popover { + position: absolute; + z-index: 30; + top: calc(100% + 7px); + right: 0; + display: flex; + flex-direction: column; + width: min(230px, calc(100vw - 24px)); + padding: 8px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--panel); + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.18); + } + .mobile-menu-popover button, + .mobile-menu-popover .github-link { + min-height: 44px; + justify-content: flex-start; + background: transparent; + color: var(--text); + } + .mobile-bottom-nav { + position: fixed; + z-index: 20; + right: 0; + bottom: 0; + left: 0; + display: grid; + grid-template-columns: repeat(2, 1fr); + padding: 6px max(12px, env(safe-area-inset-right)) + max(6px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left)); + border-top: 1px solid var(--border); + background: color-mix(in srgb, var(--bg) 94%, transparent); + backdrop-filter: blur(15px); + } + .mobile-bottom-nav button { + min-height: 52px; + flex-direction: column; + gap: 2px; + padding: 5px; + background: transparent; + color: var(--muted); + font-size: 12px; + } + .mobile-bottom-nav button.active { + color: var(--accent); + } + .mobile-bottom-nav svg { + height: 21px; + width: 21px; } .content { - padding: 24px 16px; + padding: 22px max(14px, env(safe-area-inset-right)) 24px + max(14px, env(safe-area-inset-left)); } header { - align-items: start; - gap: 15px; + align-items: stretch; + flex-direction: column; + gap: 16px; + margin-bottom: 20px; + } + header h1 { + font-size: 27px; } .header-actions { flex-direction: column; @@ -679,9 +759,40 @@ a { } .account-select { min-width: 0; + width: 100%; + } + .account { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + padding: 18px; + } + .account div, + .account b { + min-width: 0; + } + .fresh { + display: block; + grid-column: 2 / -1; + margin-left: 0; + } + .limits { + grid-template-columns: minmax(0, 1fr); } .stats { grid-template-columns: 1fr 1fr; + gap: 10px; + } + .stat { + padding: 16px; + } + .stat b { + font-size: 21px; + } + .chart { + padding: 18px 8px 14px; + } + .chart h3 { + margin-left: 10px; } .settings { grid-template-columns: 1fr; @@ -690,15 +801,17 @@ a { min-height: 900px; } .tabs { - flex-direction: row; - overflow: auto; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + } + .tabs button { + min-height: 46px; + justify-content: center; } .grid2 { grid-template-columns: 1fr; } - .fresh { - display: none; - } .add-account-card, .add-account-controls { align-items: stretch; @@ -716,6 +829,7 @@ a { } .account-buttons button { flex: 1; + min-width: 0; } .account-buttons .icon { flex: none; @@ -724,6 +838,38 @@ a { flex-direction: column; align-items: flex-start; } + .codebox strong { + max-width: 100%; + font-size: 20px; + white-space: normal; + } + .actions { + flex-wrap: wrap; + } + .actions button { + min-height: 44px; + flex: 1 1 140px; + } + .form { + padding: 22px 18px; + } + input, + select { + min-height: 44px; + font-size: 16px; + } + button, + .github-link { + min-height: 44px; + } + .checks { + min-height: 44px; + flex-wrap: wrap; + } + .checks input { + min-height: 22px; + min-width: 22px; + } .limit { grid-template-columns: 1fr; } @@ -735,6 +881,43 @@ a { grid-column: 1; } } +@media (max-width: 359px) { + .brand-name { + font-size: 14px; + } + .mobile-topbar .version-badge { + max-width: 76px; + overflow: hidden; + text-overflow: ellipsis; + } + .stats { + grid-template-columns: 1fr; + } + .account { + grid-template-columns: auto minmax(0, 1fr); + } + .account .badge { + grid-column: 2; + justify-self: start; + } + .account-buttons { + flex-wrap: wrap; + } + .account-buttons .icon { + flex: 0 0 44px; + } +} +@media (max-width: 800px) and (orientation: landscape) and (max-height: 500px) { + .center, + .setup { + place-content: start center; + align-items: start; + } + .login, + .setup .form { + margin: 16px 0; + } +} @media (prefers-reduced-motion: reduce) { * { animation: none !important; diff --git a/frontend/src/usage-chart.tsx b/frontend/src/usage-chart.tsx index 94515d3..d1e6fae 100644 --- a/frontend/src/usage-chart.tsx +++ b/frontend/src/usage-chart.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from "react"; import { Area, AreaChart, @@ -16,13 +17,27 @@ const compactAxis = (value: number) => }).format(value); export default function UsageChart({ data }: { data: Point[] }) { + const [compact, setCompact] = useState( + () => matchMedia("(max-width: 480px)").matches, + ); + useEffect(() => { + const media = matchMedia("(max-width: 480px)"); + const update = () => setCompact(media.matches); + media.addEventListener("change", update); + return () => media.removeEventListener("change", update); + }, []); return (

每日 Token 趋势

- - + + @@ -30,10 +45,17 @@ export default function UsageChart({ data }: { data: Point[] }) { - + date.slice(5) : undefined + } + minTickGap={compact ? 22 : 5} + /> diff --git a/frontend/tests/settings-layout.spec.ts b/frontend/tests/settings-layout.spec.ts index b97318d..9d605cd 100644 --- a/frontend/tests/settings-layout.spec.ts +++ b/frontend/tests/settings-layout.spec.ts @@ -70,7 +70,8 @@ async function openSettings(page: Page, version = "0.3.0-beta.1") { test("shows the build version in the authenticated brand", async ({ page }) => { await openSettings(page); - const brand = page.locator("aside .logo"); + const mobile = (page.viewportSize()?.width ?? 0) <= 800; + const brand = page.locator(mobile ? ".mobile-topbar .logo" : "aside .logo"); const badge = brand.locator(".version-badge"); await expect(badge).toBeVisible(); await expect(badge).toHaveText("v0.3.0-beta.1"); @@ -95,8 +96,8 @@ test("shows the build version in the authenticated brand", async ({ page }) => { }); expect(Math.abs(layout.badgeCenter - layout.nameCenter)).toBeLessThan(1); expect(Math.abs(layout.iconCenter - layout.nameCenter)).toBeLessThan(1); - expect(layout.iconWidth).toBe(30); - expect(layout.iconHeight).toBe(30); + expect(layout.iconWidth).toBe(mobile ? 22 : 30); + expect(layout.iconHeight).toBe(mobile ? 22 : 30); expect(layout.iconFlexShrink).toBe("0"); }); @@ -104,7 +105,11 @@ test("keeps the authenticated brand on one line with a release version", async ( page, }) => { await openSettings(page, "0.2.0"); - const brand = page.locator("aside .logo"); + const brand = page.locator( + (page.viewportSize()?.width ?? 0) <= 800 + ? ".mobile-topbar .logo" + : "aside .logo", + ); await expect(brand).toContainText("Codex Helper"); await expect(brand.locator(".version-badge")).toHaveText("v0.2.0"); const centers = await brand @@ -159,6 +164,8 @@ test("shows the build version on login", async ({ page }) => { test("shows the GitHub link after login", async ({ page }) => { await openSettings(page); + if ((page.viewportSize()?.width ?? 0) <= 800) + await page.getByRole("button", { name: "打开更多操作" }).click(); const github = page.getByRole("link", { name: "在 GitHub 上查看 Codex Helper", }); @@ -169,6 +176,46 @@ test("shows the GitHub link after login", async ({ page }) => { ); }); +test("mobile shell exposes accessible navigation without covering content", async ({ + page, +}) => { + test.skip((page.viewportSize()?.width ?? 0) > 800, "mobile only"); + await openSettings(page); + const settings = page.getByRole("button", { name: "设置", exact: true }); + await expect(settings).toHaveAttribute("aria-current", "page"); + const nav = page.locator(".mobile-bottom-nav"); + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + const geometry = await page.evaluate(() => ({ + documentWidth: document.documentElement.scrollWidth, + viewportWidth: document.documentElement.clientWidth, + navTop: document + .querySelector(".mobile-bottom-nav")! + .getBoundingClientRect().top, + contentBottom: document + .querySelector("#settings-panel-general .panel")! + .getBoundingClientRect().bottom, + })); + expect(geometry.documentWidth).toBeLessThanOrEqual(geometry.viewportWidth); + expect(geometry.contentBottom).toBeLessThanOrEqual(geometry.navTop); + await expect(nav).toBeVisible(); + await page.getByRole("button", { name: "总览", exact: true }).click(); + await expect(page).toHaveURL(/\/$/); +}); + +test("mobile auxiliary menu closes with Escape and restores focus", async ({ + page, +}) => { + test.skip((page.viewportSize()?.width ?? 0) > 800, "mobile only"); + await openSettings(page); + const trigger = page.getByRole("button", { name: "打开更多操作" }); + await trigger.click(); + await expect(trigger).toHaveAttribute("aria-expanded", "true"); + await expect(page.getByRole("button", { name: "切换主题" })).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(trigger).toHaveAttribute("aria-expanded", "false"); + await expect(trigger).toBeFocused(); +}); + test("automatically enables Telegram features and removes the configuration", async ({ page, }) => {