feat: show all accounts in collapsible overview
This commit is contained in:
+447
-115
@@ -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)}
|
||||
>
|
||||
{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}
|
||||
>
|
||||
{refresh === "done" ? <Check /> : <RefreshCw />}
|
||||
{refresh === "loading"
|
||||
? "刷新中"
|
||||
: refresh === "done"
|
||||
? "刷新完成"
|
||||
: "立即刷新"}
|
||||
</button>
|
||||
</div>
|
||||
<Header
|
||||
title="用量总览"
|
||||
sub={`共 ${accounts.length} 个账号,点击卡片查看完整详情`}
|
||||
>
|
||||
<button
|
||||
className={"secondary refresh " + refresh}
|
||||
disabled={refresh === "loading"}
|
||||
onClick={() => void syncAll()}
|
||||
>
|
||||
{refresh === "done" ? <Check /> : <RefreshCw />}
|
||||
{refresh === "loading"
|
||||
? "刷新中"
|
||||
: refresh === "done"
|
||||
? "刷新完成"
|
||||
: "刷新全部"}
|
||||
</button>
|
||||
</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">
|
||||
<small>下次重置</small>
|
||||
<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">
|
||||
<small>{label}</small>
|
||||
<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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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("默认账号");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user