feat: optimize frontend for mobile browsers
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
- 在限额重置前、重置后发送 Telegram 或邮件提醒
|
- 在限额重置前、重置后发送 Telegram 或邮件提醒
|
||||||
- 通过 Telegram 菜单查询当前用量、重置时间、历史概览和账户信息
|
- 通过 Telegram 菜单查询当前用量、重置时间、历史概览和账户信息
|
||||||
- 所有运行期配置均可通过前端完成
|
- 所有运行期配置均可通过前端完成
|
||||||
|
- 前端适配 320px 起的手机浏览器,支持移动底部导航、iOS 安全区与深浅主题
|
||||||
- React 前端、Go 后端、Codex CLI 和 SQLite 运行在同一个容器中
|
- React 前端、Go 后端、Codex CLI 和 SQLite 运行在同一个容器中
|
||||||
|
|
||||||
## 运行要求
|
## 运行要求
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
|
|
||||||
主题以服务端通用设置为持久来源,`localStorage` 仅用于首屏缓存;system 模式会跟随系统主题变化。当前账号 ID 保存到 `localStorage`。总览先加载账号列表,为当前账号加载 Dashboard,并在每轮请求完成 30 秒后刷新;切换账号会取消旧请求并校验响应账号,避免迟到响应覆盖当前账号。手动刷新调用账号级 sync 后重新读取 Dashboard。
|
主题以服务端通用设置为持久来源,`localStorage` 仅用于首屏缓存;system 模式会跟随系统主题变化。当前账号 ID 保存到 `localStorage`。总览先加载账号列表,为当前账号加载 Dashboard,并在每轮请求完成 30 秒后刷新;切换账号会取消旧请求并校验响应账号,避免迟到响应覆盖当前账号。手动刷新调用账号级 sync 后重新读取 Dashboard。
|
||||||
|
|
||||||
|
桌面端使用固定侧栏,`800px` 及以下改为紧凑顶栏和固定底部主导航;GitHub、主题和退出位于顶栏辅助菜单。移动布局最低支持 320px,使用动态视口高度和 CSS safe-area 环境变量避开 iOS 浏览器工具栏与设备安全区。内容必须为底部导航保留空间,表单控件避免 iOS 聚焦缩放,主要交互保持至少 44px 触控区域。
|
||||||
|
|
||||||
## API 客户端
|
## 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。
|
所有请求使用相对 `/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">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
|
||||||
<title>Codex Helper</title>
|
<title>Codex Helper</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ export default defineConfig({
|
|||||||
use: { baseURL: "http://127.0.0.1:4173", trace: "retain-on-failure" },
|
use: { baseURL: "http://127.0.0.1:4173", trace: "retain-on-failure" },
|
||||||
projects: [
|
projects: [
|
||||||
{ name: "desktop", use: { ...devices["Desktop Chrome"] } },
|
{ 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: {
|
webServer: {
|
||||||
command: "npm run dev -- --host 127.0.0.1 --port 4173",
|
command: "npm run dev -- --host 127.0.0.1 --port 4173",
|
||||||
|
|||||||
+107
-29
@@ -5,6 +5,7 @@ import {
|
|||||||
Navigate,
|
Navigate,
|
||||||
Route,
|
Route,
|
||||||
Routes,
|
Routes,
|
||||||
|
useLocation,
|
||||||
useNavigate,
|
useNavigate,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
Github,
|
Github,
|
||||||
LogOut,
|
LogOut,
|
||||||
Moon,
|
Moon,
|
||||||
|
MoreHorizontal,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Settings,
|
Settings,
|
||||||
Sun,
|
Sun,
|
||||||
@@ -201,36 +203,34 @@ function Login({ version }: { version: string }) {
|
|||||||
function Shell({ version }: { version: string }) {
|
function Shell({ version }: { version: string }) {
|
||||||
const { logout } = useAuth();
|
const { logout } = useAuth();
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const { theme, saveTheme, error: themeError } = useTheme();
|
const { theme, saveTheme, error: themeError } = useTheme();
|
||||||
const [shellError, setShellError] = useState("");
|
const [shellError, setShellError] = useState("");
|
||||||
return (
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
<div className="app">
|
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
||||||
<aside>
|
const mobileMenuButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
<Brand version={version} />
|
useEffect(() => setMobileMenuOpen(false), [location.pathname]);
|
||||||
<nav>
|
useEffect(() => {
|
||||||
<button onClick={() => nav("/")}>
|
if (!mobileMenuOpen) return;
|
||||||
<Activity />
|
const close = (event: PointerEvent) => {
|
||||||
总览
|
if (!mobileMenuRef.current?.contains(event.target as Node))
|
||||||
</button>
|
setMobileMenuOpen(false);
|
||||||
<button onClick={() => nav("/settings")}>
|
};
|
||||||
<Settings />
|
const escape = (event: KeyboardEvent) => {
|
||||||
设置中心
|
if (event.key !== "Escape") return;
|
||||||
</button>
|
setMobileMenuOpen(false);
|
||||||
</nav>
|
mobileMenuButtonRef.current?.focus();
|
||||||
<div className="aside-bottom">
|
};
|
||||||
<GitHubLink />
|
document.addEventListener("pointerdown", close);
|
||||||
<button
|
document.addEventListener("keydown", escape);
|
||||||
onClick={() =>
|
return () => {
|
||||||
void saveTheme(theme === "dark" ? "light" : "dark").catch(
|
document.removeEventListener("pointerdown", close);
|
||||||
() => undefined,
|
document.removeEventListener("keydown", escape);
|
||||||
)
|
};
|
||||||
}
|
}, [mobileMenuOpen]);
|
||||||
>
|
const changeTheme = () =>
|
||||||
{theme === "dark" ? <Sun /> : <Moon />}切换主题
|
void saveTheme(theme === "dark" ? "light" : "dark").catch(() => undefined);
|
||||||
</button>
|
const signOut = async () => {
|
||||||
{themeError && <small className="error">{themeError}</small>}
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
if (!confirm("确定要退出 Codex Helper 管理后台吗?")) return;
|
if (!confirm("确定要退出 Codex Helper 管理后台吗?")) return;
|
||||||
try {
|
try {
|
||||||
setShellError("");
|
setShellError("");
|
||||||
@@ -238,14 +238,74 @@ function Shell({ version }: { version: string }) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
setShellError(toErrorMessage(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 />
|
<LogOut />
|
||||||
退出
|
退出
|
||||||
</button>
|
</button>
|
||||||
{shellError && <small className="error">{shellError}</small>}
|
{shellError && <small className="error">{shellError}</small>}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</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">
|
<section className="content">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
@@ -253,6 +313,24 @@ function Shell({ version }: { version: string }) {
|
|||||||
<Route path="*" element={<Navigate to="/" />} />
|
<Route path="*" element={<Navigate to="/" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+219
-36
@@ -31,6 +31,7 @@ html[data-theme="dark"] {
|
|||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
background:
|
background:
|
||||||
radial-gradient(
|
radial-gradient(
|
||||||
circle at 85% 5%,
|
circle at 85% 5%,
|
||||||
@@ -47,6 +48,7 @@ select {
|
|||||||
}
|
}
|
||||||
.center {
|
.center {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-content: center;
|
place-content: center;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
@@ -76,6 +78,7 @@ select {
|
|||||||
.logo svg {
|
.logo svg {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
width: 22px;
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
}
|
}
|
||||||
.version-badge {
|
.version-badge {
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
@@ -90,6 +93,7 @@ select {
|
|||||||
}
|
}
|
||||||
.setup {
|
.setup {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.1fr 0.9fr;
|
grid-template-columns: 1.1fr 0.9fr;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -215,6 +219,11 @@ button svg {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 230px 1fr;
|
grid-template-columns: 230px 1fr;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
.mobile-topbar,
|
||||||
|
.mobile-bottom-nav {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
aside {
|
aside {
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
@@ -247,6 +256,8 @@ aside button,
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
aside button:hover,
|
aside button:hover,
|
||||||
|
.aside-bottom a:hover,
|
||||||
|
aside nav button.active,
|
||||||
.tabs button:hover,
|
.tabs button:hover,
|
||||||
.tabs button.active {
|
.tabs button.active {
|
||||||
background: var(--track);
|
background: var(--track);
|
||||||
@@ -415,6 +426,7 @@ header p {
|
|||||||
}
|
}
|
||||||
.chart {
|
.chart {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.chart h3 {
|
.chart h3 {
|
||||||
margin: 0 0 5px;
|
margin: 0 0 5px;
|
||||||
@@ -621,9 +633,17 @@ a {
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
|
:root {
|
||||||
|
scrollbar-gutter: auto;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
.setup {
|
.setup {
|
||||||
grid-template-columns: 1fr;
|
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 {
|
.hero {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -632,44 +652,104 @@ a {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.app {
|
.app {
|
||||||
grid-template-columns: 1fr;
|
display: block;
|
||||||
|
padding-bottom: calc(70px + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
aside {
|
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;
|
display: none;
|
||||||
}
|
}
|
||||||
aside button {
|
.mobile-topbar {
|
||||||
font-size: 0;
|
position: sticky;
|
||||||
padding: 10px;
|
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 {
|
.content {
|
||||||
padding: 24px 16px;
|
padding: 22px max(14px, env(safe-area-inset-right)) 24px
|
||||||
|
max(14px, env(safe-area-inset-left));
|
||||||
}
|
}
|
||||||
header {
|
header {
|
||||||
align-items: start;
|
align-items: stretch;
|
||||||
gap: 15px;
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
header h1 {
|
||||||
|
font-size: 27px;
|
||||||
}
|
}
|
||||||
.header-actions {
|
.header-actions {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -679,9 +759,40 @@ a {
|
|||||||
}
|
}
|
||||||
.account-select {
|
.account-select {
|
||||||
min-width: 0;
|
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 {
|
.stats {
|
||||||
grid-template-columns: 1fr 1fr;
|
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 {
|
.settings {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -690,15 +801,17 @@ a {
|
|||||||
min-height: 900px;
|
min-height: 900px;
|
||||||
}
|
}
|
||||||
.tabs {
|
.tabs {
|
||||||
flex-direction: row;
|
display: grid;
|
||||||
overflow: auto;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.tabs button {
|
||||||
|
min-height: 46px;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.grid2 {
|
.grid2 {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
.fresh {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.add-account-card,
|
.add-account-card,
|
||||||
.add-account-controls {
|
.add-account-controls {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
@@ -716,6 +829,7 @@ a {
|
|||||||
}
|
}
|
||||||
.account-buttons button {
|
.account-buttons button {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.account-buttons .icon {
|
.account-buttons .icon {
|
||||||
flex: none;
|
flex: none;
|
||||||
@@ -724,6 +838,38 @@ a {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
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 {
|
.limit {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -735,6 +881,43 @@ a {
|
|||||||
grid-column: 1;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
* {
|
* {
|
||||||
animation: none !important;
|
animation: none !important;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Area,
|
Area,
|
||||||
AreaChart,
|
AreaChart,
|
||||||
@@ -16,13 +17,27 @@ const compactAxis = (value: number) =>
|
|||||||
}).format(value);
|
}).format(value);
|
||||||
|
|
||||||
export default function UsageChart({ data }: { data: Point[] }) {
|
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 (
|
return (
|
||||||
<div className="panel chart">
|
<div className="panel chart">
|
||||||
<div>
|
<div>
|
||||||
<h3>每日 Token 趋势</h3>
|
<h3>每日 Token 趋势</h3>
|
||||||
</div>
|
</div>
|
||||||
<ResponsiveContainer width="100%" height={280}>
|
<ResponsiveContainer width="100%" height={compact ? 240 : 280}>
|
||||||
<AreaChart data={data}>
|
<AreaChart
|
||||||
|
data={data}
|
||||||
|
margin={
|
||||||
|
compact ? { top: 8, right: 4, bottom: 0, left: -8 } : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="a" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="a" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="0" stopColor="#5ee7f7" stopOpacity={0.35} />
|
<stop offset="0" stopColor="#5ee7f7" stopOpacity={0.35} />
|
||||||
@@ -30,10 +45,17 @@ export default function UsageChart({ data }: { data: Point[] }) {
|
|||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<CartesianGrid stroke="var(--grid)" vertical={false} />
|
<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
|
<YAxis
|
||||||
width={55}
|
width={compact ? 46 : 55}
|
||||||
tickCount={5}
|
tickCount={compact ? 4 : 5}
|
||||||
tickFormatter={compactAxis}
|
tickFormatter={compactAxis}
|
||||||
stroke="var(--muted)"
|
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 }) => {
|
test("shows the build version in the authenticated brand", async ({ page }) => {
|
||||||
await openSettings(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");
|
const badge = brand.locator(".version-badge");
|
||||||
await expect(badge).toBeVisible();
|
await expect(badge).toBeVisible();
|
||||||
await expect(badge).toHaveText("v0.3.0-beta.1");
|
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.badgeCenter - layout.nameCenter)).toBeLessThan(1);
|
||||||
expect(Math.abs(layout.iconCenter - layout.nameCenter)).toBeLessThan(1);
|
expect(Math.abs(layout.iconCenter - layout.nameCenter)).toBeLessThan(1);
|
||||||
expect(layout.iconWidth).toBe(30);
|
expect(layout.iconWidth).toBe(mobile ? 22 : 30);
|
||||||
expect(layout.iconHeight).toBe(30);
|
expect(layout.iconHeight).toBe(mobile ? 22 : 30);
|
||||||
expect(layout.iconFlexShrink).toBe("0");
|
expect(layout.iconFlexShrink).toBe("0");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -104,7 +105,11 @@ test("keeps the authenticated brand on one line with a release version", async (
|
|||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await openSettings(page, "0.2.0");
|
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).toContainText("Codex Helper");
|
||||||
await expect(brand.locator(".version-badge")).toHaveText("v0.2.0");
|
await expect(brand.locator(".version-badge")).toHaveText("v0.2.0");
|
||||||
const centers = await brand
|
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 }) => {
|
test("shows the GitHub link after login", async ({ page }) => {
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
|
if ((page.viewportSize()?.width ?? 0) <= 800)
|
||||||
|
await page.getByRole("button", { name: "打开更多操作" }).click();
|
||||||
const github = page.getByRole("link", {
|
const github = page.getByRole("link", {
|
||||||
name: "在 GitHub 上查看 Codex Helper",
|
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 ({
|
test("automatically enables Telegram features and removes the configuration", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user