feat: optimize frontend for mobile browsers
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
- 在限额重置前、重置后发送 Telegram 或邮件提醒
|
||||
- 通过 Telegram 菜单查询当前用量、重置时间、历史概览和账户信息
|
||||
- 所有运行期配置均可通过前端完成
|
||||
- 前端适配 320px 起的手机浏览器,支持移动底部导航、iOS 安全区与深浅主题
|
||||
- React 前端、Go 后端、Codex CLI 和 SQLite 运行在同一个容器中
|
||||
|
||||
## 运行要求
|
||||
|
||||
@@ -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 时同时跑三个浏览器项目。
|
||||
|
||||
+4
-1
@@ -2,7 +2,10 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||
/>
|
||||
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
|
||||
<title>Codex Helper</title>
|
||||
</head>
|
||||
|
||||
@@ -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",
|
||||
|
||||
+107
-29
@@ -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,36 +203,34 @@ 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("");
|
||||
return (
|
||||
<div className="app">
|
||||
<aside>
|
||||
<Brand version={version} />
|
||||
<nav>
|
||||
<button onClick={() => nav("/")}>
|
||||
<Activity />
|
||||
总览
|
||||
</button>
|
||||
<button onClick={() => nav("/settings")}>
|
||||
<Settings />
|
||||
设置中心
|
||||
</button>
|
||||
</nav>
|
||||
<div className="aside-bottom">
|
||||
<GitHubLink />
|
||||
<button
|
||||
onClick={() =>
|
||||
void saveTheme(theme === "dark" ? "light" : "dark").catch(
|
||||
() => undefined,
|
||||
)
|
||||
}
|
||||
>
|
||||
{theme === "dark" ? <Sun /> : <Moon />}切换主题
|
||||
</button>
|
||||
{themeError && <small className="error">{themeError}</small>}
|
||||
<button
|
||||
onClick={async () => {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
||||
const mobileMenuButtonRef = useRef<HTMLButtonElement>(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("");
|
||||
@@ -238,14 +238,74 @@ function Shell({ version }: { version: string }) {
|
||||
} catch (error) {
|
||||
setShellError(toErrorMessage(error));
|
||||
}
|
||||
}}
|
||||
};
|
||||
return (
|
||||
<div className="app">
|
||||
<aside>
|
||||
<Brand version={version} />
|
||||
<nav>
|
||||
<button
|
||||
className={location.pathname === "/" ? "active" : ""}
|
||||
aria-current={location.pathname === "/" ? "page" : undefined}
|
||||
onClick={() => nav("/")}
|
||||
>
|
||||
<Activity />
|
||||
总览
|
||||
</button>
|
||||
<button
|
||||
className={location.pathname === "/settings" ? "active" : ""}
|
||||
aria-current={
|
||||
location.pathname === "/settings" ? "page" : undefined
|
||||
}
|
||||
onClick={() => nav("/settings")}
|
||||
>
|
||||
<Settings />
|
||||
设置中心
|
||||
</button>
|
||||
</nav>
|
||||
<div className="aside-bottom">
|
||||
<GitHubLink />
|
||||
<button onClick={changeTheme}>
|
||||
{theme === "dark" ? <Sun /> : <Moon />}切换主题
|
||||
</button>
|
||||
{themeError && <small className="error">{themeError}</small>}
|
||||
<button onClick={signOut}>
|
||||
<LogOut />
|
||||
退出
|
||||
</button>
|
||||
{shellError && <small className="error">{shellError}</small>}
|
||||
</div>
|
||||
</aside>
|
||||
<div className="mobile-topbar">
|
||||
<Brand version={version} />
|
||||
<div className="mobile-menu" ref={mobileMenuRef}>
|
||||
<button
|
||||
ref={mobileMenuButtonRef}
|
||||
className="mobile-menu-trigger secondary"
|
||||
aria-label="打开更多操作"
|
||||
aria-expanded={mobileMenuOpen}
|
||||
aria-controls="mobile-actions"
|
||||
onClick={() => setMobileMenuOpen((open) => !open)}
|
||||
>
|
||||
<MoreHorizontal />
|
||||
</button>
|
||||
{mobileMenuOpen && (
|
||||
<div id="mobile-actions" className="mobile-menu-popover">
|
||||
<GitHubLink />
|
||||
<button onClick={changeTheme}>
|
||||
{theme === "dark" ? <Sun /> : <Moon />}切换主题
|
||||
</button>
|
||||
<button onClick={signOut}>
|
||||
<LogOut />
|
||||
退出
|
||||
</button>
|
||||
{(themeError || shellError) && (
|
||||
<small className="error">{themeError || shellError}</small>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<section className="content">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
@@ -253,6 +313,24 @@ function Shell({ version }: { version: string }) {
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</section>
|
||||
<nav className="mobile-bottom-nav" aria-label="主导航">
|
||||
<button
|
||||
className={location.pathname === "/" ? "active" : ""}
|
||||
aria-current={location.pathname === "/" ? "page" : undefined}
|
||||
onClick={() => nav("/")}
|
||||
>
|
||||
<Activity />
|
||||
<span>总览</span>
|
||||
</button>
|
||||
<button
|
||||
className={location.pathname === "/settings" ? "active" : ""}
|
||||
aria-current={location.pathname === "/settings" ? "page" : undefined}
|
||||
onClick={() => nav("/settings")}
|
||||
>
|
||||
<Settings />
|
||||
<span>设置</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+219
-36
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<div className="panel chart">
|
||||
<div>
|
||||
<h3>每日 Token 趋势</h3>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<AreaChart data={data}>
|
||||
<ResponsiveContainer width="100%" height={compact ? 240 : 280}>
|
||||
<AreaChart
|
||||
data={data}
|
||||
margin={
|
||||
compact ? { top: 8, right: 4, bottom: 0, left: -8 } : undefined
|
||||
}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="a" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor="#5ee7f7" stopOpacity={0.35} />
|
||||
@@ -30,10 +45,17 @@ export default function UsageChart({ data }: { data: Point[] }) {
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid stroke="var(--grid)" vertical={false} />
|
||||
<XAxis dataKey="date" stroke="var(--muted)" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="var(--muted)"
|
||||
tickFormatter={
|
||||
compact ? (date: string) => date.slice(5) : undefined
|
||||
}
|
||||
minTickGap={compact ? 22 : 5}
|
||||
/>
|
||||
<YAxis
|
||||
width={55}
|
||||
tickCount={5}
|
||||
width={compact ? 46 : 55}
|
||||
tickCount={compact ? 4 : 5}
|
||||
tickFormatter={compactAxis}
|
||||
stroke="var(--muted)"
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user