feat: show all accounts in collapsible overview

This commit is contained in:
2026-08-14 11:07:23 +08:00
parent f5a7031e6a
commit 1ec9001387
6 changed files with 815 additions and 140 deletions
+4 -4
View File
@@ -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` 中是否存在网络或认证错误。
+1 -1
View File
@@ -6,7 +6,7 @@
应用启动先请求 `system/status`,已初始化时再请求 `auth/me`。未初始化渲染安装页;未登录渲染登录页;登录后由 `BrowserRouter` 提供 `/` 总览和 `/settings` 设置,未知路径回到 `/`。状态响应中的构建版本以 `v<version>` 徽标显示在安装页、登录页和登录后侧栏的品牌区域;登录后侧栏使用放大的品牌图标,图标、名称和版本徽标保持单行排列。这些分支只负责交互,服务端 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 触控区域。
+434 -102
View File
@@ -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 }) {
</div>
);
}
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<Account[] | null>(null),
[id, setId] = useState(+(localStorage.accountId || 0)),
[d, setD] = useState<Dash | null>(null),
[e, setE] = useState(""),
[cards, setCards] = useState<Record<number, DashboardCardState>>({}),
[expanded, setExpanded] = useState<Record<number, boolean>>({}),
[error, setError] = useState(""),
[refresh, setRefresh] = useState<"idle" | "loading" | "done">("idle");
const requestRef = useRef(0);
const abortRef = useRef<AbortController | null>(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<number, number>()),
activeControllersRef = useRef(new Map<number, AbortController>());
const updateCard = (
accountId: number,
patch: Partial<DashboardCardState>,
) => {
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 (
<>
<Header title="用量总览" />
{e ? (
{error ? (
<div className="panel empty">
<p className="error">{e}</p>
<p className="error">{error}</p>
<button onClick={() => void loadAccounts()}>重试</button>
</div>
) : (
@@ -426,100 +543,292 @@ function Dashboard() {
<div className="panel empty">尚未添加 Codex 账号</div>
</>
);
if (!d)
return (
<>
<Header title="用量总览" />
{e ? (
<div className="panel empty">
<p className="error">{e}</p>
<button onClick={() => void load(id)}>重试</button>
</div>
) : (
<Splash />
)}
</>
);
return (
<>
<Header title="用量总览">
<div className="header-actions">
<select
className="account-select"
value={id}
onChange={(x) => setId(+x.target.value)}
<Header
title="用量总览"
sub={`共 ${accounts.length} 个账号,点击卡片查看完整详情`}
>
{accounts.map((x) => (
<option key={x.id} value={x.id}>
{x.displayName}
{x.email ? " · " + maskEmail(x.email) : ""}
</option>
))}
</select>
<button
className={"secondary refresh " + refresh}
disabled={refresh === "loading"}
onClick={sync}
onClick={() => void syncAll()}
>
{refresh === "done" ? <Check /> : <RefreshCw />}
{refresh === "loading"
? "刷新中"
: refresh === "done"
? "刷新完成"
: "立即刷新"}
: "刷新全部"}
</button>
</div>
</Header>
{e && <div className="banner">{e}</div>}
<div className="account panel">
<span className={"dot " + (d.account.connected ? "ok" : "")} />
<div>
<small>Codex Account</small>
<b>
{d.displayName} ·{" "}
{d.account.email ? maskEmail(d.account.email) : "尚未连接"}
</b>
</div>
<span className="badge">{planLabel(d.account.planType)}</span>
<span className="fresh">
更新于{" "}
{d.fetchedAt
? new Date(d.fetchedAt * 1000).toLocaleString()
: "尚未同步"}
</span>
</div>
<div className="limits">
{d.limits.map((x, i) => (
<LimitCard key={i} x={x} />
{error && <div className="banner">{error}</div>}
<div className="account-overview-list">
{accounts.map((account) => (
<AccountOverviewCard
key={account.id}
account={account}
state={cards[account.id] || emptyDashboardCardState()}
expanded={Boolean(expanded[account.id])}
onToggle={() =>
setExpanded((current) => ({
...current,
[account.id]: !current[account.id],
}))
}
onRefresh={() => void syncAccount(account.id)}
/>
))}
</div>
{!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 (
<section
className={`panel account-overview${expanded ? " expanded" : ""}`}
data-account-id={account.id}
>
<button
type="button"
className="account-summary-toggle"
aria-expanded={expanded}
aria-controls={`account-detail-${account.id}`}
aria-label={`${expanded ? "收起" : "展开"}${title}详情`}
onClick={onToggle}
>
<span className={`dot ${connected ? "ok" : ""}`} />
<span className="account-summary-identity">
<strong>{title}</strong>
<span>
{email ? maskEmail(email) : "尚未登录"} ·{" "}
{connected ? "已连接" : "未连接"}
</span>
</span>
<span className="badge account-summary-plan">{planLabel(plan)}</span>
<AccountQuickSummary dashboard={dashboard} state={state} />
<ChevronDown className="account-chevron" aria-hidden="true" />
</button>
{expanded && (
<div
className="account-detail"
id={`account-detail-${account.id}`}
aria-label={`${title}完整详情`}
>
{dashboard ? (
<AccountDashboardDetails
dashboard={dashboard}
state={state}
onRefresh={onRefresh}
/>
) : (
<div className="account-detail-state">
{state.loading && <div className="spinner" />}
<p className={state.error ? "error" : ""}>
{state.error || "正在读取账号用量…"}
</p>
{state.error && (
<button className="secondary" onClick={onRefresh}>
<RefreshCw /> 重试
</button>
)}
</div>
)}
</div>
)}
</section>
);
}
function AccountQuickSummary({
dashboard,
state,
}: {
dashboard: Dash | null;
state: DashboardCardState;
}) {
if (!dashboard)
return (
<span className={`account-summary-metrics ${state.error ? "error" : ""}`}>
{state.loading
? "读取中…"
: state.error
? "读取失败,展开查看"
: "等待数据"}
</span>
);
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 (
<span className="account-summary-metrics">
<span>
{remaining == null ? "限额暂无" : `最低 ${Math.round(remaining)}% 剩余`}
</span>
<span>{reset ? `最近重置 ${relative(reset)}` : "暂无重置时间"}</span>
</span>
);
}
function AccountDashboardDetails({
dashboard,
state,
onRefresh,
}: {
dashboard: Dash;
state: DashboardCardState;
onRefresh: () => void;
}) {
return (
<>
<div className="account-detail-heading">
<div className="account-detail-title">
{dashboard.account.connected ? <Wifi /> : <WifiOff />}
<div>
<small>Codex Account</small>
<h2>{dashboard.displayName}</h2>
</div>
</div>
<div className="account-detail-actions">
<span className="fresh">
更新于{" "}
{dashboard.fetchedAt
? new Date(dashboard.fetchedAt * 1000).toLocaleString()
: "尚未同步"}
</span>
<button
className={`secondary refresh ${state.refresh}`}
disabled={state.refresh === "loading"}
onClick={onRefresh}
>
{state.refresh === "done" ? <Check /> : <RefreshCw />}
{state.refresh === "loading"
? "刷新中"
: state.refresh === "done"
? "刷新完成"
: "刷新账号"}
</button>
</div>
</div>
{(state.error || dashboard.lastError) && (
<div className="account-detail-alert error">
{state.error || dashboard.lastError}
</div>
)}
<div className="account-info-grid">
<AccountInfo
icon={<Mail />}
label="登录邮箱"
value={
dashboard.account.email
? maskEmail(dashboard.account.email)
: "尚未连接"
}
/>
<AccountInfo
icon={dashboard.account.connected ? <Wifi /> : <WifiOff />}
label="连接状态"
value={dashboard.account.connected ? "已连接" : "未连接"}
/>
<AccountInfo
icon={<Gauge />}
label="套餐"
value={planLabel(dashboard.account.planType)}
/>
<AccountInfo
icon={<Clock />}
label="数据状态"
value={dashboard.stale ? "数据可能已过期" : "数据已更新"}
/>
</div>
<div className="limits">
{dashboard.limits.map((limit, index) => (
<LimitCard
key={`${limit.limitId}-${limit.windowType}-${index}`}
x={limit}
/>
))}
</div>
{!dashboard.limits.length && (
<div className="panel empty">
该连接暂无限额数据,请确认登录后刷新。
</div>
)}
<div className="stats">
<Stat label="累计 Tokens" v={num(d.summary.lifetimeTokens)} />
<Stat label="单日峰值" v={num(d.summary.peakDailyTokens)} />
<Stat
icon={<Coins />}
label="累计 Tokens"
v={num(dashboard.summary.lifetimeTokens)}
/>
<Stat
icon={<TrendingUp />}
label="单日峰值"
v={num(dashboard.summary.peakDailyTokens)}
/>
<Stat
icon={<Flame />}
label="连续使用"
v={
d.summary.currentStreakDays == null
dashboard.summary.currentStreakDays == null
? "暂无"
: d.summary.currentStreakDays + " 天"
: dashboard.summary.currentStreakDays + " 天"
}
/>
<Stat
icon={<Timer />}
label="最长任务时长"
v={duration(d.summary.longestRunningTurnSec)}
v={duration(dashboard.summary.longestRunningTurnSec)}
/>
</div>
<Suspense fallback={<SettingsLoading />}>
<UsageChart data={d.usage} />
<UsageChart data={dashboard.usage} accountId={dashboard.accountId} />
</Suspense>
</>
);
}
function AccountInfo({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="account-info-item">
{icon}
<div>
<small>{label}</small>
<b>{value}</b>
</div>
</div>
);
}
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 (
<div className="panel limit">
<small>余额</small>
<div className="limit-heading">
<Gauge />
<small>{limitLabel(x)}</small>
</div>
<div className="remaining">
<strong style={{ color: leftColor }}>
{leftLabel}
@@ -537,7 +849,10 @@ function LimitCard({ x }: { x: Limit }) {
<span>剩余</span>
</div>
<div className="reset">
<div className="limit-heading">
<Clock />
<small>下次重置</small>
</div>
<b>
{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;
}) => (
<div className="panel stat">
<div className="stat-label">
{icon}
<small>{label}</small>
</div>
<b className={muted ? "muted" : ""}>{v}</b>
</div>
);
@@ -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] + "*";
+227 -5
View File
@@ -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);
}
+12 -4
View File
@@ -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[] }) {
}
>
<defs>
<linearGradient id="a" x1="0" y1="0" x2="0" y2="1">
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#5ee7f7" stopOpacity={0.35} />
<stop offset="1" stopColor="#5ee7f7" stopOpacity={0} />
</linearGradient>
@@ -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}
/>
</AreaChart>
+124 -11
View File
@@ -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<number>();
const dashboards: Record<number, unknown> = {
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("默认账号");
});