fix: harden frontend state and tooling

This commit is contained in:
zhoujun0601
2026-08-13 12:04:23 -04:00
parent 833908ac9c
commit a027edda88
19 changed files with 1773 additions and 392 deletions
+125 -4
View File
@@ -1,4 +1,125 @@
export async function api<T>(path:string,init:RequestInit={}):Promise<T>{const r=await fetch('/api/v1/'+path,{credentials:'same-origin',...init,headers:{'Content-Type':'application/json','X-Requested-With':'codex-helper',...(init.headers||{})}});const j=await r.json().catch(()=>({}));if(!r.ok)throw new Error(j.error||`HTTP ${r.status}`);return j}
export const post=<T>(p:string,v:unknown={})=>api<T>(p,{method:'POST',body:JSON.stringify(v)})
export const put=<T>(p:string,v:unknown)=>api<T>(p,{method:'PUT',body:JSON.stringify(v)})
export const del=<T>(p:string)=>api<T>(p,{method:'DELETE'})
export class ApiError extends Error {
constructor(
message: string,
readonly kind: "http" | "network" | "timeout" | "invalid-response",
readonly status?: number,
) {
super(message);
this.name = "ApiError";
}
}
export type Decoder<T> = (value: unknown) => T;
let unauthorizedHandler: (() => void) | undefined;
export function onUnauthorized(handler?: () => void) {
unauthorizedHandler = handler;
}
export const unknownResponse: Decoder<unknown> = (value) => value;
export async function api<T>(
path: string,
decoder: Decoder<T>,
init: RequestInit = {},
timeoutMs = 30_000,
): Promise<T> {
const timeout = new AbortController();
const timer = window.setTimeout(() => timeout.abort("timeout"), timeoutMs);
const signal = init.signal
? AbortSignal.any([init.signal, timeout.signal])
: timeout.signal;
const headers = new Headers(init.headers);
headers.set("X-Requested-With", "codex-helper");
if (init.body != null) headers.set("Content-Type", "application/json");
try {
const response = await fetch(`/api/v1/${path}`, {
credentials: "same-origin",
...init,
headers,
signal,
});
const json: unknown = await response.json().catch(() => undefined);
if (!response.ok) {
if (response.status === 401) unauthorizedHandler?.();
const message =
isRecord(json) && typeof json.error === "string"
? json.error
: `HTTP ${response.status}`;
throw new ApiError(message, "http", response.status);
}
try {
return decoder(json);
} catch (error) {
throw new ApiError(toErrorMessage(error), "invalid-response");
}
} catch (error) {
if (error instanceof ApiError) throw error;
if (signal.aborted) {
if (init.signal?.aborted) throw error;
throw new ApiError("请求超时,请重试", "timeout");
}
throw new ApiError("网络请求失败,请检查连接后重试", "network");
} finally {
window.clearTimeout(timer);
}
}
export const get = <T>(
path: string,
decoder: Decoder<T>,
signal?: AbortSignal,
) => api(path, decoder, { signal });
export const post = <T>(
path: string,
decoder: Decoder<T>,
value: unknown = {},
signal?: AbortSignal,
timeoutMs = 30_000,
) =>
api(
path,
decoder,
{ method: "POST", body: JSON.stringify(value), signal },
timeoutMs,
);
export const put = <T>(path: string, decoder: Decoder<T>, value: unknown) =>
api(path, decoder, { method: "PUT", body: JSON.stringify(value) });
export const del = <T>(path: string, decoder: Decoder<T>) =>
api(path, decoder, { method: "DELETE" });
export function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : "发生未知错误";
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function record(value: unknown, name = "响应"): Record<string, unknown> {
if (!isRecord(value)) throw new Error(`${name}格式无效`);
return value;
}
export function string(value: unknown, name: string): string {
if (typeof value !== "string") throw new Error(`${name}格式无效`);
return value;
}
export function number(value: unknown, name: string): number {
if (typeof value !== "number" || !Number.isFinite(value))
throw new Error(`${name}格式无效`);
return value;
}
export function boolean(value: unknown, name: string): boolean {
if (typeof value !== "boolean") throw new Error(`${name}格式无效`);
return value;
}
export function nullableString(value: unknown, name: string): string | null {
if (value === null) return null;
return string(value, name);
}
+112
View File
@@ -0,0 +1,112 @@
import {
createContext,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
import { ApiError, get, onUnauthorized, post, toErrorMessage } from "./api";
import { decodeOK, decodeStatus, type AsyncState, type Status } from "./types";
type AuthContextValue = {
status: Status;
authenticated: boolean;
login: (username: string, password: string) => Promise<void>;
setup: (
username: string,
password: string,
timezone: string,
) => Promise<void>;
logout: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<
AsyncState<{ status: Status; authenticated: boolean }>
>({ status: "loading" });
const load = async () => {
setState({ status: "loading" });
try {
const status = await get("system/status", decodeStatus);
if (!status.initialized)
return setState({
status: "success",
data: { status, authenticated: false },
});
try {
await get("auth/me", () => undefined);
setState({ status: "success", data: { status, authenticated: true } });
} catch (error) {
if (error instanceof ApiError && error.status === 401)
setState({
status: "success",
data: { status, authenticated: false },
});
else throw error;
}
} catch (error) {
setState({ status: "error", message: toErrorMessage(error) });
}
};
useEffect(() => {
void load();
}, []);
useEffect(() => {
onUnauthorized(() =>
setState((current) =>
current.status === "success"
? {
status: "success",
data: { ...current.data, authenticated: false },
}
: current,
),
);
return () => onUnauthorized();
}, []);
if (state.status === "loading")
return (
<main className="center">
<div className="spinner" />
</main>
);
if (state.status === "error")
return (
<main className="center">
<section className="panel form">
<h2>无法启动应用</h2>
<p className="error">{state.message}</p>
<button onClick={() => void load()}>重试</button>
</section>
</main>
);
const { status, authenticated } = state.data;
const value: AuthContextValue = {
status,
authenticated,
login: async (username, password) => {
await post("auth/login", decodeOK, { username, password });
setState({ status: "success", data: { status, authenticated: true } });
},
setup: async (username, password, timezone) => {
await post("setup", decodeOK, { username, password, timezone });
setState({
status: "success",
data: { status: { ...status, initialized: true }, authenticated: true },
});
},
logout: async () => {
await post("auth/logout", decodeOK);
setState({ status: "success", data: { status, authenticated: false } });
},
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const value = useContext(AuthContext);
if (!value) throw new Error("useAuth 必须在 AuthProvider 内使用");
return value;
}
+348 -253
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React, { lazy, Suspense, useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import {
BrowserRouter,
@@ -19,97 +19,40 @@ import {
Trash2,
Zap,
} from "lucide-react";
import { api, del, get, post, put, toErrorMessage } from "./api";
import { AuthProvider, useAuth } from "./auth";
import { ThemeProvider, useTheme } from "./theme";
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { api, del, post, put } from "./api";
decodeAccounts,
decodeCode,
decodeDashboard,
decodeDeviceLogin,
decodeGeneral,
decodeOK,
decodeSMTP,
decodeTelegram,
type Account,
type Dashboard as Dash,
type DeviceLogin,
type GeneralSettings,
type Limit,
type SMTPSettingsForm,
type TelegramSettingsForm,
} from "./types";
import "./styles.css";
type Status = { initialized: boolean; appServer: boolean; version: string };
type Limit = {
limitId: string;
limitName?: string;
windowType: string;
usedPercent: number;
windowDurationMinutes: number;
resetsAt: number;
};
type Point = { date: string; totalTokens: number };
type Account = {
id: number;
displayName: string;
email?: string;
planType?: string;
expectedKind: "any" | "personal" | "team";
actualKind: "unknown" | "personal" | "team";
validationStatus: "pending" | "matched" | "mismatch" | "unknown";
possibleDuplicate: boolean;
connected: boolean;
};
type DeviceLogin = {
accountId: number;
verificationUrl: string;
userCode: string;
};
type Dash = {
accountId: number;
displayName: string;
account: { email?: string; planType?: string; connected: boolean };
limits: Limit[];
summary: {
lifetimeTokens?: number;
peakDailyTokens?: number;
currentStreakDays?: number;
longestRunningTurnSec?: number;
};
usage: Point[];
fetchedAt: number;
stale: boolean;
lastError?: string;
};
const UsageChart = lazy(() => import("./usage-chart"));
function App() {
const [s, setS] = useState<Status | null>(null);
const [auth, setAuth] = useState<boolean | null>(null);
useEffect(() => {
api<Status>("system/status").then((x) => {
setS(x);
if (x.initialized)
api("auth/me")
.then(() => setAuth(true))
.catch(() => setAuth(false));
else setAuth(false);
});
}, []);
if (!s || auth === null) return <Splash />;
if (!s.initialized)
return (
<Setup
version={s.version}
onDone={() => {
setS({ ...s, initialized: true });
setAuth(true);
}}
/>
);
const { status, authenticated } = useAuth();
if (!status.initialized) return <Setup version={status.version} />;
return (
<BrowserRouter>
<Routes>
{auth ? (
<Route
path="*"
element={<Shell version={s.version} logout={() => setAuth(false)} />}
/>
{authenticated ? (
<Route path="*" element={<Shell version={status.version} />} />
) : (
<Route
path="*"
element={<Login version={s.version} done={() => setAuth(true)} />}
/>
<Route path="*" element={<Login version={status.version} />} />
)}
</Routes>
</BrowserRouter>
@@ -131,7 +74,8 @@ function Brand({ version }: { version?: string }) {
</div>
);
}
function Setup({ version, onDone }: { version: string; onDone: () => void }) {
function Setup({ version }: { version: string }) {
const { setup } = useAuth();
const [form, set] = useState({
username: "admin",
password: "",
@@ -141,10 +85,9 @@ function Setup({ version, onDone }: { version: string; onDone: () => void }) {
async function submit(e: React.FormEvent) {
e.preventDefault();
try {
await post("setup", form);
onDone();
await setup(form.username, form.password, form.timezone);
} catch (x) {
setErr((x as Error).message);
setErr(toErrorMessage(x));
}
}
return (
@@ -197,7 +140,8 @@ function Setup({ version, onDone }: { version: string; onDone: () => void }) {
</main>
);
}
function Login({ version, done }: { version: string; done: () => void }) {
function Login({ version }: { version: string }) {
const { login } = useAuth();
const [u, setU] = useState("admin"),
[p, setP] = useState(""),
[e, setE] = useState("");
@@ -208,10 +152,9 @@ function Login({ version, done }: { version: string; done: () => void }) {
onSubmit={async (x) => {
x.preventDefault();
try {
await post("auth/login", { username: u, password: p });
done();
await login(u, p);
} catch (q) {
setE((q as Error).message);
setE(toErrorMessage(q));
}
}}
>
@@ -235,18 +178,11 @@ function Login({ version, done }: { version: string; done: () => void }) {
</main>
);
}
function Shell({ version, logout }: { version: string; logout: () => void }) {
function Shell({ version }: { version: string }) {
const { logout } = useAuth();
const nav = useNavigate();
const [theme, setTheme] = useState(localStorage.theme || "system");
useEffect(() => {
document.documentElement.dataset.theme =
theme === "system"
? matchMedia("(prefers-color-scheme:dark)").matches
? "dark"
: "light"
: theme;
localStorage.theme = theme;
}, [theme]);
const { theme, saveTheme, error: themeError } = useTheme();
const [shellError, setShellError] = useState("");
return (
<div className="app">
<aside>
@@ -262,19 +198,31 @@ function Shell({ version, logout }: { version: string; logout: () => void }) {
</button>
</nav>
<div className="aside-bottom">
<button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
<button
onClick={() =>
void saveTheme(theme === "dark" ? "light" : "dark").catch(
() => undefined,
)
}
>
{theme === "dark" ? <Sun /> : <Moon />}切换主题
</button>
{themeError && <small className="error">{themeError}</small>}
<button
onClick={async () => {
if (!confirm("确定要退出 Codex Helper 管理后台吗?")) return;
await post("auth/logout");
logout();
try {
setShellError("");
await logout();
} catch (error) {
setShellError(toErrorMessage(error));
}
}}
>
<LogOut />
退出
</button>
{shellError && <small className="error">{shellError}</small>}
</div>
</aside>
<section className="content">
@@ -288,50 +236,90 @@ function Shell({ version, logout }: { version: string; logout: () => void }) {
);
}
function Dashboard() {
const [accounts, setAccounts] = useState<Account[]>([]),
const [accounts, setAccounts] = useState<Account[] | null>(null),
[id, setId] = useState(+(localStorage.accountId || 0)),
[d, setD] = useState<Dash | null>(null),
[e, setE] = useState(""),
[refresh, setRefresh] = useState<"idle" | "loading" | "done">("idle");
const loadAccounts = () =>
api<Account[]>("accounts").then((xs) => {
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);
});
const load = async (accountId = id) => {
} catch (error) {
setE(toErrorMessage(error));
}
};
const load = async (accountId = id, signal?: AbortSignal) => {
if (!accountId) return;
const request = ++requestRef.current;
try {
setD(await api<Dash>("dashboard?accountId=" + accountId));
setE("");
const dashboard = await get(
"dashboard?accountId=" + accountId,
decodeDashboard,
signal,
);
if (request === requestRef.current && dashboard.accountId === accountId) {
setD(dashboard);
setE("");
}
} catch (x) {
setE((x as Error).message);
if (!signal?.aborted && request === requestRef.current)
setE(toErrorMessage(x));
}
};
useEffect(() => {
loadAccounts();
void loadAccounts();
}, []);
useEffect(() => {
if (!id) return;
localStorage.accountId = String(id);
localStorage.setItem("accountId", String(id));
setD(null);
load(id);
const t = setInterval(() => load(id), 30000);
return () => clearInterval(t);
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
let timer = 0;
const poll = async () => {
await load(id, controller.signal);
if (!controller.signal.aborted) timer = window.setTimeout(poll, 30_000);
};
void poll();
return () => {
controller.abort();
window.clearTimeout(timer);
};
}, [id]);
const sync = async () => {
setRefresh("loading");
setE("");
try {
await post(`accounts/${id}/sync`);
await post(`accounts/${id}/sync`, decodeOK, {}, undefined, 60_000);
await load(id);
setRefresh("done");
setTimeout(() => setRefresh("idle"), 2000);
window.setTimeout(() => setRefresh("idle"), 2000);
} catch (x) {
setE((x as Error).message);
setE(toErrorMessage(x));
setRefresh("idle");
}
};
if (accounts === null)
return (
<>
<Header title="用量总览" />
{e ? (
<div className="panel empty">
<p className="error">{e}</p>
<button onClick={() => void loadAccounts()}>重试</button>
</div>
) : (
<Splash />
)}
</>
);
if (!accounts.length)
return (
<>
@@ -339,7 +327,20 @@ function Dashboard() {
<div className="panel empty">尚未添加 Codex 账号</div>
</>
);
if (!d) return <Splash />;
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="用量总览">
@@ -376,9 +377,8 @@ function Dashboard() {
<div>
<small>Codex Account</small>
<b>
{d.displayName} · {d.account.email
? maskEmail(d.account.email)
: "尚未连接"}
{d.displayName} ·{" "}
{d.account.email ? maskEmail(d.account.email) : "尚未连接"}
</b>
</div>
<span className="badge">{planLabel(d.account.planType)}</span>
@@ -415,7 +415,9 @@ function Dashboard() {
v={duration(d.summary.longestRunningTurnSec)}
/>
</div>
<Chart data={d.usage} />
<Suspense fallback={<SettingsLoading />}>
<UsageChart data={d.usage} />
</Suspense>
</>
);
}
@@ -468,49 +470,6 @@ const Stat = ({
<b className={muted ? "muted" : ""}>{v}</b>
</div>
);
const Chart = ({ data }: { data: Point[] }) => (
<div className="panel chart">
<div>
<h3>每日 Token 趋势</h3>
</div>
<ResponsiveContainer width="100%" height={280}>
<AreaChart data={data}>
<defs>
<linearGradient id="a" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#5ee7f7" stopOpacity={0.35} />
<stop offset="1" stopColor="#5ee7f7" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid stroke="var(--grid)" vertical={false} />
<XAxis dataKey="date" stroke="var(--muted)" />
<YAxis
width={55}
tickCount={5}
tickFormatter={compactAxis}
stroke="var(--muted)"
/>
<Tooltip
formatter={(v) => [
new Intl.NumberFormat("zh-CN").format(Number(v)),
"Tokens",
]}
contentStyle={{
background: "var(--panel)",
border: "1px solid var(--border)",
borderRadius: 12,
}}
/>
<Area
type="monotone"
dataKey="totalTokens"
stroke="#4dd8ed"
fill="url(#a)"
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
type SettingsTab = "general" | "codex" | "telegram" | "smtp";
const settingsTabs: Array<{
@@ -540,7 +499,8 @@ function SettingsPage() {
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;
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;
@@ -605,25 +565,47 @@ const SettingsLoading = () => (
</div>
);
function General() {
const [v, setV] = useState<any>(null),
[msg, setMsg] = useState("");
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(() => {
api("settings/general").then(setV);
void loadGeneral();
}, []);
if (!v) return <SettingsLoading />;
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 {
await api("settings/general", {
const saved = await api("settings/general", decodeGeneral, {
method: "PUT",
body: JSON.stringify(v),
});
setV(saved);
applyTheme(saved.theme);
setMsg("设置已保存");
} catch (x) {
setMsg((x as Error).message);
setMsg(toErrorMessage(x));
}
}}
>
@@ -640,7 +622,11 @@ function General() {
主题
<select
value={v.theme}
onChange={(e) => setV({ ...v, theme: e.target.value })}
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>
@@ -705,29 +691,45 @@ function CodexSettings() {
[newKind, setNewKind] = useState<"personal" | "team">("team"),
[busy, setBusy] = useState(false),
[err, setErr] = useState("");
const load = () => api<Account[]>("accounts").then((accounts) => { setXs(accounts); return accounts; });
const load = async (signal?: AbortSignal) => {
const accounts = await get("accounts", decodeAccounts, signal);
setXs(accounts);
return accounts;
};
useEffect(() => {
load();
void load().catch((error) => setErr(toErrorMessage(error)));
}, []);
useEffect(() => {
if (!deviceLogin) return;
const started = Date.now();
const timer = setInterval(async () => {
const accounts = await load();
const account = accounts.find((x) => x.id === deviceLogin.accountId);
if (!account) {
setDeviceLogin(null);
clearInterval(timer);
return;
const controller = new AbortController();
let timer = 0;
const poll = async () => {
try {
const accounts = await load(controller.signal);
const account = accounts.find((x) => x.id === deviceLogin.accountId);
if (!account) {
setDeviceLogin(null);
return;
}
if (["matched", "mismatch"].includes(account.validationStatus)) return;
if (Date.now() - started >= 120_000) {
setErr("设备码登录检测已超时,请重新生成设备码");
return;
}
timer = window.setTimeout(poll, 2000);
} catch (error) {
if (!controller.signal.aborted) {
setErr(toErrorMessage(error));
timer = window.setTimeout(poll, 2000);
}
}
if (
["matched", "mismatch"].includes(account.validationStatus)
)
clearInterval(timer);
else if (Date.now() - started >= 120000)
clearInterval(timer);
}, 2000);
return () => clearInterval(timer);
};
timer = window.setTimeout(poll, 2000);
return () => {
controller.abort();
window.clearTimeout(timer);
};
}, [deviceLogin]);
const login = async (id: number) => {
try {
@@ -735,12 +737,16 @@ function CodexSettings() {
setActive(id);
setErr("");
setDeviceLogin(null);
const result = await post<Omit<DeviceLogin, "accountId">>(
const result = await post(
`accounts/${id}/login/device`,
decodeDeviceLogin,
{},
undefined,
60_000,
);
setDeviceLogin({ accountId: id, ...result });
} catch (q) {
setErr((q as Error).message);
setErr(toErrorMessage(q));
} finally {
setBusy(false);
}
@@ -751,18 +757,22 @@ function CodexSettings() {
setActive(0);
setErr("");
setDeviceLogin(null);
const x = await post<Account>("accounts", {
const x = await post("accounts", (value) => decodeAccounts([value])[0], {
displayName: `账号 ${xs.length + 1}`,
expectedKind: newKind,
});
await load();
setActive(x.id);
const result = await post<Omit<DeviceLogin, "accountId">>(
const result = await post(
`accounts/${x.id}/login/device`,
decodeDeviceLogin,
{},
undefined,
60_000,
);
setDeviceLogin({ accountId: x.id, ...result });
} catch (q) {
setErr((q as Error).message);
setErr(toErrorMessage(q));
} finally {
setBusy(false);
}
@@ -771,7 +781,9 @@ function CodexSettings() {
<div className="codex-settings wide">
<div className="codex-heading">
<h2>Codex 账户与工作区</h2>
<p>个人订阅和 Team 工作区请分别添加为独立连接;同一邮箱可以添加多次。</p>
<p>
个人订阅和 Team 工作区请分别添加为独立连接;同一邮箱可以添加多次。
</p>
</div>
<section className="panel add-account-card">
<div>
@@ -813,18 +825,22 @@ function CodexSettings() {
onBlur={async (e) => {
const name = e.target.value.trim();
if (name && name !== x.displayName) {
await put(`accounts/${x.id}`, {
await put(`accounts/${x.id}`, decodeOK, {
displayName: name,
expectedKind: x.expectedKind,
});
load();
void load().catch((error) =>
setErr(toErrorMessage(error)),
);
}
}}
/>
<div className="account-meta">
<span>{x.email ? maskEmail(x.email) : "尚未登录"}</span>
<span>{planLabel(x.planType)}</span>
<span className={x.validationStatus === "mismatch" ? "error" : ""}>
<span
className={x.validationStatus === "mismatch" ? "error" : ""}
>
{validationLabel(x)}
</span>
</div>
@@ -842,11 +858,11 @@ function CodexSettings() {
aria-label="预期连接类型"
value={x.expectedKind}
onChange={async (e) => {
await put(`accounts/${x.id}`, {
await put(`accounts/${x.id}`, decodeOK, {
displayName: x.displayName,
expectedKind: e.target.value,
});
load();
void load().catch((error) => setErr(toErrorMessage(error)));
}}
>
<option value="any">不校验</option>
@@ -855,21 +871,28 @@ function CodexSettings() {
</select>
</label>
<div className="account-buttons">
<button className="secondary" disabled={busy} onClick={() => login(x.id)}>
<button
className="secondary"
disabled={busy}
onClick={() => login(x.id)}
>
{busy && active === x.id ? "服务启动中…" : "设备码登录"}
</button>
<button
className="secondary"
disabled={busy}
onClick={async () => {
if (!confirm(`确定要退出“${x.displayName}”的 Codex 账号吗?`)) return;
if (
!confirm(`确定要退出“${x.displayName}”的 Codex 账号吗?`)
)
return;
try {
setErr("");
await post(`accounts/${x.id}/logout`);
await post(`accounts/${x.id}/logout`, decodeOK);
if (deviceLogin?.accountId === x.id) setDeviceLogin(null);
await load();
} catch (q) {
setErr((q as Error).message);
setErr(toErrorMessage(q));
}
}}
>
@@ -881,16 +904,22 @@ function CodexSettings() {
title="删除连接"
aria-label={`删除“${x.displayName}”`}
onClick={async () => {
if (!confirm(`删除“${x.displayName}”及其全部凭据和历史数据?此操作无法撤销。`)) return;
if (
!confirm(
`删除“${x.displayName}”及其全部凭据和历史数据?此操作无法撤销。`,
)
)
return;
try {
setErr("");
await del(`accounts/${x.id}`);
await del(`accounts/${x.id}`, decodeOK);
if (deviceLogin?.accountId === x.id) setDeviceLogin(null);
if (active === x.id) setActive(0);
if (+localStorage.accountId === x.id) localStorage.removeItem("accountId");
if (+localStorage.accountId === x.id)
localStorage.removeItem("accountId");
await load();
} catch (q) {
setErr((q as Error).message);
setErr(toErrorMessage(q));
}
}}
>
@@ -902,7 +931,11 @@ function CodexSettings() {
<div className="codebox">
<span>
在浏览器中访问{" "}
<a href={deviceLogin.verificationUrl} target="_blank" rel="noreferrer">
<a
href={deviceLogin.verificationUrl}
target="_blank"
rel="noreferrer"
>
{deviceLogin.verificationUrl}
</a>
,然后输入设备码
@@ -918,27 +951,45 @@ function CodexSettings() {
);
}
function Telegram() {
const [v, setV] = useState<any>(null),
const [v, setV] = useState<TelegramSettingsForm | null>(null),
[msg, setMsg] = useState(""),
[code, setCode] = 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(() => {
api("settings/telegram").then(setV);
void loadTelegram();
}, []);
if (!v) return <SettingsLoading />;
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: any = await api("settings/telegram", {
const x = await api("settings/telegram", decodeTelegram, {
method: "PUT",
body: JSON.stringify(v),
});
setV(x);
setV({ ...x, token: "" });
setMsg("Bot 已验证并保存");
} catch (x) {
setMsg((x as Error).message);
setMsg(toErrorMessage(x));
}
}}
>
@@ -978,8 +1029,12 @@ function Telegram() {
type="button"
className="secondary"
onClick={async () => {
const x: any = await post("settings/telegram/bind");
setCode(x.code);
try {
const x = await post("settings/telegram/bind", decodeCode);
setCode(x.code);
} catch (error) {
setMsg(toErrorMessage(error));
}
}}
>
生成绑定码
@@ -989,10 +1044,10 @@ function Telegram() {
className="secondary"
onClick={async () => {
try {
await post("settings/telegram/test");
await post("settings/telegram/test", decodeOK);
setMsg("测试消息已发送");
} catch (x) {
setMsg((x as Error).message);
setMsg(toErrorMessage(x));
}
}}
>
@@ -1010,21 +1065,41 @@ function Telegram() {
);
}
function SMTP() {
const [v, setV] = useState<any>(null),
[msg, setMsg] = useState("");
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(() => {
api("settings/smtp").then(setV);
void loadSMTP();
}, []);
if (!v) return <SettingsLoading />;
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 {
setV(
await api("settings/smtp", { method: "PUT", body: JSON.stringify(v) }),
);
const saved = await api("settings/smtp", decodeSMTP, {
method: "PUT",
body: JSON.stringify(v),
});
setV({ ...saved, password: "" });
setMsg("SMTP 设置已保存");
} catch (x) {
setMsg((x as Error).message);
setMsg(toErrorMessage(x));
}
};
return (
@@ -1067,7 +1142,15 @@ function SMTP() {
加密
<select
value={v.security}
onChange={(e) => setV({ ...v, security: e.target.value })}
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>
@@ -1113,10 +1196,10 @@ function SMTP() {
className="secondary"
onClick={async () => {
try {
await post("settings/smtp/test");
await post("settings/smtp/test", decodeOK);
setMsg("测试邮件已发送");
} catch (x) {
setMsg((x as Error).message);
setMsg(toErrorMessage(x));
}
}}
>
@@ -1146,19 +1229,14 @@ function Header({
</header>
);
}
const num = (v?: number) =>
const num = (v?: number | null) =>
v == null
? "暂无"
: new Intl.NumberFormat("zh-CN", {
notation: "compact",
maximumFractionDigits: 1,
}).format(v);
const compactAxis = (v: number) =>
new Intl.NumberFormat("en", {
notation: "compact",
maximumFractionDigits: 1,
}).format(v);
const duration = (v?: number) =>
const duration = (v?: number | null) =>
v == null
? "暂无"
: v < 60
@@ -1182,22 +1260,33 @@ const maskEmail = (email: string) => {
)
.join(".")}`;
};
const planLabel = (plan?: string) => {
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",
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",
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)},请退出后重新授权`;
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) => {
@@ -1208,8 +1297,14 @@ const relative = (ts: number) => {
m = Math.floor((s % 3600) / 60);
return d ? `${d} 天 ${h} 小时后` : h ? `${h} 小时 ${m} 分后` : `${m} 分钟后`;
};
createRoot(document.getElementById("root")!).render(
const root = document.getElementById("root");
if (!root) throw new Error("缺少 #root 挂载节点");
createRoot(root).render(
<React.StrictMode>
<App />
<AuthProvider>
<ThemeProvider>
<App />
</ThemeProvider>
</AuthProvider>
</React.StrictMode>,
);
+77
View File
@@ -0,0 +1,77 @@
import {
createContext,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
import type { Theme } from "./types";
import { get, put, toErrorMessage } from "./api";
import { decodeGeneral } from "./types";
import { useAuth } from "./auth";
type ThemeContextValue = {
theme: Theme;
applyTheme: (theme: Theme) => void;
saveTheme: (theme: Theme) => Promise<void>;
error: string;
};
const ThemeContext = createContext<ThemeContextValue | null>(null);
const validTheme = (value: string | null): value is Theme =>
value === "system" || value === "dark" || value === "light";
export function ThemeProvider({ children }: { children: ReactNode }) {
const { authenticated } = useAuth();
const stored = localStorage.getItem("theme");
const [theme, setThemeState] = useState<Theme>(
validTheme(stored) ? stored : "system",
);
const [error, setError] = useState("");
useEffect(() => {
const media = matchMedia("(prefers-color-scheme:dark)");
const apply = () => {
document.documentElement.dataset.theme =
theme === "system" ? (media.matches ? "dark" : "light") : theme;
};
apply();
if (theme === "system") media.addEventListener("change", apply);
localStorage.setItem("theme", theme);
return () => media.removeEventListener("change", apply);
}, [theme]);
useEffect(() => {
if (!authenticated) return;
const controller = new AbortController();
void get("settings/general", decodeGeneral, controller.signal)
.then((settings) => setThemeState(settings.theme))
.catch((cause) => {
if (!controller.signal.aborted) setError(toErrorMessage(cause));
});
return () => controller.abort();
}, [authenticated]);
const saveTheme = async (next: Theme) => {
const previous = theme;
setThemeState(next);
setError("");
try {
const general = await get("settings/general", decodeGeneral);
await put("settings/general", decodeGeneral, { ...general, theme: next });
} catch (cause) {
setThemeState(previous);
setError(toErrorMessage(cause));
throw cause;
}
};
return (
<ThemeContext.Provider
value={{ theme, applyTheme: setThemeState, saveTheme, error }}
>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const value = useContext(ThemeContext);
if (!value) throw new Error("useTheme 必须在 ThemeProvider 内使用");
return value;
}
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { ApiError, toErrorMessage } from "./api";
import { decodeDashboard, decodeDeviceLogin, decodeGeneral } from "./types";
describe("API decoders", () => {
it("保留 Dashboard 的 null 与 optional 摘要", () => {
const value = decodeDashboard({
accountId: 2,
displayName: "Team",
account: { email: null, planType: null, connected: false },
limits: [],
summary: { lifetimeTokens: null },
usage: [],
fetchedAt: 0,
stale: true,
});
expect(value.summary.lifetimeTokens).toBeNull();
expect(value.account.email).toBeNull();
});
it("拒绝未知主题", () => {
expect(() =>
decodeGeneral({
timezone: "UTC",
theme: "blue",
syncMinutes: 5,
retentionDays: 90,
beforeMinutes: 30,
notifyBefore: true,
notifyAfter: true,
}),
).toThrow("theme格式无效");
});
it("只接受 HTTPS 设备授权地址", () => {
expect(() =>
decodeDeviceLogin({
verificationUrl: "http://example.com",
userCode: "ABC",
}),
).toThrow("HTTPS");
expect(
decodeDeviceLogin({
verificationUrl: "https://example.com",
userCode: "ABC",
}).userCode,
).toBe("ABC");
});
});
describe("errors", () => {
it("保留结构化 API 错误", () => {
const error = new ApiError("未登录", "http", 401);
expect(error.status).toBe(401);
expect(toErrorMessage(error)).toBe("未登录");
expect(toErrorMessage("bad")).toBe("发生未知错误");
});
});
+263
View File
@@ -0,0 +1,263 @@
import {
boolean,
nullableString,
number,
record,
string,
type Decoder,
} from "./api";
export type Theme = "system" | "dark" | "light";
export type ExpectedKind = "any" | "personal" | "team";
export type ValidationStatus = "pending" | "matched" | "mismatch" | "unknown";
export type AsyncState<T> =
| { status: "loading" }
| { status: "error"; message: string }
| { status: "success"; data: T };
export interface Status {
initialized: boolean;
appServer: boolean;
version: string;
}
export interface Account {
id: number;
displayName: string;
email: string | null;
planType: string | null;
expectedKind: ExpectedKind;
actualKind: "unknown" | "personal" | "team";
validationStatus: ValidationStatus;
possibleDuplicate: boolean;
connected: boolean;
}
export interface DeviceLogin {
accountId: number;
verificationUrl: string;
userCode: string;
}
export interface Limit {
limitId: string;
limitName: string | null;
windowType: string;
usedPercent: number;
windowDurationMinutes: number;
resetsAt: number;
}
export interface Point {
date: string;
totalTokens: number;
}
export interface Dashboard {
accountId: number;
displayName: string;
account: {
email: string | null;
planType: string | null;
connected: boolean;
};
limits: Limit[];
summary: {
lifetimeTokens?: number | null;
peakDailyTokens?: number | null;
currentStreakDays?: number | null;
longestRunningTurnSec?: number | null;
};
usage: Point[];
fetchedAt: number;
stale: boolean;
lastError?: string;
}
export interface GeneralSettings {
timezone: string;
theme: Theme;
syncMinutes: number;
retentionDays: number;
beforeMinutes: number;
notifyBefore: boolean;
notifyAfter: boolean;
}
export interface TelegramSettingsResponse {
chatId: number;
enabled: boolean;
menuEnabled: boolean;
configured: boolean;
botName?: string;
}
export type TelegramSettingsForm = TelegramSettingsResponse & { token: string };
export interface SMTPSettingsResponse {
host: string;
port: number;
username: string;
from: string;
fromName: string;
to: string;
security: "starttls" | "tls" | "none";
enabled: boolean;
configured: boolean;
}
export type SMTPSettingsForm = SMTPSettingsResponse & { password: string };
const enumValue = <T extends string>(
value: unknown,
values: readonly T[],
name: string,
): T => {
const result = string(value, name);
if (!values.includes(result as T)) throw new Error(`${name}格式无效`);
return result as T;
};
const optionalNumber = (value: unknown, name: string) =>
value == null ? value : number(value, name);
export const decodeStatus: Decoder<Status> = (value) => {
const x = record(value);
return {
initialized: boolean(x.initialized, "initialized"),
appServer: boolean(x.appServer, "appServer"),
version: string(x.version, "version"),
};
};
export const decodeAccount: Decoder<Account> = (value) => {
const x = record(value, "账号");
return {
id: number(x.id, "id"),
displayName: string(x.displayName, "displayName"),
email: nullableString(x.email, "email"),
planType: nullableString(x.planType, "planType"),
expectedKind: enumValue(
x.expectedKind,
["any", "personal", "team"],
"expectedKind",
),
actualKind: enumValue(
x.actualKind,
["unknown", "personal", "team"],
"actualKind",
),
validationStatus: enumValue(
x.validationStatus,
["pending", "matched", "mismatch", "unknown"],
"validationStatus",
),
possibleDuplicate: boolean(x.possibleDuplicate, "possibleDuplicate"),
connected: boolean(x.connected, "connected"),
};
};
export const decodeAccounts: Decoder<Account[]> = (value) => {
if (!Array.isArray(value)) throw new Error("账号列表格式无效");
return value.map(decodeAccount);
};
export const decodeDashboard: Decoder<Dashboard> = (value) => {
const x = record(value, "Dashboard"),
account = record(x.account, "account"),
summary = record(x.summary, "summary");
if (!Array.isArray(x.limits) || !Array.isArray(x.usage))
throw new Error("Dashboard列表格式无效");
return {
accountId: number(x.accountId, "accountId"),
displayName: string(x.displayName, "displayName"),
account: {
email: nullableString(account.email, "email"),
planType: nullableString(account.planType, "planType"),
connected: boolean(account.connected, "connected"),
},
limits: x.limits.map((v) => {
const l = record(v, "limit");
return {
limitId: string(l.limitId, "limitId"),
limitName: nullableString(l.limitName, "limitName"),
windowType: string(l.windowType, "windowType"),
usedPercent: number(l.usedPercent, "usedPercent"),
windowDurationMinutes: number(
l.windowDurationMinutes,
"windowDurationMinutes",
),
resetsAt: number(l.resetsAt, "resetsAt"),
};
}),
summary: {
lifetimeTokens: optionalNumber(summary.lifetimeTokens, "lifetimeTokens"),
peakDailyTokens: optionalNumber(
summary.peakDailyTokens,
"peakDailyTokens",
),
currentStreakDays: optionalNumber(
summary.currentStreakDays,
"currentStreakDays",
),
longestRunningTurnSec: optionalNumber(
summary.longestRunningTurnSec,
"longestRunningTurnSec",
),
},
usage: x.usage.map((v) => {
const p = record(v, "usage");
return {
date: string(p.date, "date"),
totalTokens: number(p.totalTokens, "totalTokens"),
};
}),
fetchedAt: number(x.fetchedAt, "fetchedAt"),
stale: boolean(x.stale, "stale"),
...(typeof x.lastError === "string" ? { lastError: x.lastError } : {}),
};
};
export const decodeGeneral: Decoder<GeneralSettings> = (value) => {
const x = record(value);
return {
timezone: string(x.timezone, "timezone"),
theme: enumValue(x.theme, ["system", "dark", "light"], "theme"),
syncMinutes: number(x.syncMinutes, "syncMinutes"),
retentionDays: number(x.retentionDays, "retentionDays"),
beforeMinutes: number(x.beforeMinutes, "beforeMinutes"),
notifyBefore: boolean(x.notifyBefore, "notifyBefore"),
notifyAfter: boolean(x.notifyAfter, "notifyAfter"),
};
};
export const decodeTelegram: Decoder<TelegramSettingsResponse> = (value) => {
const x = record(value);
return {
chatId: number(x.chatId ?? 0, "chatId"),
enabled: boolean(x.enabled, "enabled"),
menuEnabled: boolean(x.menuEnabled, "menuEnabled"),
configured: boolean(x.configured, "configured"),
...(typeof x.botName === "string" ? { botName: x.botName } : {}),
};
};
export const decodeSMTP: Decoder<SMTPSettingsResponse> = (value) => {
const x = record(value);
return {
host: string(x.host, "host"),
port: number(x.port, "port"),
username: string(x.username, "username"),
from: string(x.from, "from"),
fromName: string(x.fromName, "fromName"),
to: string(x.to, "to"),
security: enumValue(x.security, ["starttls", "tls", "none"], "security"),
enabled: boolean(x.enabled, "enabled"),
configured: boolean(x.configured, "configured"),
};
};
export const decodeDeviceLogin: Decoder<Omit<DeviceLogin, "accountId">> = (
value,
) => {
const x = record(value),
verificationUrl = string(x.verificationUrl, "verificationUrl");
let url: URL;
try {
url = new URL(verificationUrl);
} catch {
throw new Error("设备授权地址无效");
}
if (url.protocol !== "https:") throw new Error("设备授权地址必须使用 HTTPS");
return { verificationUrl, userCode: string(x.userCode, "userCode") };
};
export const decodeOK: Decoder<{ ok: boolean }> = (value) => {
const x = record(value);
return { ok: boolean(x.ok, "ok") };
};
export const decodeCode: Decoder<{ code: string }> = (value) => {
const x = record(value);
return { code: string(x.code, "code") };
};
+62
View File
@@ -0,0 +1,62 @@
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { Point } from "./types";
const compactAxis = (value: number) =>
new Intl.NumberFormat("en", {
notation: "compact",
maximumFractionDigits: 1,
}).format(value);
export default function UsageChart({ data }: { data: Point[] }) {
return (
<div className="panel chart">
<div>
<h3>每日 Token 趋势</h3>
</div>
<ResponsiveContainer width="100%" height={280}>
<AreaChart data={data}>
<defs>
<linearGradient id="a" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#5ee7f7" stopOpacity={0.35} />
<stop offset="1" stopColor="#5ee7f7" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid stroke="var(--grid)" vertical={false} />
<XAxis dataKey="date" stroke="var(--muted)" />
<YAxis
width={55}
tickCount={5}
tickFormatter={compactAxis}
stroke="var(--muted)"
/>
<Tooltip
formatter={(value) => [
new Intl.NumberFormat("zh-CN").format(Number(value)),
"Tokens",
]}
contentStyle={{
background: "var(--panel)",
border: "1px solid var(--border)",
borderRadius: 12,
}}
/>
<Area
type="monotone"
dataKey="totalTokens"
stroke="#4dd8ed"
fill="url(#a)"
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}