diff --git a/README.md b/README.md index f4d0344..b798200 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ## 功能 -- 支持添加并切换多个 Codex 账号或工作区(包括同一邮箱的个人订阅与 Team 工作区) +- 支持在同一总览页查看多个 Codex 账号或工作区(包括同一邮箱的个人订阅与 Team 工作区) - 展示 Codex 账户、套餐、剩余额度和下次重置时间 - 展示累计 Token、单日峰值、连续使用天数及每日 Token 趋势 - 在本地 SQLite 中保留历史用量和限额快照 @@ -56,12 +56,12 @@ http://服务器地址:8180 4. 点击页面显示的 OpenAI 验证地址,或在另一台设备的浏览器中打开该地址。 5. 登录需要监控的 ChatGPT/Codex 账户。 6. 输入页面显示的一次性设备码并确认授权。 -7. 返回 Codex Helper,等待数秒后进入“用量总览”,点击“立即刷新”。 +7. 返回 Codex Helper,等待数秒后进入“用量总览”,点击“刷新全部”或对应账号卡片中的“刷新账号”。 8. 页面显示账户邮箱、套餐和限额窗口后,即表示连接成功。 设备码授权在浏览器中完成,适用于 Docker、NAS 和远程服务器。默认连接的登录凭据保存在 `/data/codex`,新增连接保存在 `/data/accounts/<账号 ID>/codex`,不会写入浏览器或项目源码。 -如需添加其他账号,或同一邮箱下的个人订阅与 Team 工作区,请在“设置中心” → “Codex”中分别创建连接并完成设备码登录。每个连接使用隔离的登录凭据,可自定义名称并在总览顶部切换。删除连接会同时删除对应凭据和历史数据。 +如需添加其他账号,或同一邮箱下的个人订阅与 Team 工作区,请在“设置中心” → “Codex”中分别创建连接并完成设备码登录。每个连接使用隔离的登录凭据,可自定义名称;总览页会同时展示所有连接,点击账号卡片即可查看完整详情。删除连接会同时删除对应凭据和历史数据。 创建连接时可选择预期的“个人订阅”或“Team / Business 工作区”。授权完成后,Codex Helper 会使用 app-server 返回的真实套餐进行校验;`team` 和当前 Business 系列套餐均识别为团队工作区。设备码接口本身不能指定工作区 ID,因此同一邮箱包含多个空间时,需要在授权页面进入目标空间;如果页面提示类型不匹配,请退出该连接后重新授权。 @@ -189,7 +189,7 @@ docker compose restart codex-helper ### 完成设备码授权后仍显示“尚未连接” -- 等待几秒后点击“立即刷新”。 +- 等待几秒后点击“刷新全部”或对应账号卡片中的“刷新账号”。 - 确认授权的是需要监控的 ChatGPT 账户。 - 重新进入 Codex 设置,退出账户后再次生成设备码。 - 检查 `docker compose logs -f codex-helper` 中是否存在网络或认证错误。 diff --git a/docs/frontend/application.md b/docs/frontend/application.md index 5e4cbb9..8fd9689 100644 --- a/docs/frontend/application.md +++ b/docs/frontend/application.md @@ -6,7 +6,7 @@ 应用启动先请求 `system/status`,已初始化时再请求 `auth/me`。未初始化渲染安装页;未登录渲染登录页;登录后由 `BrowserRouter` 提供 `/` 总览和 `/settings` 设置,未知路径回到 `/`。状态响应中的构建版本以 `v` 徽标显示在安装页、登录页和登录后侧栏的品牌区域;登录后侧栏使用放大的品牌图标,图标、名称和版本徽标保持单行排列。这些分支只负责交互,服务端 session 才是安全边界。 -主题以服务端通用设置为持久来源,`localStorage` 仅用于首屏缓存;system 模式会跟随系统主题变化。当前账号 ID 保存到 `localStorage`。总览先加载账号列表,为当前账号加载 Dashboard,并在每轮请求完成 30 秒后刷新;切换账号会取消旧请求并校验响应账号,避免迟到响应覆盖当前账号。手动刷新调用账号级 sync 后重新读取 Dashboard。 +主题以服务端通用设置为持久来源,`localStorage` 仅用于首屏缓存;system 模式会跟随系统主题变化。总览先加载账号列表,再并发加载每个账号的 Dashboard,并在每轮请求完成 30 秒后刷新;每个账号独立维护请求、加载和错误状态,校验响应账号,避免迟到响应覆盖其他账号。总览默认只展示账号摘要,展开卡片后显示完整限额、统计和 Token 图;顶部“刷新全部”和卡片内的账号级刷新都会先调用对应的 sync,再重新读取 Dashboard。 桌面端使用固定侧栏,`800px` 及以下改为紧凑顶栏和固定底部主导航;GitHub、主题和退出位于顶栏辅助菜单。移动布局最低支持 320px,使用动态视口高度和 CSS safe-area 环境变量避开 iOS 浏览器工具栏与设备安全区。内容必须为底部导航保留空间,表单控件避免 iOS 聚焦缩放,主要交互保持至少 44px 触控区域。 diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 742dabc..f850070 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -12,7 +12,13 @@ import { Activity, Bell, Check, + ChevronDown, + Clock, + Coins, + Flame, Github, + Gauge, + Mail, LogOut, Moon, MoreHorizontal, @@ -20,7 +26,11 @@ import { Settings, Sun, Trash2, + TrendingUp, + Timer, Zap, + Wifi, + WifiOff, } from "lucide-react"; import { api, del, get, post, put, toErrorMessage } from "./api"; import { AuthProvider, useAuth } from "./auth"; @@ -334,56 +344,133 @@ function Shell({ version }: { version: string }) { ); } +type DashboardCardState = { + dashboard: Dash | null; + loading: boolean; + error: string; + refresh: "idle" | "loading" | "done"; +}; + +const emptyDashboardCardState = (): DashboardCardState => ({ + dashboard: null, + loading: false, + error: "", + refresh: "idle", +}); + function Dashboard() { const [accounts, setAccounts] = useState(null), - [id, setId] = useState(+(localStorage.accountId || 0)), - [d, setD] = useState(null), - [e, setE] = useState(""), + [cards, setCards] = useState>({}), + [expanded, setExpanded] = useState>({}), + [error, setError] = useState(""), [refresh, setRefresh] = useState<"idle" | "loading" | "done">("idle"); - const requestRef = useRef(0); - const abortRef = useRef(null); - const loadAccounts = async () => { - try { - const xs = await get("accounts", decodeAccounts); - setAccounts(xs); - setE(""); - const next = xs.some((x) => x.id === id) ? id : xs[0]?.id || 0; - if (next !== id) setId(next); - } catch (error) { - setE(toErrorMessage(error)); - } + const requestRef = useRef(new Map()), + activeControllersRef = useRef(new Map()); + + const updateCard = ( + accountId: number, + patch: Partial, + ) => { + setCards((current) => ({ + ...current, + [accountId]: { + ...(current[accountId] || emptyDashboardCardState()), + ...patch, + }, + })); }; - const load = async (accountId = id, signal?: AbortSignal) => { - if (!accountId) return; - const request = ++requestRef.current; + + const loadDashboard = async ( + accountId: number, + signal?: AbortSignal, + manual = false, + ) => { + const request = (requestRef.current.get(accountId) || 0) + 1; + requestRef.current.set(accountId, request); + let ownController: AbortController | undefined; + if (!signal) { + activeControllersRef.current.get(accountId)?.abort(); + ownController = new AbortController(); + activeControllersRef.current.set(accountId, ownController); + signal = ownController.signal; + } + updateCard(accountId, { + loading: true, + error: "", + ...(manual ? { refresh: "loading" as const } : {}), + }); try { const dashboard = await get( "dashboard?accountId=" + accountId, decodeDashboard, signal, ); - if (request === requestRef.current && dashboard.accountId === accountId) { - setD(dashboard); - setE(""); + if ( + requestRef.current.get(accountId) !== request || + dashboard.accountId !== accountId + ) + return; + updateCard(accountId, { + dashboard, + loading: false, + error: "", + ...(manual ? { refresh: "done" as const } : {}), + }); + if (manual) { + window.setTimeout( + () => + setCards((current) => { + const card = current[accountId]; + if (!card || card.refresh !== "done") return current; + return { ...current, [accountId]: { ...card, refresh: "idle" } }; + }), + 2000, + ); } - } catch (x) { - if (!signal?.aborted && request === requestRef.current) - setE(toErrorMessage(x)); + } catch (value) { + if (signal.aborted || requestRef.current.get(accountId) !== request) + return; + updateCard(accountId, { + loading: false, + error: toErrorMessage(value), + ...(manual ? { refresh: "idle" as const } : {}), + }); + } finally { + if ( + ownController && + activeControllersRef.current.get(accountId) === ownController + ) + activeControllersRef.current.delete(accountId); } }; + + const loadAccounts = async () => { + try { + const values = await get("accounts", decodeAccounts); + setAccounts(values); + setError(""); + setExpanded((current) => { + const valid = new Set(values.map((account) => account.id)); + return Object.fromEntries( + Object.entries(current).filter(([id]) => valid.has(+id)), + ); + }); + } catch (value) { + setError(toErrorMessage(value)); + } + }; + useEffect(() => { void loadAccounts(); }, []); useEffect(() => { - if (!id) return; - localStorage.setItem("accountId", String(id)); - setD(null); - abortRef.current?.abort(); + if (!accounts?.length) return; const controller = new AbortController(); - abortRef.current = controller; let timer = 0; const poll = async () => { - await load(id, controller.signal); + await Promise.allSettled( + accounts.map((account) => loadDashboard(account.id, controller.signal)), + ); if (!controller.signal.aborted) timer = window.setTimeout(poll, 30_000); }; void poll(); @@ -391,27 +478,57 @@ function Dashboard() { controller.abort(); window.clearTimeout(timer); }; - }, [id]); - const sync = async () => { - setRefresh("loading"); - setE(""); + }, [accounts]); + useEffect( + () => () => { + activeControllersRef.current.forEach((controller) => controller.abort()); + }, + [], + ); + + const syncAccount = async (accountId: number) => { try { - await post(`accounts/${id}/sync`, decodeOK, {}, undefined, 60_000); - await load(id); - setRefresh("done"); - window.setTimeout(() => setRefresh("idle"), 2000); - } catch (x) { - setE(toErrorMessage(x)); - setRefresh("idle"); + setError(""); + await post(`accounts/${accountId}/sync`, decodeOK, {}, undefined, 60_000); + await loadDashboard(accountId, undefined, true); + } catch (value) { + updateCard(accountId, { + loading: false, + refresh: "idle", + error: toErrorMessage(value), + }); } }; + const syncAll = async () => { + if (!accounts?.length) return; + setRefresh("loading"); + setError(""); + const results = await Promise.allSettled( + accounts.map(async (account) => { + await post( + `accounts/${account.id}/sync`, + decodeOK, + {}, + undefined, + 60_000, + ); + await loadDashboard(account.id, undefined, true); + }), + ); + const failed = results.filter((result) => result.status === "rejected"); + if (failed.length) + setError(`${failed.length} 个账号刷新失败,请展开对应卡片查看详情。`); + setRefresh(failed.length ? "idle" : "done"); + if (!failed.length) window.setTimeout(() => setRefresh("idle"), 2000); + }; + if (accounts === null) return ( <>
- {e ? ( + {error ? (
-

{e}

+

{error}

) : ( @@ -426,100 +543,292 @@ function Dashboard() {
尚未添加 Codex 账号
); - if (!d) - return ( - <> -
- {e ? ( -
-

{e}

- -
- ) : ( - - )} - - ); return ( <> -
-
- - -
+
+
- {e &&
{e}
} -
- -
- Codex Account - - {d.displayName} ·{" "} - {d.account.email ? maskEmail(d.account.email) : "尚未连接"} - -
- {planLabel(d.account.planType)} - - 更新于{" "} - {d.fetchedAt - ? new Date(d.fetchedAt * 1000).toLocaleString() - : "尚未同步"} - -
-
- {d.limits.map((x, i) => ( - + {error &&
{error}
} +
+ {accounts.map((account) => ( + + setExpanded((current) => ({ + ...current, + [account.id]: !current[account.id], + })) + } + onRefresh={() => void syncAccount(account.id)} + /> ))}
- {!d.limits.length && ( + + ); +} + +function AccountOverviewCard({ + account, + state, + expanded, + onToggle, + onRefresh, +}: { + account: Account; + state: DashboardCardState; + expanded: boolean; + onToggle: () => void; + onRefresh: () => void; +}) { + const dashboard = state.dashboard; + const email = dashboard?.account.email ?? account.email; + const plan = dashboard?.account.planType ?? account.planType; + const connected = dashboard?.account.connected ?? account.connected; + const title = dashboard?.displayName || account.displayName; + return ( +
+ + {expanded && ( +
+ {dashboard ? ( + + ) : ( +
+ {state.loading &&
} +

+ {state.error || "正在读取账号用量…"} +

+ {state.error && ( + + )} +
+ )} +
+ )} +
+ ); +} + +function AccountQuickSummary({ + dashboard, + state, +}: { + dashboard: Dash | null; + state: DashboardCardState; +}) { + if (!dashboard) + return ( + + {state.loading + ? "读取中…" + : state.error + ? "读取失败,展开查看" + : "等待数据"} + + ); + const remaining = dashboard.limits.length + ? Math.min(...dashboard.limits.map((limit) => remainingPercent(limit))) + : null; + const reset = dashboard.limits + .map((limit) => limit.resetsAt) + .filter((value) => value > 0) + .sort((a, b) => a - b)[0]; + return ( + + + {remaining == null ? "限额暂无" : `最低 ${Math.round(remaining)}% 剩余`} + + {reset ? `最近重置 ${relative(reset)}` : "暂无重置时间"} + + ); +} + +function AccountDashboardDetails({ + dashboard, + state, + onRefresh, +}: { + dashboard: Dash; + state: DashboardCardState; + onRefresh: () => void; +}) { + return ( + <> +
+
+ {dashboard.account.connected ? : } +
+ Codex Account +

{dashboard.displayName}

+
+
+
+ + 更新于{" "} + {dashboard.fetchedAt + ? new Date(dashboard.fetchedAt * 1000).toLocaleString() + : "尚未同步"} + + +
+
+ {(state.error || dashboard.lastError) && ( +
+ {state.error || dashboard.lastError} +
+ )} +
+ } + label="登录邮箱" + value={ + dashboard.account.email + ? maskEmail(dashboard.account.email) + : "尚未连接" + } + /> + : } + label="连接状态" + value={dashboard.account.connected ? "已连接" : "未连接"} + /> + } + label="套餐" + value={planLabel(dashboard.account.planType)} + /> + } + label="数据状态" + value={dashboard.stale ? "数据可能已过期" : "数据已更新"} + /> +
+
+ {dashboard.limits.map((limit, index) => ( + + ))} +
+ {!dashboard.limits.length && (
该连接暂无限额数据,请确认登录后刷新。
)}
- - } + label="累计 Tokens" + v={num(dashboard.summary.lifetimeTokens)} + /> + } + label="单日峰值" + v={num(dashboard.summary.peakDailyTokens)} + /> + } label="连续使用" v={ - d.summary.currentStreakDays == null + dashboard.summary.currentStreakDays == null ? "暂无" - : d.summary.currentStreakDays + " 天" + : dashboard.summary.currentStreakDays + " 天" } /> } label="最长任务时长" - v={duration(d.summary.longestRunningTurnSec)} + v={duration(dashboard.summary.longestRunningTurnSec)} />
}> - + ); } + +function AccountInfo({ + icon, + label, + value, +}: { + icon: React.ReactNode; + label: string; + value: string; +}) { + return ( +
+ {icon} +
+ {label} + {value} +
+
+ ); +} + function LimitCard({ x }: { x: Limit }) { const used = Math.min(100, Math.max(0, x.usedPercent)), left = 100 - used, @@ -528,7 +837,10 @@ function LimitCard({ x }: { x: Limit }) { leftColor = `hsl(${left * 1.2} 70% 45%)`; return (
- 余额 +
+ + {limitLabel(x)} +
{leftLabel} @@ -537,7 +849,10 @@ function LimitCard({ x }: { x: Limit }) { 剩余
- 下次重置 +
+ + 下次重置 +
{x.resetsAt ? new Date(x.resetsAt * 1000).toLocaleString() @@ -556,16 +871,21 @@ function LimitCard({ x }: { x: Limit }) { ); } const Stat = ({ + icon, label, v, muted = false, }: { + icon?: React.ReactNode; label: string; v: string; muted?: boolean; }) => (
- {label} +
+ {icon} + {label} +
{v}
); @@ -1356,6 +1676,18 @@ const duration = (v?: number | null) => : v < 3600 ? `${Math.floor(v / 60)} 分 ${v % 60} 秒` : `${(v / 3600).toFixed(1)} 小时`; +const remainingPercent = (limit: Limit) => + 100 - Math.min(100, Math.max(0, limit.usedPercent)); +const limitLabel = (limit: Limit) => { + if (limit.limitName) return limit.limitName; + if (limit.windowDurationMinutes >= 60) { + const hours = limit.windowDurationMinutes / 60; + return `${Number.isInteger(hours) ? hours : hours.toFixed(1)} 小时限额`; + } + if (limit.windowDurationMinutes > 0) + return `${limit.windowDurationMinutes} 分钟限额`; + return limit.windowType || "额度窗口"; +}; const maskEmailPart = (part: string) => { if (part.length <= 1) return "*"; if (part.length === 2) return part[0] + "*"; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 8b5f17d..1d4b37b 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -294,9 +294,6 @@ header p { display: flex; gap: 10px; } -.account-select { - min-width: 220px; -} .secondary { background: var(--panel); border: 1px solid var(--border); @@ -313,6 +310,176 @@ header p { color: var(--green); border-color: var(--green); } +.account-overview-list { + display: flex; + flex-direction: column; + gap: 16px; +} +.account-overview { + overflow: hidden; +} +.account-summary-toggle { + width: 100%; + display: grid; + grid-template-columns: auto minmax(180px, 1fr) auto minmax(280px, 1.5fr) auto; + align-items: center; + gap: 14px; + padding: 20px 24px; + border-radius: 17px; + background: transparent; + color: var(--text); + text-align: left; +} +.account-summary-toggle:hover, +.account-summary-toggle:focus-visible { + background: color-mix(in srgb, var(--track) 62%, transparent); +} +.account-summary-toggle:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -3px; +} +.account-summary-identity { + display: flex; + flex-direction: column; + gap: 5px; + min-width: 0; +} +.account-summary-identity strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.account-summary-identity span, +.account-summary-metrics { + color: var(--muted); + font-size: 13px; +} +.account-summary-identity span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.account-summary-plan { + justify-self: start; + white-space: nowrap; +} +.account-summary-metrics { + display: flex; + justify-content: flex-end; + flex-wrap: wrap; + gap: 6px 14px; + min-width: 0; +} +.account-summary-metrics span { + white-space: nowrap; +} +.account-chevron { + flex: none; + width: 20px; + color: var(--muted); + transition: transform 0.2s ease; +} +.account-overview.expanded .account-chevron { + transform: rotate(180deg); +} +.account-detail { + display: flex; + flex-direction: column; + gap: 20px; + padding: 0 24px 24px; + border-top: 1px solid var(--border); +} +.account-detail-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding-top: 22px; +} +.account-detail-title, +.account-detail-actions, +.limit-heading, +.stat-label { + display: flex; + align-items: center; +} +.account-detail-title { + gap: 12px; +} +.account-detail-title > svg { + width: 24px; + height: 24px; + color: var(--accent); +} +.account-detail-title small, +.account-info-item small { + color: var(--muted); +} +.account-detail-title h2 { + margin: 4px 0 0; + font-size: 22px; +} +.account-detail-actions { + justify-content: flex-end; + gap: 12px; +} +.account-detail-actions .fresh { + margin-left: 0; +} +.account-detail-alert { + padding: 12px 14px; + border: 1px solid color-mix(in srgb, var(--danger) 35%, var(--border)); + border-radius: 10px; + background: color-mix(in srgb, var(--danger) 8%, var(--panel)); +} +.account-info-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} +.account-info-item { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + padding: 13px 14px; + border-radius: 12px; + background: var(--track); +} +.account-info-item > svg { + flex: none; + width: 18px; + color: var(--accent); +} +.account-info-item div { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} +.account-info-item b { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; +} +.account-detail-state { + display: flex; + min-height: 170px; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 10px; + color: var(--muted); + text-align: center; +} +.account-detail-state .spinner { + width: 28px; + height: 28px; +} +.account-detail-state p { + margin: 0; +} .account { padding: 20px 24px; display: flex; @@ -367,6 +534,18 @@ header p { .limit > small { grid-column: 1/-1; } +.limit-heading { + grid-column: 1/-1; + gap: 7px; +} +.limit-heading svg, +.stat-label svg { + width: 16px; + color: var(--accent); +} +.stat-label { + gap: 7px; +} .remaining strong { display: block; font-size: 50px; @@ -757,10 +936,50 @@ a { .refresh { width: 100%; } - .account-select { - min-width: 0; + .account-summary-toggle { + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 10px 12px; + padding: 18px; + } + .account-summary-identity { + grid-column: 2; + grid-row: 1; + } + .account-summary-plan { + grid-column: 2; + grid-row: 2; + } + .account-summary-metrics { + grid-column: 2 / -1; + grid-row: 3; + justify-content: flex-start; + gap: 6px 12px; + } + .account-chevron { + grid-column: 3; + grid-row: 1; + } + .account-detail { + padding: 0 18px 18px; + } + .account-detail-heading { + align-items: stretch; + flex-direction: column; + gap: 14px; + } + .account-detail-actions { + align-items: stretch; + flex-direction: column; + } + .account-detail-actions .fresh { + display: block; + } + .account-detail-actions .refresh { width: 100%; } + .account-info-grid { + grid-template-columns: 1fr 1fr; + } .account { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; @@ -893,6 +1112,9 @@ a { .stats { grid-template-columns: 1fr; } + .account-info-grid { + grid-template-columns: 1fr; + } .account { grid-template-columns: auto minmax(0, 1fr); } diff --git a/frontend/src/usage-chart.tsx b/frontend/src/usage-chart.tsx index 960292c..ac79cab 100644 --- a/frontend/src/usage-chart.tsx +++ b/frontend/src/usage-chart.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useId, useState } from "react"; import { Area, AreaChart, @@ -16,7 +16,15 @@ const compactAxis = (value: number) => maximumFractionDigits: 1, }).format(value); -export default function UsageChart({ data }: { data: Point[] }) { +export default function UsageChart({ + data, + accountId, +}: { + data: Point[]; + accountId?: number; +}) { + const generatedId = useId().replace(/:/g, ""); + const gradientId = `usage-gradient-${accountId ?? generatedId}`; const [compact, setCompact] = useState( () => matchMedia("(max-width: 480px)").matches, ); @@ -39,7 +47,7 @@ export default function UsageChart({ data }: { data: Point[] }) { } > - + @@ -74,7 +82,7 @@ export default function UsageChart({ data }: { data: Point[] }) { type="monotone" dataKey="totalTokens" stroke="#4dd8ed" - fill="url(#a)" + fill={`url(#${gradientId})`} strokeWidth={2} /> diff --git a/frontend/tests/settings-layout.spec.ts b/frontend/tests/settings-layout.spec.ts index 0602d5d..df3771e 100644 --- a/frontend/tests/settings-layout.spec.ts +++ b/frontend/tests/settings-layout.spec.ts @@ -68,6 +68,106 @@ async function openSettings(page: Page, version = "0.3.0-beta.1") { await expect(page.locator(".settings-loading")).toHaveCount(0); } +test("shows every account as a collapsible summary on the overview", async ({ + page, +}) => { + const requestedDashboards = new Set(); + const dashboards: Record = { + 1: { + accountId: 1, + displayName: "个人账号", + account: { + email: "personal@example.com", + planType: "plus", + connected: true, + }, + limits: [ + { + limitId: "primary", + limitName: "5 小时限额", + windowType: "five_hour", + usedPercent: 35, + windowDurationMinutes: 300, + resetsAt: 1_900_000_000, + }, + ], + summary: { + lifetimeTokens: 12_345, + peakDailyTokens: 3_000, + currentStreakDays: 4, + longestRunningTurnSec: 80, + }, + usage: [{ date: "2026-08-13", totalTokens: 3_000 }], + fetchedAt: 1_900_000_000, + stale: false, + }, + 2: { + accountId: 2, + displayName: "Team 工作区", + account: { + email: "team@example.com", + planType: "team", + connected: true, + }, + limits: [], + summary: {}, + usage: [], + fetchedAt: 0, + stale: true, + }, + }; + await page.route("**/api/v1/**", async (route) => { + const request = route.request(); + const key = new URL(request.url()).pathname.replace("/api/v1/", ""); + if (key === "system/status") + return route.fulfill({ + json: { initialized: true, appServer: true, version: "test" }, + }); + if (key === "auth/me") + return route.fulfill({ json: { username: "admin" } }); + if (key === "accounts") + return route.fulfill({ + json: [ + { ...responses.accounts[0], displayName: "个人账号" }, + { + ...responses.accounts[0], + id: 2, + displayName: "Team 工作区", + email: "team@example.com", + planType: "team", + expectedKind: "team", + }, + ], + }); + if (key === "dashboard") { + const id = Number(new URL(request.url()).searchParams.get("accountId")); + requestedDashboards.add(id); + return route.fulfill({ json: dashboards[id] }); + } + return route.fulfill({ json: responses[key] ?? { ok: true } }); + }); + + await page.goto("/"); + await expect(page.locator(".account-overview")).toHaveCount(2); + await expect.poll(() => requestedDashboards.size).toBe(2); + await expect(page.getByText("最低 65% 剩余")).toBeVisible(); + await expect(page.getByText("限额暂无")).toBeVisible(); + await expect(page.locator(".account-detail")).toHaveCount(0); + await expect(page.locator(".account-select")).toHaveCount(0); + + const first = page.locator('[data-account-id="1"]'); + await first.getByRole("button", { name: "展开个人账号详情" }).click(); + await expect(first.locator(".account-detail")).toBeVisible(); + await expect(first.getByRole("heading", { name: "个人账号" })).toBeVisible(); + await expect( + first.getByRole("heading", { name: "每日 Token 趋势" }), + ).toBeVisible(); + await expect(page.locator(".account-detail")).toHaveCount(1); + + await first.getByRole("button", { name: "收起个人账号详情" }).click(); + await expect(page.locator(".account-detail")).toHaveCount(0); +}); + test("shows the build version in the authenticated brand", async ({ page }) => { await openSettings(page); const mobile = (page.viewportSize()?.width ?? 0) <= 800; @@ -233,6 +333,10 @@ test("keeps daily Token axis labels visible on narrow screens", async ({ }); await page.goto("/"); + await page + .locator('[data-account-id="1"]') + .getByRole("button", { name: "展开默认账号详情" }) + .click(); const chart = page.locator(".chart"); await expect( chart.getByRole("heading", { name: "每日 Token 趋势" }), @@ -505,13 +609,17 @@ test("Codex account emails are masked everywhere they are displayed", async ({ }); await page.goto("/"); - await expect(page.locator(".account-select")).toContainText( + const account = page.locator('[data-account-id="1"]'); + await expect(account.locator(".account-summary-identity")).toContainText( + "t**t@e*****e.com", + ); + await account.getByRole("button", { name: "展开默认账号详情" }).click(); + await expect(account.locator(".account-info-item").first()).toContainText( "t**t@e*****e.com", ); - await expect(page.locator(".account b")).toContainText("t**t@e*****e.com"); await expect(page.getByText("已使用 25%", { exact: true })).toHaveCount(0); await expect(page.getByText("剩余 75%", { exact: true })).toHaveCount(0); - await expect(page.locator(".bar")).toHaveAttribute( + await expect(account.locator(".bar")).toHaveAttribute( "aria-label", "5h:已使用 25%,剩余 75%", ); @@ -548,12 +656,12 @@ test("returns to login when an authenticated request receives 401", async ({ return route.fulfill({ json: responses[key] ?? {} }); }); await page.goto("/"); - await expect(page.locator(".account-select")).toBeVisible(); - await page.getByRole("button", { name: "立即刷新" }).click(); + await expect(page.locator(".account-overview")).toBeVisible(); + await page.getByRole("button", { name: "刷新全部" }).click(); await expect(page.getByRole("heading", { name: "欢迎回来" })).toBeVisible(); }); -test("a slow previous account response cannot replace the selected account", async ({ +test("keeps each account dashboard in its own card when responses finish out of order", async ({ page, }) => { const accounts = [ @@ -593,10 +701,15 @@ test("a slow previous account response cannot replace the selected account", asy return route.fulfill({ json: responses[key] ?? {} }); }); await page.goto("/"); - const selector = page.locator(".account-select"); - await selector.selectOption("2"); - await expect(page.locator(".account b")).toContainText("第二账号"); + const second = page.locator('[data-account-id="2"]'); + await expect(second.locator(".account-summary-identity")).toContainText( + "第二账号", + ); await page.waitForTimeout(350); - await expect(page.locator(".account b")).toContainText("第二账号"); - await expect(page.locator(".account b")).not.toContainText("默认账号"); + await expect(second.locator(".account-summary-identity")).toContainText( + "第二账号", + ); + await expect( + page.locator('[data-account-id="1"] .account-summary-identity'), + ).toContainText("默认账号"); });