1789 lines
53 KiB
TypeScript
1789 lines
53 KiB
TypeScript
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>,
|
||
);
|