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,
}) => {