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 账户、套餐、剩余额度和下次重置时间 - 展示 Codex 账户、套餐、剩余额度和下次重置时间
- 展示累计 Token、单日峰值、连续使用天数及每日 Token 趋势 - 展示累计 Token、单日峰值、连续使用天数及每日 Token 趋势
- 在本地 SQLite 中保留历史用量和限额快照 - 在本地 SQLite 中保留历史用量和限额快照
@@ -56,12 +56,12 @@ http://服务器地址:8180
4. 点击页面显示的 OpenAI 验证地址,或在另一台设备的浏览器中打开该地址。 4. 点击页面显示的 OpenAI 验证地址,或在另一台设备的浏览器中打开该地址。
5. 登录需要监控的 ChatGPT/Codex 账户。 5. 登录需要监控的 ChatGPT/Codex 账户。
6. 输入页面显示的一次性设备码并确认授权。 6. 输入页面显示的一次性设备码并确认授权。
7. 返回 Codex Helper,等待数秒后进入“用量总览”,点击“立即刷新”。 7. 返回 Codex Helper,等待数秒后进入“用量总览”,点击“刷新全部”或对应账号卡片中的“刷新账号”。
8. 页面显示账户邮箱、套餐和限额窗口后,即表示连接成功。 8. 页面显示账户邮箱、套餐和限额窗口后,即表示连接成功。
设备码授权在浏览器中完成,适用于 Docker、NAS 和远程服务器。默认连接的登录凭据保存在 `/data/codex`,新增连接保存在 `/data/accounts/<账号 ID>/codex`,不会写入浏览器或项目源码。 设备码授权在浏览器中完成,适用于 Docker、NAS 和远程服务器。默认连接的登录凭据保存在 `/data/codex`,新增连接保存在 `/data/accounts/<账号 ID>/codex`,不会写入浏览器或项目源码。
如需添加其他账号,或同一邮箱下的个人订阅与 Team 工作区,请在“设置中心” → “Codex”中分别创建连接并完成设备码登录。每个连接使用隔离的登录凭据,可自定义名称并在总览顶部切换。删除连接会同时删除对应凭据和历史数据。 如需添加其他账号,或同一邮箱下的个人订阅与 Team 工作区,请在“设置中心” → “Codex”中分别创建连接并完成设备码登录。每个连接使用隔离的登录凭据,可自定义名称;总览页会同时展示所有连接,点击账号卡片即可查看完整详情。删除连接会同时删除对应凭据和历史数据。
创建连接时可选择预期的“个人订阅”或“Team / Business 工作区”。授权完成后,Codex Helper 会使用 app-server 返回的真实套餐进行校验;`team` 和当前 Business 系列套餐均识别为团队工作区。设备码接口本身不能指定工作区 ID,因此同一邮箱包含多个空间时,需要在授权页面进入目标空间;如果页面提示类型不匹配,请退出该连接后重新授权。 创建连接时可选择预期的“个人订阅”或“Team / Business 工作区”。授权完成后,Codex Helper 会使用 app-server 返回的真实套餐进行校验;`team` 和当前 Business 系列套餐均识别为团队工作区。设备码接口本身不能指定工作区 ID,因此同一邮箱包含多个空间时,需要在授权页面进入目标空间;如果页面提示类型不匹配,请退出该连接后重新授权。
@@ -189,7 +189,7 @@ docker compose restart codex-helper
### 完成设备码授权后仍显示“尚未连接” ### 完成设备码授权后仍显示“尚未连接”
- 等待几秒后点击“立即刷新”。 - 等待几秒后点击“刷新全部”或对应账号卡片中的“刷新账号”。
- 确认授权的是需要监控的 ChatGPT 账户。 - 确认授权的是需要监控的 ChatGPT 账户。
- 重新进入 Codex 设置,退出账户后再次生成设备码。 - 重新进入 Codex 设置,退出账户后再次生成设备码。
- 检查 `docker compose logs -f codex-helper` 中是否存在网络或认证错误。 - 检查 `docker compose logs -f codex-helper` 中是否存在网络或认证错误。
+1 -1
View File
@@ -6,7 +6,7 @@
应用启动先请求 `system/status`,已初始化时再请求 `auth/me`。未初始化渲染安装页;未登录渲染登录页;登录后由 `BrowserRouter` 提供 `/` 总览和 `/settings` 设置,未知路径回到 `/`。状态响应中的构建版本以 `v<version>` 徽标显示在安装页、登录页和登录后侧栏的品牌区域;登录后侧栏使用放大的品牌图标,图标、名称和版本徽标保持单行排列。这些分支只负责交互,服务端 session 才是安全边界。 应用启动先请求 `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 触控区域。 桌面端使用固定侧栏,`800px` 及以下改为紧凑顶栏和固定底部主导航;GitHub、主题和退出位于顶栏辅助菜单。移动布局最低支持 320px,使用动态视口高度和 CSS safe-area 环境变量避开 iOS 浏览器工具栏与设备安全区。内容必须为底部导航保留空间,表单控件避免 iOS 聚焦缩放,主要交互保持至少 44px 触控区域。
+434 -102
View File
@@ -12,7 +12,13 @@ import {
Activity, Activity,
Bell, Bell,
Check, Check,
ChevronDown,
Clock,
Coins,
Flame,
Github, Github,
Gauge,
Mail,
LogOut, LogOut,
Moon, Moon,
MoreHorizontal, MoreHorizontal,
@@ -20,7 +26,11 @@ import {
Settings, Settings,
Sun, Sun,
Trash2, Trash2,
TrendingUp,
Timer,
Zap, Zap,
Wifi,
WifiOff,
} from "lucide-react"; } from "lucide-react";
import { api, del, get, post, put, toErrorMessage } from "./api"; import { api, del, get, post, put, toErrorMessage } from "./api";
import { AuthProvider, useAuth } from "./auth"; import { AuthProvider, useAuth } from "./auth";
@@ -334,56 +344,133 @@ function Shell({ version }: { version: string }) {
</div> </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() { function Dashboard() {
const [accounts, setAccounts] = useState<Account[] | null>(null), const [accounts, setAccounts] = useState<Account[] | null>(null),
[id, setId] = useState(+(localStorage.accountId || 0)), [cards, setCards] = useState<Record<number, DashboardCardState>>({}),
[d, setD] = useState<Dash | null>(null), [expanded, setExpanded] = useState<Record<number, boolean>>({}),
[e, setE] = useState(""), [error, setError] = useState(""),
[refresh, setRefresh] = useState<"idle" | "loading" | "done">("idle"); [refresh, setRefresh] = useState<"idle" | "loading" | "done">("idle");
const requestRef = useRef(0); const requestRef = useRef(new Map<number, number>()),
const abortRef = useRef<AbortController | null>(null); activeControllersRef = useRef(new Map<number, AbortController>());
const loadAccounts = async () => {
try { const updateCard = (
const xs = await get("accounts", decodeAccounts); accountId: number,
setAccounts(xs); patch: Partial<DashboardCardState>,
setE(""); ) => {
const next = xs.some((x) => x.id === id) ? id : xs[0]?.id || 0; setCards((current) => ({
if (next !== id) setId(next); ...current,
} catch (error) { [accountId]: {
setE(toErrorMessage(error)); ...(current[accountId] || emptyDashboardCardState()),
} ...patch,
},
}));
}; };
const load = async (accountId = id, signal?: AbortSignal) => {
if (!accountId) return; const loadDashboard = async (
const request = ++requestRef.current; 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 { try {
const dashboard = await get( const dashboard = await get(
"dashboard?accountId=" + accountId, "dashboard?accountId=" + accountId,
decodeDashboard, decodeDashboard,
signal, signal,
); );
if (request === requestRef.current && dashboard.accountId === accountId) { if (
setD(dashboard); requestRef.current.get(accountId) !== request ||
setE(""); 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) { } catch (value) {
if (!signal?.aborted && request === requestRef.current) if (signal.aborted || requestRef.current.get(accountId) !== request)
setE(toErrorMessage(x)); 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(() => { useEffect(() => {
void loadAccounts(); void loadAccounts();
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!id) return; if (!accounts?.length) return;
localStorage.setItem("accountId", String(id));
setD(null);
abortRef.current?.abort();
const controller = new AbortController(); const controller = new AbortController();
abortRef.current = controller;
let timer = 0; let timer = 0;
const poll = async () => { 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); if (!controller.signal.aborted) timer = window.setTimeout(poll, 30_000);
}; };
void poll(); void poll();
@@ -391,27 +478,57 @@ function Dashboard() {
controller.abort(); controller.abort();
window.clearTimeout(timer); window.clearTimeout(timer);
}; };
}, [id]); }, [accounts]);
const sync = async () => { useEffect(
setRefresh("loading"); () => () => {
setE(""); activeControllersRef.current.forEach((controller) => controller.abort());
},
[],
);
const syncAccount = async (accountId: number) => {
try { try {
await post(`accounts/${id}/sync`, decodeOK, {}, undefined, 60_000); setError("");
await load(id); await post(`accounts/${accountId}/sync`, decodeOK, {}, undefined, 60_000);
setRefresh("done"); await loadDashboard(accountId, undefined, true);
window.setTimeout(() => setRefresh("idle"), 2000); } catch (value) {
} catch (x) { updateCard(accountId, {
setE(toErrorMessage(x)); loading: false,
setRefresh("idle"); 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) if (accounts === null)
return ( return (
<> <>
<Header title="用量总览" /> <Header title="用量总览" />
{e ? ( {error ? (
<div className="panel empty"> <div className="panel empty">
<p className="error">{e}</p> <p className="error">{error}</p>
<button onClick={() => void loadAccounts()}>重试</button> <button onClick={() => void loadAccounts()}>重试</button>
</div> </div>
) : ( ) : (
@@ -426,100 +543,292 @@ function Dashboard() {
<div className="panel empty">尚未添加 Codex 账号</div> <div className="panel empty">尚未添加 Codex 账号</div>
</> </>
); );
if (!d)
return ( return (
<> <>
<Header title="用量总览" /> <Header
{e ? ( title="用量总览"
<div className="panel empty"> sub={`共 ${accounts.length} 个账号,点击卡片查看完整详情`}
<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)}
> >
{accounts.map((x) => (
<option key={x.id} value={x.id}>
{x.displayName}
{x.email ? " · " + maskEmail(x.email) : ""}
</option>
))}
</select>
<button <button
className={"secondary refresh " + refresh} className={"secondary refresh " + refresh}
disabled={refresh === "loading"} disabled={refresh === "loading"}
onClick={sync} onClick={() => void syncAll()}
> >
{refresh === "done" ? <Check /> : <RefreshCw />} {refresh === "done" ? <Check /> : <RefreshCw />}
{refresh === "loading" {refresh === "loading"
? "刷新中" ? "刷新中"
: refresh === "done" : refresh === "done"
? "刷新完成" ? "刷新完成"
: "立即刷新"} : "刷新全部"}
</button> </button>
</div>
</Header> </Header>
{e && <div className="banner">{e}</div>} {error && <div className="banner">{error}</div>}
<div className="account panel"> <div className="account-overview-list">
<span className={"dot " + (d.account.connected ? "ok" : "")} /> {accounts.map((account) => (
<div> <AccountOverviewCard
<small>Codex Account</small> key={account.id}
<b> account={account}
{d.displayName} ·{" "} state={cards[account.id] || emptyDashboardCardState()}
{d.account.email ? maskEmail(d.account.email) : "尚未连接"} expanded={Boolean(expanded[account.id])}
</b> onToggle={() =>
</div> setExpanded((current) => ({
<span className="badge">{planLabel(d.account.planType)}</span> ...current,
<span className="fresh"> [account.id]: !current[account.id],
更新于{" "} }))
{d.fetchedAt }
? new Date(d.fetchedAt * 1000).toLocaleString() onRefresh={() => void syncAccount(account.id)}
: "尚未同步"} />
</span>
</div>
<div className="limits">
{d.limits.map((x, i) => (
<LimitCard key={i} x={x} />
))} ))}
</div> </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 className="panel empty">
该连接暂无限额数据,请确认登录后刷新。 该连接暂无限额数据,请确认登录后刷新。
</div> </div>
)} )}
<div className="stats"> <div className="stats">
<Stat label="累计 Tokens" v={num(d.summary.lifetimeTokens)} />
<Stat label="单日峰值" v={num(d.summary.peakDailyTokens)} />
<Stat <Stat
icon={<Coins />}
label="累计 Tokens"
v={num(dashboard.summary.lifetimeTokens)}
/>
<Stat
icon={<TrendingUp />}
label="单日峰值"
v={num(dashboard.summary.peakDailyTokens)}
/>
<Stat
icon={<Flame />}
label="连续使用" label="连续使用"
v={ v={
d.summary.currentStreakDays == null dashboard.summary.currentStreakDays == null
? "暂无" ? "暂无"
: d.summary.currentStreakDays + " 天" : dashboard.summary.currentStreakDays + " 天"
} }
/> />
<Stat <Stat
icon={<Timer />}
label="最长任务时长" label="最长任务时长"
v={duration(d.summary.longestRunningTurnSec)} v={duration(dashboard.summary.longestRunningTurnSec)}
/> />
</div> </div>
<Suspense fallback={<SettingsLoading />}> <Suspense fallback={<SettingsLoading />}>
<UsageChart data={d.usage} /> <UsageChart data={dashboard.usage} accountId={dashboard.accountId} />
</Suspense> </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 }) { function LimitCard({ x }: { x: Limit }) {
const used = Math.min(100, Math.max(0, x.usedPercent)), const used = Math.min(100, Math.max(0, x.usedPercent)),
left = 100 - used, left = 100 - used,
@@ -528,7 +837,10 @@ function LimitCard({ x }: { x: Limit }) {
leftColor = `hsl(${left * 1.2} 70% 45%)`; leftColor = `hsl(${left * 1.2} 70% 45%)`;
return ( return (
<div className="panel limit"> <div className="panel limit">
<small>余额</small> <div className="limit-heading">
<Gauge />
<small>{limitLabel(x)}</small>
</div>
<div className="remaining"> <div className="remaining">
<strong style={{ color: leftColor }}> <strong style={{ color: leftColor }}>
{leftLabel} {leftLabel}
@@ -537,7 +849,10 @@ function LimitCard({ x }: { x: Limit }) {
<span>剩余</span> <span>剩余</span>
</div> </div>
<div className="reset"> <div className="reset">
<div className="limit-heading">
<Clock />
<small>下次重置</small> <small>下次重置</small>
</div>
<b> <b>
{x.resetsAt {x.resetsAt
? new Date(x.resetsAt * 1000).toLocaleString() ? new Date(x.resetsAt * 1000).toLocaleString()
@@ -556,16 +871,21 @@ function LimitCard({ x }: { x: Limit }) {
); );
} }
const Stat = ({ const Stat = ({
icon,
label, label,
v, v,
muted = false, muted = false,
}: { }: {
icon?: React.ReactNode;
label: string; label: string;
v: string; v: string;
muted?: boolean; muted?: boolean;
}) => ( }) => (
<div className="panel stat"> <div className="panel stat">
<div className="stat-label">
{icon}
<small>{label}</small> <small>{label}</small>
</div>
<b className={muted ? "muted" : ""}>{v}</b> <b className={muted ? "muted" : ""}>{v}</b>
</div> </div>
); );
@@ -1356,6 +1676,18 @@ const duration = (v?: number | null) =>
: v < 3600 : v < 3600
? `${Math.floor(v / 60)} 分 ${v % 60} 秒` ? `${Math.floor(v / 60)} 分 ${v % 60} 秒`
: `${(v / 3600).toFixed(1)} 小时`; : `${(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) => { const maskEmailPart = (part: string) => {
if (part.length <= 1) return "*"; if (part.length <= 1) return "*";
if (part.length === 2) return part[0] + "*"; if (part.length === 2) return part[0] + "*";
+227 -5
View File
@@ -294,9 +294,6 @@ header p {
display: flex; display: flex;
gap: 10px; gap: 10px;
} }
.account-select {
min-width: 220px;
}
.secondary { .secondary {
background: var(--panel); background: var(--panel);
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -313,6 +310,176 @@ header p {
color: var(--green); color: var(--green);
border-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 { .account {
padding: 20px 24px; padding: 20px 24px;
display: flex; display: flex;
@@ -367,6 +534,18 @@ header p {
.limit > small { .limit > small {
grid-column: 1/-1; 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 { .remaining strong {
display: block; display: block;
font-size: 50px; font-size: 50px;
@@ -757,10 +936,50 @@ a {
.refresh { .refresh {
width: 100%; width: 100%;
} }
.account-select { .account-summary-toggle {
min-width: 0; 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%; width: 100%;
} }
.account-info-grid {
grid-template-columns: 1fr 1fr;
}
.account { .account {
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr) auto; grid-template-columns: auto minmax(0, 1fr) auto;
@@ -893,6 +1112,9 @@ a {
.stats { .stats {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.account-info-grid {
grid-template-columns: 1fr;
}
.account { .account {
grid-template-columns: auto minmax(0, 1fr); 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 { import {
Area, Area,
AreaChart, AreaChart,
@@ -16,7 +16,15 @@ const compactAxis = (value: number) =>
maximumFractionDigits: 1, maximumFractionDigits: 1,
}).format(value); }).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( const [compact, setCompact] = useState(
() => matchMedia("(max-width: 480px)").matches, () => matchMedia("(max-width: 480px)").matches,
); );
@@ -39,7 +47,7 @@ export default function UsageChart({ data }: { data: Point[] }) {
} }
> >
<defs> <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="0" stopColor="#5ee7f7" stopOpacity={0.35} />
<stop offset="1" stopColor="#5ee7f7" stopOpacity={0} /> <stop offset="1" stopColor="#5ee7f7" stopOpacity={0} />
</linearGradient> </linearGradient>
@@ -74,7 +82,7 @@ export default function UsageChart({ data }: { data: Point[] }) {
type="monotone" type="monotone"
dataKey="totalTokens" dataKey="totalTokens"
stroke="#4dd8ed" stroke="#4dd8ed"
fill="url(#a)" fill={`url(#${gradientId})`}
strokeWidth={2} strokeWidth={2}
/> />
</AreaChart> </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); 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 }) => { test("shows the build version in the authenticated brand", async ({ page }) => {
await openSettings(page); await openSettings(page);
const mobile = (page.viewportSize()?.width ?? 0) <= 800; 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.goto("/");
await page
.locator('[data-account-id="1"]')
.getByRole("button", { name: "展开默认账号详情" })
.click();
const chart = page.locator(".chart"); const chart = page.locator(".chart");
await expect( await expect(
chart.getByRole("heading", { name: "每日 Token 趋势" }), chart.getByRole("heading", { name: "每日 Token 趋势" }),
@@ -505,13 +609,17 @@ test("Codex account emails are masked everywhere they are displayed", async ({
}); });
await page.goto("/"); 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", "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("已使用 25%", { exact: true })).toHaveCount(0);
await expect(page.getByText("剩余 75%", { 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", "aria-label",
"5h:已使用 25%,剩余 75%", "5h:已使用 25%,剩余 75%",
); );
@@ -548,12 +656,12 @@ test("returns to login when an authenticated request receives 401", async ({
return route.fulfill({ json: responses[key] ?? {} }); return route.fulfill({ json: responses[key] ?? {} });
}); });
await page.goto("/"); await page.goto("/");
await expect(page.locator(".account-select")).toBeVisible(); await expect(page.locator(".account-overview")).toBeVisible();
await page.getByRole("button", { name: "立即刷新" }).click(); await page.getByRole("button", { name: "刷新全部" }).click();
await expect(page.getByRole("heading", { name: "欢迎回来" })).toBeVisible(); 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, page,
}) => { }) => {
const accounts = [ const accounts = [
@@ -593,10 +701,15 @@ test("a slow previous account response cannot replace the selected account", asy
return route.fulfill({ json: responses[key] ?? {} }); return route.fulfill({ json: responses[key] ?? {} });
}); });
await page.goto("/"); await page.goto("/");
const selector = page.locator(".account-select"); const second = page.locator('[data-account-id="2"]');
await selector.selectOption("2"); await expect(second.locator(".account-summary-identity")).toContainText(
await expect(page.locator(".account b")).toContainText("第二账号"); "第二账号",
);
await page.waitForTimeout(350); await page.waitForTimeout(350);
await expect(page.locator(".account b")).toContainText("第二账号"); await expect(second.locator(".account-summary-identity")).toContainText(
await expect(page.locator(".account b")).not.toContainText("默认账号"); "第二账号",
);
await expect(
page.locator('[data-account-id="1"] .account-summary-identity'),
).toContainText("默认账号");
}); });