fix: harden frontend state and tooling
This commit is contained in:
+125
-4
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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("发生未知错误");
|
||||
});
|
||||
});
|
||||
@@ -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") };
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user