Files
codex-helper/frontend/src/main.tsx
T
wuxu 8997886eef
Frontend / quality (push) Canceled after 0s
feat: migrate account usage to CLIProxyAPI
2026-09-08 09:56:08 +08:00

1789 lines
53 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { lazy, Suspense, useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import {
BrowserRouter,
Link,
Navigate,
Route,
Routes,
useLocation,
useNavigate,
} from "react-router-dom";
import {
Activity,
Bell,
ChevronDown,
Clock,
Coins,
Github,
Gauge,
LogIn,
Mail,
LogOut,
Moon,
MoreHorizontal,
Settings,
Sun,
Ticket,
Trash2,
TrendingUp,
Zap,
Wifi,
WifiOff,
} from "lucide-react";
import { api, del, get, post, put, toErrorMessage } from "./api";
import { AuthProvider, useAuth } from "./auth";
import { ThemeProvider, useTheme } from "./theme";
import {
decodeAccounts,
decodeAction,
decodeCode,
decodeDashboard,
decodeGeneral,
decodeOK,
decodeSMTP,
decodeTelegram,
type Account,
type Dashboard as Dash,
type GeneralSettings,
type Limit,
type SMTPSettingsForm,
type TelegramSettingsForm,
} from "./types";
import "./styles.css";
const UsageChart = lazy(() => import("./usage-chart"));
const repositoryURL = "https://github.com/zhoujun0601/codex-helper";
function GitHubLink({ className = "" }: { className?: string }) {
return (
<a
className={`github-link ${className}`.trim()}
href={repositoryURL}
target="_blank"
rel="noopener noreferrer"
aria-label="在 GitHub 上查看 Codex Helper"
title="GitHub"
>
<Github />
<span>GitHub</span>
</a>
);
}
function App() {
const { status, authenticated } = useAuth();
if (!status.initialized) return <Setup version={status.version} />;
return (
<BrowserRouter>
<Routes>
{authenticated ? (
<Route path="*" element={<Shell version={status.version} />} />
) : (
<>
<Route path="/login" element={<Login version={status.version} />} />
<Route
path="*"
element={<PublicShell version={status.version} />}
/>
</>
)}
</Routes>
</BrowserRouter>
);
}
const Splash = () => (
<main className="center">
<div className="logo">
<Zap /> Codex Helper
</div>
<div className="spinner" />
</main>
);
function Brand({ version }: { version?: string }) {
return (
<div className="logo">
<Zap /> <span className="brand-name">Codex Helper</span>
{version && <span className="version-badge">v{version}</span>}
</div>
);
}
function Setup({ version }: { version: string }) {
const { setup } = useAuth();
const [form, set] = useState({
username: "admin",
password: "",
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
const [err, setErr] = useState("");
async function submit(e: React.FormEvent) {
e.preventDefault();
try {
await setup(form.username, form.password, form.timezone);
} catch (x) {
setErr(toErrorMessage(x));
}
}
return (
<main className="setup">
<section className="hero">
<Brand version={version} />
<h1>
连接你的 Codex
<br />
<span>看见每一次增长</span>
</h1>
<p>一个容器内完成用量洞察、重置提醒与账户管理。</p>
</section>
<form className="panel form" onSubmit={submit}>
<div className="setup-mobile-brand">
<Brand version={version} />
</div>
<small>首次初始化 · 1 / 1</small>
<h2>创建管理员</h2>
<label>
用户名
<input
value={form.username}
onChange={(e) => set({ ...form, username: e.target.value })}
/>
</label>
<label>
密码
<input
type="password"
minLength={10}
value={form.password}
onChange={(e) => set({ ...form, password: e.target.value })}
placeholder="至少 10 位"
/>
</label>
<label>
时区
<input
value={form.timezone}
onChange={(e) => set({ ...form, timezone: e.target.value })}
/>
</label>
{err && <p className="error">{err}</p>}
<button>完成初始化</button>
<p className="hint">
Codex、Telegram 与 SMTP 可在进入系统后通过设置中心完成。
</p>
</form>
</main>
);
}
function Login({ version }: { version: string }) {
const { login } = useAuth();
const [u, setU] = useState("admin"),
[p, setP] = useState(""),
[e, setE] = useState("");
return (
<main className="center">
<form
className="panel form login"
onSubmit={async (x) => {
x.preventDefault();
try {
await login(u, p);
} catch (q) {
setE(toErrorMessage(q));
}
}}
>
<GitHubLink className="login-github" />
<Brand version={version} />
<h2>欢迎回来</h2>
<label>
用户名
<input value={u} onChange={(x) => setU(x.target.value)} />
</label>
<label>
密码
<input
type="password"
value={p}
onChange={(x) => setP(x.target.value)}
/>
</label>
{e && <p className="error">{e}</p>}
<button>登录</button>
<p className="hint public-login-link">
<Link to="/">查看公开总览</Link>
</p>
</form>
</main>
);
}
function PublicShell({ version }: { version: string }) {
const nav = useNavigate();
return (
<div className="public-app">
<header className="public-header">
<Brand version={version} />
<button className="secondary" onClick={() => nav("/login")}>
<LogIn /> 登录后配置
</button>
</header>
<main className="public-content">
<Routes>
<Route path="/" element={<Dashboard publicView />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main>
</div>
);
}
function Shell({ version }: { version: string }) {
const { logout } = useAuth();
const nav = useNavigate();
const location = useLocation();
const { theme, saveTheme, error: themeError } = useTheme();
const [shellError, setShellError] = useState("");
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const mobileMenuRef = useRef<HTMLDivElement>(null);
const mobileMenuButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => setMobileMenuOpen(false), [location.pathname]);
useEffect(() => {
if (!mobileMenuOpen) return;
const close = (event: PointerEvent) => {
if (!mobileMenuRef.current?.contains(event.target as Node))
setMobileMenuOpen(false);
};
const escape = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
setMobileMenuOpen(false);
mobileMenuButtonRef.current?.focus();
};
document.addEventListener("pointerdown", close);
document.addEventListener("keydown", escape);
return () => {
document.removeEventListener("pointerdown", close);
document.removeEventListener("keydown", escape);
};
}, [mobileMenuOpen]);
const changeTheme = () =>
void saveTheme(theme === "dark" ? "light" : "dark").catch(() => undefined);
const signOut = async () => {
if (!confirm("确定要退出 Codex Helper 管理后台吗?")) return;
try {
setShellError("");
await logout();
} catch (error) {
setShellError(toErrorMessage(error));
}
};
return (
<div className="app">
<aside>
<Brand version={version} />
<nav>
<button
className={location.pathname === "/" ? "active" : ""}
aria-current={location.pathname === "/" ? "page" : undefined}
onClick={() => nav("/")}
>
<Activity />
总览
</button>
<button
className={location.pathname === "/settings" ? "active" : ""}
aria-current={
location.pathname === "/settings" ? "page" : undefined
}
onClick={() => nav("/settings")}
>
<Settings />
设置中心
</button>
</nav>
<div className="aside-bottom">
<GitHubLink />
<button onClick={changeTheme}>
{theme === "dark" ? <Sun /> : <Moon />}切换主题
</button>
{themeError && <small className="error">{themeError}</small>}
<button onClick={signOut}>
<LogOut />
退出
</button>
{shellError && <small className="error">{shellError}</small>}
</div>
</aside>
<div className="mobile-topbar">
<Brand version={version} />
<div className="mobile-menu" ref={mobileMenuRef}>
<button
ref={mobileMenuButtonRef}
className="mobile-menu-trigger secondary"
aria-label="打开更多操作"
aria-expanded={mobileMenuOpen}
aria-controls="mobile-actions"
onClick={() => setMobileMenuOpen((open) => !open)}
>
<MoreHorizontal />
</button>
{mobileMenuOpen && (
<div id="mobile-actions" className="mobile-menu-popover">
<GitHubLink />
<button onClick={changeTheme}>
{theme === "dark" ? <Sun /> : <Moon />}切换主题
</button>
<button onClick={signOut}>
<LogOut />
退出
</button>
{(themeError || shellError) && (
<small className="error">{themeError || shellError}</small>
)}
</div>
)}
</div>
</div>
<section className="content">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</section>
<nav className="mobile-bottom-nav" aria-label="主导航">
<button
className={location.pathname === "/" ? "active" : ""}
aria-current={location.pathname === "/" ? "page" : undefined}
onClick={() => nav("/")}
>
<Activity />
<span>总览</span>
</button>
<button
className={location.pathname === "/settings" ? "active" : ""}
aria-current={location.pathname === "/settings" ? "page" : undefined}
onClick={() => nav("/settings")}
>
<Settings />
<span>设置</span>
</button>
</nav>
</div>
);
}
type DashboardCardState = {
dashboard: Dash | null;
loading: boolean;
error: string;
};
const emptyDashboardCardState = (): DashboardCardState => ({
dashboard: null,
loading: false,
error: "",
});
function Dashboard({ publicView = false }: { publicView?: boolean }) {
const [accounts, setAccounts] = useState<Account[] | null>(null),
[cards, setCards] = useState<Record<number, DashboardCardState>>({}),
[expanded, setExpanded] = useState<Record<number, boolean>>({}),
[error, setError] = useState("");
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 loadDashboard = async (accountId: number, signal?: AbortSignal) => {
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: "",
});
try {
const dashboard = await get(
"dashboard?accountId=" + accountId,
decodeDashboard,
signal,
);
if (
requestRef.current.get(accountId) !== request ||
dashboard.accountId !== accountId
)
return;
updateCard(accountId, {
dashboard,
loading: false,
error: "",
});
} catch (value) {
if (signal.aborted || requestRef.current.get(accountId) !== request)
return;
updateCard(accountId, {
loading: false,
error: toErrorMessage(value),
});
} 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 (!accounts?.length) return;
const controller = new AbortController();
let timer = 0;
const poll = async () => {
await Promise.allSettled(
accounts.map((account) => loadDashboard(account.id, controller.signal)),
);
if (!controller.signal.aborted) timer = window.setTimeout(poll, 30_000);
};
void poll();
return () => {
controller.abort();
window.clearTimeout(timer);
};
}, [accounts]);
useEffect(
() => () => {
activeControllersRef.current.forEach((controller) => controller.abort());
},
[],
);
if (accounts === null)
return (
<>
<Header title="用量总览" />
{error ? (
<div className="panel empty">
<p className="error">{error}</p>
<button onClick={() => void loadAccounts()}>重试</button>
</div>
) : (
<Splash />
)}
</>
);
if (!accounts.length)
return (
<>
<Header
title="用量总览"
sub={
publicView ? "当前还没有可公开展示的账号" : "请先在设置中心添加账号"
}
/>
<div className="panel empty">尚未添加 Codex 账号</div>
</>
);
return (
<>
<Header
title="用量总览"
sub={`${publicView ? "公开只读 · " : ""}共 ${accounts.length} 个账号,服务器每 5 分钟自动同步`}
/>
{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()}
publicView={publicView}
expanded={Boolean(expanded[account.id])}
onToggle={() =>
setExpanded((current) => ({
...current,
[account.id]: !current[account.id],
}))
}
/>
))}
</div>
</>
);
}
function AccountOverviewCard({
account,
state,
publicView,
expanded,
onToggle,
}: {
account: Account;
state: DashboardCardState;
publicView: boolean;
expanded: boolean;
onToggle: () => 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>
{publicView ? "公开只读" : 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}
publicView={publicView}
/>
) : (
<div className="account-detail-state">
{state.loading && <div className="spinner" />}
<p className={state.error ? "error" : ""}>
{state.error || "正在读取账号用量…"}
</p>
</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 limits = quickSummaryLimits(dashboard.limits);
if (!limits.length)
return <span className="account-summary-metrics">限额暂无</span>;
return (
<span
className={`account-summary-metrics account-summary-limits${limits.length === 1 ? " single" : ""}`}
>
{limits.map(({ kind, limit }) => {
const remaining = Math.round(remainingPercent(limit));
return (
<span
className="account-summary-limit"
data-window-kind={kind}
key={`${kind}-${limit.limitId}-${limit.windowType}`}
aria-label={`${quickLimitLabel(kind, limit)},剩余 ${remaining}%,${limit.resetsAt ? `${relative(limit.resetsAt)}重置` : "重置时间暂无"}`}
>
<span className="account-summary-limit-name">
{quickLimitLabel(kind, limit)}
</span>
<strong style={{ color: `hsl(${remaining * 1.2} 70% 45%)` }}>
{remaining}%
</strong>
<span className="account-summary-limit-remaining">剩余</span>
<span
className="account-summary-limit-reset"
title={
limit.resetsAt
? new Date(limit.resetsAt * 1000).toLocaleString()
: undefined
}
>
{limit.resetsAt
? `${relative(limit.resetsAt)}重置`
: "重置时间暂无"}
</span>
</span>
);
})}
</span>
);
}
function AccountDashboardDetails({
dashboard,
state,
publicView,
}: {
dashboard: Dash;
state: DashboardCardState;
publicView: boolean;
}) {
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>
</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={
publicView
? "登录后查看"
: 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>
{dashboard.resetCredits && dashboard.resetCredits.availableCount > 0 && (
<div className="reset-credits" data-testid="reset-credits">
<Ticket />
<div className="reset-credits-main">
<small>重置卡</small>
<b>可用 {dashboard.resetCredits.availableCount} 张</b>
</div>
<div className="reset-credits-expiry">
<small>到期时间</small>
<b>{formatResetCreditExpiry(dashboard.resetCredits.expiresAt)}</b>
</div>
</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">
{publicView
? "该连接暂无限额数据"
: "该连接暂无限额数据,服务器会自动同步。"}
</div>
)}
{dashboard.currentCycle && (
<div className="cycle-note">
<Clock />
<span>
本周期:{cycleTimestamp(dashboard.currentCycle.startedAt)} 至{" "}
{cycleTimestamp(dashboard.currentCycle.resetsAt)}(
{cycleDuration(dashboard.currentCycle.windowDurationMinutes)}
,按每日数据汇总)
</span>
</div>
)}
<div className="stats">
<Stat
icon={<Coins />}
label="本周期 Tokens"
v={num(dashboard.currentCycle?.totalTokens)}
/>
<Stat
icon={<TrendingUp />}
label="本周期单日峰值"
v={num(dashboard.summary.peakDailyTokens)}
/>
</div>
<Suspense fallback={<SettingsLoading />}>
<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,
usedLabel = Math.round(used),
leftLabel = Math.round(left),
leftColor = `hsl(${left * 1.2} 70% 45%)`;
return (
<div className="panel limit">
<div className="limit-heading">
<Gauge />
<small>{limitLabel(x)}</small>
</div>
<div className="remaining">
<strong style={{ color: leftColor }}>
{leftLabel}
<i>%</i>
</strong>
<span>剩余</span>
</div>
<div className="reset">
<div className="limit-heading">
<Clock />
<small>下次重置</small>
</div>
<b>
{x.resetsAt
? new Date(x.resetsAt * 1000).toLocaleString()
: "暂未提供"}
</b>
<span>{x.resetsAt ? relative(x.resetsAt) : ""}</span>
</div>
<div
className="bar"
aria-label={`${x.windowType}:已使用 ${usedLabel}%,剩余 ${leftLabel}%`}
>
<em style={{ width: left + "%", background: leftColor }} />
<i style={{ width: used + "%" }} />
</div>
</div>
);
}
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>
);
type SettingsTab = "general" | "codex" | "telegram" | "smtp";
const settingsTabs: Array<{
id: SettingsTab;
label: string;
icon: React.ReactNode;
content: React.ReactNode;
}> = [
{ id: "general", label: "通用", icon: <Settings />, content: <General /> },
{ id: "codex", label: "Codex", icon: <Zap />, content: <CodexSettings /> },
{
id: "telegram",
label: "Telegram",
icon: <Bell />,
content: <Telegram />,
},
{ id: "smtp", label: "SMTP 邮件", icon: <Bell />, content: <SMTP /> },
];
function SettingsPage() {
const [tab, setTab] = useState<SettingsTab>("general");
const selectAdjacentTab = (
event: React.KeyboardEvent<HTMLButtonElement>,
currentIndex: number,
) => {
let nextIndex: number | undefined;
if (event.key === "ArrowRight" || event.key === "ArrowDown")
nextIndex = (currentIndex + 1) % settingsTabs.length;
if (event.key === "ArrowLeft" || event.key === "ArrowUp")
nextIndex =
(currentIndex - 1 + settingsTabs.length) % settingsTabs.length;
if (event.key === "Home") nextIndex = 0;
if (event.key === "End") nextIndex = settingsTabs.length - 1;
if (nextIndex === undefined) return;
event.preventDefault();
const nextTab = settingsTabs[nextIndex].id;
setTab(nextTab);
document.getElementById(`settings-tab-${nextTab}`)?.focus();
};
return (
<>
<Header title="设置中心" />
<div className="settings">
<div className="tabs" role="tablist" aria-label="设置分类">
{settingsTabs.map((item, index) => (
<button
key={item.id}
id={`settings-tab-${item.id}`}
type="button"
role="tab"
aria-selected={tab === item.id}
aria-controls={`settings-panel-${item.id}`}
tabIndex={tab === item.id ? 0 : -1}
className={tab === item.id ? "active" : ""}
onClick={() => setTab(item.id)}
onKeyDown={(event) => selectAdjacentTab(event, index)}
>
{item.icon}
{item.label}
</button>
))}
</div>
<div className="settings-content">
{settingsTabs.map((item) => {
const active = tab === item.id;
return (
<section
key={item.id}
id={`settings-panel-${item.id}`}
className={`settings-panel${active ? " active" : ""}`}
role="tabpanel"
aria-labelledby={`settings-tab-${item.id}`}
aria-hidden={!active}
inert={!active}
>
{item.content}
</section>
);
})}
</div>
</div>
</>
);
}
const SettingsLoading = () => (
<div
className="panel wide settings-loading"
role="status"
aria-label="正在加载设置"
>
<div className="spinner" />
</div>
);
function General() {
const [v, setV] = useState<GeneralSettings | null>(null),
[msg, setMsg] = useState(""),
[loadError, setLoadError] = useState("");
const { applyTheme } = useTheme();
const loadGeneral = async () => {
try {
setLoadError("");
const value = await get("settings/general", decodeGeneral);
setV(value);
applyTheme(value.theme);
} catch (error) {
setLoadError(toErrorMessage(error));
}
};
useEffect(() => {
void loadGeneral();
}, []);
if (!v)
return loadError ? (
<div className="panel wide">
<p className="error">{loadError}</p>
<button onClick={() => void loadGeneral()}>重试</button>
</div>
) : (
<SettingsLoading />
);
return (
<form
className="panel form wide"
onSubmit={async (e) => {
e.preventDefault();
try {
const saved = await api("settings/general", decodeGeneral, {
method: "PUT",
body: JSON.stringify(v),
});
setV(saved);
applyTheme(saved.theme);
setMsg("设置已保存");
} catch (x) {
setMsg(toErrorMessage(x));
}
}}
>
<h2>通用设置</h2>
<div className="grid2">
<label>
时区
<input
value={v.timezone}
onChange={(e) => setV({ ...v, timezone: e.target.value })}
/>
</label>
<label>
主题
<select
value={v.theme}
onChange={(e) => {
const theme = e.target.value;
if (theme === "system" || theme === "dark" || theme === "light")
setV({ ...v, theme });
}}
>
<option value="system">跟随系统</option>
<option value="dark">深色</option>
<option value="light">浅色</option>
</select>
</label>
<div className="fixed-setting">
<span>自动同步</span>
<b>服务器每 5 分钟自动刷新所有账号</b>
</div>
<label>
历史保留(天)
<select
value={v.retentionDays}
onChange={(e) => setV({ ...v, retentionDays: +e.target.value })}
>
{[30, 60, 90, 180, 365].map((x) => (
<option key={x}>{x}</option>
))}
</select>
</label>
<label>
提前提醒(分钟)
<input
type="number"
min="1"
max="1440"
value={v.beforeMinutes}
onChange={(e) => setV({ ...v, beforeMinutes: +e.target.value })}
/>
</label>
<label className="checks">
<input
type="checkbox"
checked={v.notifyBefore}
onChange={(e) => setV({ ...v, notifyBefore: e.target.checked })}
/>
重置前提醒{" "}
<input
type="checkbox"
checked={v.notifyAfter}
onChange={(e) => setV({ ...v, notifyAfter: e.target.checked })}
/>
重置后确认
</label>
</div>
<button>保存设置</button>
{msg && <p className="hint">{msg}</p>}
</form>
);
}
function CodexSettings() {
const [xs, setXs] = useState<Account[]>([]),
[active, setActive] = useState(0),
[newAuthIndex, setNewAuthIndex] = useState(""),
[newKind, setNewKind] = useState<"personal" | "team">("team"),
[newPublicVisible, setNewPublicVisible] = useState(false),
[busy, setBusy] = useState(false),
[err, setErr] = useState("");
const load = async (signal?: AbortSignal) => {
const accounts = await get("accounts", decodeAccounts, signal);
setXs(accounts);
return accounts;
};
useEffect(() => {
void load().catch((error) => setErr(toErrorMessage(error)));
}, []);
const updateAccount = async (
account: Account,
changes: Partial<
Pick<
Account,
"displayName" | "authIndex" | "expectedKind" | "publicVisible"
>
>,
) => {
try {
setErr("");
await put(
`accounts/${account.id}`,
decodeOK,
{
displayName: changes.displayName ?? account.displayName,
authIndex: changes.authIndex ?? account.authIndex,
expectedKind: changes.expectedKind ?? account.expectedKind,
publicVisible: changes.publicVisible ?? account.publicVisible,
},
changes.authIndex === undefined ? 30_000 : 60_000,
);
await load();
return true;
} catch (error) {
setErr(toErrorMessage(error));
return false;
}
};
const add = async () => {
const authIndex = newAuthIndex.trim();
if (!authIndex) {
setErr("请输入 CPA authIndex");
return;
}
try {
setBusy(true);
setActive(0);
setErr("");
const account = await post(
"accounts",
(value) => decodeAccounts([value])[0],
{
displayName: "",
authIndex,
expectedKind: newKind,
publicVisible: newPublicVisible,
},
undefined,
60_000,
);
setNewAuthIndex("");
await load();
setActive(account.id);
} catch (error) {
setErr(toErrorMessage(error));
} finally {
setBusy(false);
}
};
return (
<div className="codex-settings wide">
<div className="codex-heading">
<h2>Codex 账户与工作区</h2>
<p>
账号凭据和 OAuth 刷新由 CLIProxyAPI 管理;这里通过 CPA authIndex
绑定需要展示的 Codex 账号。
</p>
</div>
<section className="panel add-account-card">
<div>
<h3>添加 CPA 账号</h3>
<p>authIndex 可从 CLIProxyAPI 的认证文件列表中取得。</p>
</div>
<div className="add-account-controls">
<label>
CPA authIndex
<input
aria-label="CPA authIndex"
autoComplete="off"
placeholder="例如 6c4894e0cb312814"
value={newAuthIndex}
onChange={(event) => setNewAuthIndex(event.target.value)}
/>
</label>
<label>
预期连接类型
<select
value={newKind}
onChange={(event) =>
setNewKind(event.target.value as "personal" | "team")
}
>
<option value="team">Team / Business 工作区</option>
<option value="personal">个人订阅</option>
</select>
</label>
<label className="checks">
<input
type="checkbox"
checked={newPublicVisible}
onChange={(event) => setNewPublicVisible(event.target.checked)}
/>
公开显示到未登录总览
</label>
<button disabled={busy} onClick={add}>
{busy && active === 0 ? "正在验证并添加…" : "添加账号"}
</button>
</div>
</section>
<div className="account-list">
{xs.length === 0 && (
<div className="panel empty account-empty">
尚未绑定 CPA Codex 账号,请在上方输入 authIndex。
</div>
)}
{xs.map((account) => (
<section className="panel account-card" key={account.id}>
<div className="account-card-main">
<span className={"dot " + (account.connected ? "ok" : "")} />
<div className="account-details">
<input
aria-label="连接名称"
defaultValue={account.displayName}
onBlur={async (event) => {
const input = event.currentTarget;
const displayName = input.value.trim();
if (
displayName &&
displayName !== account.displayName &&
!(await updateAccount(account, { displayName }))
)
input.value = account.displayName;
}}
/>
<div className="account-meta">
<span>
{account.email
? maskEmail(account.email)
: "账号信息暂不可用"}
</span>
<span>{planLabel(account.planType)}</span>
<span
className={
account.validationStatus === "mismatch" ? "error" : ""
}
>
{validationLabel(account)}
</span>
</div>
{account.possibleDuplicate && (
<small className="hint">
同一邮箱已有相同类型绑定,请确认 authIndex 没有指向重复凭据
</small>
)}
</div>
</div>
<div className="account-card-actions">
<label>
CPA authIndex
<input
aria-label={`“${account.displayName}”的 CPA authIndex`}
autoComplete="off"
defaultValue={account.authIndex}
onBlur={async (event) => {
const input = event.currentTarget;
const authIndex = input.value.trim();
if (!authIndex || authIndex === account.authIndex) return;
if (
!confirm(
`更换“${account.displayName}”的 authIndex 会清除该本地账号已有的 Token 历史、限额快照和提醒记录。继续吗?`,
)
) {
input.value = account.authIndex;
return;
}
if (!(await updateAccount(account, { authIndex })))
input.value = account.authIndex;
}}
/>
</label>
<label>
预期连接类型
<select
aria-label="预期连接类型"
value={account.expectedKind}
onChange={(event) =>
void updateAccount(account, {
expectedKind: event.target
.value as Account["expectedKind"],
})
}
>
<option value="any">不校验</option>
<option value="personal">个人订阅</option>
<option value="team">Team / Business</option>
</select>
</label>
<label className="checks">
<input
type="checkbox"
checked={account.publicVisible}
onChange={(event) =>
void updateAccount(account, {
publicVisible: event.target.checked,
})
}
/>
公开显示到未登录总览
</label>
<div className="account-buttons">
<button
className="secondary"
disabled={busy}
onClick={async () => {
try {
setBusy(true);
setActive(account.id);
setErr("");
await post(
`accounts/${account.id}/sync`,
decodeOK,
{},
undefined,
60_000,
);
await load();
} catch (error) {
setErr(toErrorMessage(error));
} finally {
setBusy(false);
}
}}
>
{busy && active === account.id ? "同步中…" : "立即同步"}
</button>
<button
className="icon danger"
disabled={busy}
title="删除本地绑定"
aria-label={`删除“${account.displayName}”`}
onClick={async () => {
if (
!confirm(
`删除“${account.displayName}”的本地绑定及全部历史数据?此操作不会删除 CLIProxyAPI 中的凭据,且无法撤销。`,
)
)
return;
try {
setErr("");
await del(`accounts/${account.id}`, decodeOK);
if (active === account.id) setActive(0);
if (+localStorage.accountId === account.id)
localStorage.removeItem("accountId");
await load();
} catch (error) {
setErr(toErrorMessage(error));
}
}}
>
<Trash2 />
</button>
</div>
</div>
</section>
))}
</div>
{err && <p className="error">{err}</p>}
</div>
);
}
function Telegram() {
const [v, setV] = useState<TelegramSettingsForm | null>(null),
[msg, setMsg] = useState(""),
[code, setCode] = useState(""),
[loadError, setLoadError] = useState("");
const loadTelegram = async () => {
try {
setLoadError("");
const value = await get("settings/telegram", decodeTelegram);
setV({ ...value, token: "" });
} catch (error) {
setLoadError(toErrorMessage(error));
}
};
useEffect(() => {
void loadTelegram();
}, []);
if (!v)
return loadError ? (
<div className="panel wide">
<p className="error">{loadError}</p>
<button onClick={() => void loadTelegram()}>重试</button>
</div>
) : (
<SettingsLoading />
);
return (
<form
className="panel form wide"
onSubmit={async (e) => {
e.preventDefault();
try {
const x = await api("settings/telegram", decodeTelegram, {
method: "PUT",
body: JSON.stringify(v),
});
setV({ ...x, token: "" });
setMsg(x.warning || "Bot 已验证并保存");
} catch (x) {
setMsg(toErrorMessage(x));
}
}}
>
<h2>Telegram Bot</h2>
<label>
Bot Token
<input
type="password"
value={v.token || ""}
onChange={(e) => setV({ ...v, token: e.target.value })}
placeholder={
v.configured ? "已配置,留空表示不修改" : "从 BotFather 获取"
}
/>
</label>
<p className="hint">Bot 绑定后会自动启用额度提醒和查询菜单。</p>
<button>验证并保存</button>
<div className="actions">
<button
type="button"
className="secondary"
onClick={async () => {
try {
const x = await post("settings/telegram/bind", decodeCode);
setCode(x.code);
} catch (error) {
setMsg(toErrorMessage(error));
}
}}
>
生成绑定码
</button>
<button
type="button"
className="secondary"
onClick={async () => {
try {
await post("settings/telegram/test", decodeOK);
setMsg("测试消息已发送");
} catch (x) {
setMsg(toErrorMessage(x));
}
}}
>
发送测试
</button>
<button
type="button"
className="danger"
disabled={!v.configured}
onClick={async () => {
if (
!confirm(
"确定要解除 Telegram Bot 绑定吗?这会删除 Bot Token、Chat ID 和未使用的绑定码。",
)
)
return;
try {
const x = await del("settings/telegram", decodeAction);
setV({
chatId: 0,
enabled: false,
menuEnabled: false,
configured: false,
token: "",
});
setCode("");
setMsg(x.warning || "Telegram Bot 配置已删除");
} catch (error) {
setMsg(toErrorMessage(error));
}
}}
>
<Trash2 />
解除绑定
</button>
</div>
{code && (
<div className="codebox">
<span>向 Bot 发送</span>
<strong>/bind {code}</strong>
</div>
)}
{msg && <p className="hint">{msg}</p>}
</form>
);
}
function SMTP() {
const [v, setV] = useState<SMTPSettingsForm | null>(null),
[msg, setMsg] = useState(""),
[loadError, setLoadError] = useState("");
const loadSMTP = async () => {
try {
setLoadError("");
const value = await get("settings/smtp", decodeSMTP);
setV({ ...value, password: "" });
} catch (error) {
setLoadError(toErrorMessage(error));
}
};
useEffect(() => {
void loadSMTP();
}, []);
if (!v)
return loadError ? (
<div className="panel wide">
<p className="error">{loadError}</p>
<button onClick={() => void loadSMTP()}>重试</button>
</div>
) : (
<SettingsLoading />
);
const save = async (e: React.FormEvent) => {
e.preventDefault();
try {
const saved = await api("settings/smtp", decodeSMTP, {
method: "PUT",
body: JSON.stringify(v),
});
setV({ ...saved, password: "" });
setMsg("SMTP 设置已保存");
} catch (x) {
setMsg(toErrorMessage(x));
}
};
return (
<form className="panel form wide" onSubmit={save}>
<h2>SMTP 邮件</h2>
<div className="grid2">
<label>
服务器
<input
value={v.host}
onChange={(e) => setV({ ...v, host: e.target.value })}
placeholder="smtp.example.com"
/>
</label>
<label>
端口
<input
type="number"
value={v.port}
onChange={(e) => setV({ ...v, port: +e.target.value })}
/>
</label>
<label>
用户名
<input
value={v.username}
onChange={(e) => setV({ ...v, username: e.target.value })}
/>
</label>
<label>
密码
<input
type="password"
value={v.password || ""}
onChange={(e) => setV({ ...v, password: e.target.value })}
placeholder={v.configured ? "已配置,留空不修改" : ""}
/>
</label>
<label>
加密
<select
value={v.security}
onChange={(e) => {
const security = e.target.value;
if (
security === "starttls" ||
security === "tls" ||
security === "none"
)
setV({ ...v, security });
}}
>
<option value="starttls">STARTTLS</option>
<option value="tls">隐式 TLS</option>
<option value="none">无加密</option>
</select>
</label>
<label>
发件名称
<input
value={v.fromName}
onChange={(e) => setV({ ...v, fromName: e.target.value })}
/>
</label>
<label>
发件地址
<input
type="email"
value={v.from}
onChange={(e) => setV({ ...v, from: e.target.value })}
/>
</label>
<label>
收件地址
<input
type="email"
value={v.to}
onChange={(e) => setV({ ...v, to: e.target.value })}
/>
</label>
</div>
<label className="checks">
<input
type="checkbox"
checked={v.enabled}
onChange={(e) => setV({ ...v, enabled: e.target.checked })}
/>
启用邮件提醒
</label>
<div className="actions">
<button>保存</button>
<button
type="button"
className="secondary"
onClick={async () => {
try {
await post("settings/smtp/test", decodeOK);
setMsg("测试邮件已发送");
} catch (x) {
setMsg(toErrorMessage(x));
}
}}
>
发送测试邮件
</button>
</div>
{msg && <p className="hint">{msg}</p>}
</form>
);
}
function Header({
title,
sub,
children,
}: {
title: string;
sub?: string;
children?: React.ReactNode;
}) {
return (
<header>
<div>
<h1>{title}</h1>
{sub && <p>{sub}</p>}
</div>
{children}
</header>
);
}
const num = (v?: number | null) =>
v == null
? "暂无"
: new Intl.NumberFormat("en", {
notation: "compact",
maximumFractionDigits: 1,
}).format(v);
const cycleTimestamp = (value: number) =>
new Date(value * 1000).toLocaleString();
const formatResetCreditExpiry = (values: number[]) =>
values.length ? values.map(cycleTimestamp).join("、") : "服务端未提供";
const cycleDuration = (minutes: number) =>
minutes % (24 * 60) === 0
? `${minutes / (24 * 60)} 天窗口`
: minutes % 60 === 0
? `${minutes / 60} 小时窗口`
: `${minutes} 分钟窗口`;
const remainingPercent = (limit: Limit) =>
100 - Math.min(100, Math.max(0, limit.usedPercent));
type QuickLimitKind = "hour" | "week" | "other";
const quickSummaryLimits = (
limits: Limit[],
): Array<{ kind: QuickLimitKind; limit: Limit }> => {
const sorted = [...limits].sort(
(a, b) => a.windowDurationMinutes - b.windowDurationMinutes,
);
const hour = sorted.find(
(limit) =>
limit.windowDurationMinutes > 0 && limit.windowDurationMinutes < 24 * 60,
);
const week =
sorted.find(
(limit) =>
limit.limitId.toLowerCase() === "codex" &&
limit.windowDurationMinutes === 168 * 60,
) ??
sorted.find(
(limit) =>
limit.limitName == null && limit.windowDurationMinutes === 168 * 60,
);
const selected: Array<{ kind: QuickLimitKind; limit: Limit }> = [];
if (hour) selected.push({ kind: "hour", limit: hour });
if (week && week !== hour) selected.push({ kind: "week", limit: week });
if (selected.length) return selected;
return sorted.slice(0, 2).map((limit) => ({ kind: "other", limit }));
};
const quickLimitLabel = (kind: QuickLimitKind, limit: Limit) => {
if (kind === "week") return "周额度";
if (kind === "hour") {
const hours = limit.windowDurationMinutes / 60;
return `${Number.isInteger(hours) ? hours : hours.toFixed(1)} 小时额度`;
}
return limitLabel(limit).replace(/限额$/, "额度");
};
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] + "*";
return part[0] + "*".repeat(part.length - 2) + part.at(-1);
};
const maskEmail = (email: string) => {
const at = email.lastIndexOf("@");
if (at < 1 || at === email.length - 1) return maskEmailPart(email);
const local = email.slice(0, at);
const domain = email.slice(at + 1).split(".");
return `${maskEmailPart(local)}@${domain
.map((part, index) =>
index === domain.length - 1 ? part : maskEmailPart(part),
)
.join(".")}`;
};
const planLabel = (plan?: string | null) => {
if (!plan) return "未识别套餐";
const labels: Record<string, string> = {
free: "Free",
go: "Go",
plus: "Plus",
pro: "Pro",
prolite: "Pro Lite",
team: "Team",
business: "Business",
self_serve_business_prolite: "Business Pro Lite",
self_serve_business_usage_based: "Business(按量)",
enterprise: "Enterprise",
edu: "Edu",
};
return labels[plan.toLowerCase()] || plan;
};
const validationLabel = (account: Account) => {
if (account.expectedKind === "any") return "未启用套餐类型校验";
if (account.validationStatus === "pending")
return `等待验证${account.expectedKind === "team" ? " Team / Business" : "个人订阅"}`;
if (account.validationStatus === "matched")
return account.actualKind === "team"
? "已连接 Team / Business 工作区"
: "已连接个人订阅";
if (account.validationStatus === "mismatch")
return `类型不匹配:实际为 ${planLabel(account.planType)},请退出后重新授权`;
return `无法验证工作区类型:${planLabel(account.planType)}`;
};
const relative = (ts: number) => {
const s = Math.max(0, ts - Math.floor(Date.now() / 1000));
if (!s) return "即将重置";
const d = Math.floor(s / 86400),
h = Math.floor((s % 86400) / 3600),
m = Math.floor((s % 3600) / 60);
return d ? `${d} 天 ${h} 小时后` : h ? `${h} 小时 ${m} 分后` : `${m} 分钟后`;
};
const root = document.getElementById("root");
if (!root) throw new Error("缺少 #root 挂载节点");
createRoot(root).render(
<React.StrictMode>
<AuthProvider>
<ThemeProvider>
<App />
</ThemeProvider>
</AuthProvider>
</React.StrictMode>,
);