feat: migrate account usage to CLIProxyAPI
Frontend / quality (push) Canceled after 0s

This commit is contained in:
2026-09-08 09:56:08 +08:00
parent 0b2f969c6d
commit 8997886eef
35 changed files with 2778 additions and 1651 deletions
+7 -2
View File
@@ -85,8 +85,13 @@ export const post = <T>(
{ 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 put = <T>(
path: string,
decoder: Decoder<T>,
value: unknown,
timeoutMs = 30_000,
) =>
api(path, decoder, { method: "PUT", body: JSON.stringify(value) }, timeoutMs);
export const del = <T>(path: string, decoder: Decoder<T>) =>
api(path, decoder, { method: "DELETE" });
+147 -160
View File
@@ -39,14 +39,12 @@ import {
decodeAction,
decodeCode,
decodeDashboard,
decodeDeviceLogin,
decodeGeneral,
decodeOK,
decodeSMTP,
decodeTelegram,
type Account,
type Dashboard as Dash,
type DeviceLogin,
type GeneralSettings,
type Limit,
type SMTPSettingsForm,
@@ -1083,8 +1081,8 @@ function General() {
}
function CodexSettings() {
const [xs, setXs] = useState<Account[]>([]),
[deviceLogin, setDeviceLogin] = useState<DeviceLogin | null>(null),
[active, setActive] = useState(0),
[newAuthIndex, setNewAuthIndex] = useState(""),
[newKind, setNewKind] = useState<"personal" | "team">("team"),
[newPublicVisible, setNewPublicVisible] = useState(false),
[busy, setBusy] = useState(false),
@@ -1097,81 +1095,62 @@ function CodexSettings() {
useEffect(() => {
void load().catch((error) => setErr(toErrorMessage(error)));
}, []);
useEffect(() => {
if (!deviceLogin) return;
const started = Date.now();
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);
}
}
};
timer = window.setTimeout(poll, 2000);
return () => {
controller.abort();
window.clearTimeout(timer);
};
}, [deviceLogin]);
const login = async (id: number) => {
const updateAccount = async (
account: Account,
changes: Partial<
Pick<
Account,
"displayName" | "authIndex" | "expectedKind" | "publicVisible"
>
>,
) => {
try {
setBusy(true);
setActive(id);
setErr("");
setDeviceLogin(null);
const result = await post(
`accounts/${id}/login/device`,
decodeDeviceLogin,
{},
undefined,
60_000,
await put(
`accounts/${account.id}`,
decodeOK,
{
displayName: changes.displayName ?? account.displayName,
authIndex: changes.authIndex ?? account.authIndex,
expectedKind: changes.expectedKind ?? account.expectedKind,
publicVisible: changes.publicVisible ?? account.publicVisible,
},
changes.authIndex === undefined ? 30_000 : 60_000,
);
setDeviceLogin({ accountId: id, ...result });
} catch (q) {
setErr(toErrorMessage(q));
} finally {
setBusy(false);
await load();
return true;
} catch (error) {
setErr(toErrorMessage(error));
return false;
}
};
const add = async () => {
const authIndex = newAuthIndex.trim();
if (!authIndex) {
setErr("请输入 CPA authIndex");
return;
}
try {
setBusy(true);
setActive(0);
setErr("");
setDeviceLogin(null);
const x = await post("accounts", (value) => decodeAccounts([value])[0], {
displayName: `账号 ${xs.length + 1}`,
expectedKind: newKind,
publicVisible: newPublicVisible,
});
await load();
setActive(x.id);
const result = await post(
`accounts/${x.id}/login/device`,
decodeDeviceLogin,
{},
const account = await post(
"accounts",
(value) => decodeAccounts([value])[0],
{
displayName: "",
authIndex,
expectedKind: newKind,
publicVisible: newPublicVisible,
},
undefined,
60_000,
);
setDeviceLogin({ accountId: x.id, ...result });
} catch (q) {
setErr(toErrorMessage(q));
setNewAuthIndex("");
await load();
setActive(account.id);
} catch (error) {
setErr(toErrorMessage(error));
} finally {
setBusy(false);
}
@@ -1181,21 +1160,32 @@ function CodexSettings() {
<div className="codex-heading">
<h2>Codex 账户与工作区</h2>
<p>
个人订阅和 Team 工作区请分别添加为独立连接;同一邮箱可以添加多次。
账号凭据和 OAuth 刷新由 CLIProxyAPI 管理;这里通过 CPA authIndex
绑定需要展示的 Codex 账号。
</p>
</div>
<section className="panel add-account-card">
<div>
<h3>添加新连接</h3>
<p>选择要连接的订阅类型,然后使用设备码完成授权。</p>
<h3>添加 CPA 账号</h3>
<p>authIndex 可从 CLIProxyAPI 的认证文件列表中取得。</p>
</div>
<div className="add-account-controls">
<label>
连接类型
CPA authIndex
<input
aria-label="CPA authIndex"
autoComplete="off"
placeholder="例如 6c4894e0cb312814"
value={newAuthIndex}
onChange={(event) => setNewAuthIndex(event.target.value)}
/>
</label>
<label>
预期连接类型
<select
value={newKind}
onChange={(e) =>
setNewKind(e.target.value as "personal" | "team")
onChange={(event) =>
setNewKind(event.target.value as "personal" | "team")
}
>
<option value="team">Team / Business 工作区</option>
@@ -1206,73 +1196,97 @@ function CodexSettings() {
<input
type="checkbox"
checked={newPublicVisible}
onChange={(e) => setNewPublicVisible(e.target.checked)}
onChange={(event) => setNewPublicVisible(event.target.checked)}
/>
公开显示到未登录总览
</label>
<button disabled={busy} onClick={add}>
{busy && active === 0 ? "服务启动中…" : "添加账号"}
{busy && active === 0 ? "正在验证并添加…" : "添加账号"}
</button>
</div>
</section>
<div className="account-list">
{xs.length === 0 && (
<div className="panel empty account-empty">
尚未添加 Codex 账号,请使用上方的“添加账号”创建连接。
尚未绑定 CPA Codex 账号,请在上方输入 authIndex。
</div>
)}
{xs.map((x) => (
<section className="panel account-card" key={x.id}>
{xs.map((account) => (
<section className="panel account-card" key={account.id}>
<div className="account-card-main">
<span className={"dot " + (x.connected ? "ok" : "")} />
<span className={"dot " + (account.connected ? "ok" : "")} />
<div className="account-details">
<input
aria-label="连接名称"
defaultValue={x.displayName}
onBlur={async (e) => {
const name = e.target.value.trim();
if (name && name !== x.displayName) {
await put(`accounts/${x.id}`, decodeOK, {
displayName: name,
expectedKind: x.expectedKind,
publicVisible: x.publicVisible,
});
void load().catch((error) =>
setErr(toErrorMessage(error)),
);
}
defaultValue={account.displayName}
onBlur={async (event) => {
const input = event.currentTarget;
const displayName = input.value.trim();
if (
displayName &&
displayName !== account.displayName &&
!(await updateAccount(account, { displayName }))
)
input.value = account.displayName;
}}
/>
<div className="account-meta">
<span>{x.email ? maskEmail(x.email) : "尚未登录"}</span>
<span>{planLabel(x.planType)}</span>
<span>
{account.email
? maskEmail(account.email)
: "账号信息暂不可用"}
</span>
<span>{planLabel(account.planType)}</span>
<span
className={x.validationStatus === "mismatch" ? "error" : ""}
className={
account.validationStatus === "mismatch" ? "error" : ""
}
>
{validationLabel(x)}
{validationLabel(account)}
</span>
</div>
{x.possibleDuplicate && (
{account.possibleDuplicate && (
<small className="hint">
同一邮箱已有相同类型连接,请确认没有重复授权同一工作区
同一邮箱已有相同类型绑定,请确认 authIndex 没有指向重复凭据
</small>
)}
</div>
</div>
<div className="account-card-actions">
<label>
CPA authIndex
<input
aria-label={`“${account.displayName}”的 CPA authIndex`}
autoComplete="off"
defaultValue={account.authIndex}
onBlur={async (event) => {
const input = event.currentTarget;
const authIndex = input.value.trim();
if (!authIndex || authIndex === account.authIndex) return;
if (
!confirm(
`更换“${account.displayName}”的 authIndex 会清除该本地账号已有的 Token 历史、限额快照和提醒记录。继续吗?`,
)
) {
input.value = account.authIndex;
return;
}
if (!(await updateAccount(account, { authIndex })))
input.value = account.authIndex;
}}
/>
</label>
<label>
预期连接类型
<select
aria-label="预期连接类型"
value={x.expectedKind}
onChange={async (e) => {
await put(`accounts/${x.id}`, decodeOK, {
displayName: x.displayName,
expectedKind: e.target.value,
publicVisible: x.publicVisible,
});
void load().catch((error) => setErr(toErrorMessage(error)));
}}
value={account.expectedKind}
onChange={(event) =>
void updateAccount(account, {
expectedKind: event.target
.value as Account["expectedKind"],
})
}
>
<option value="any">不校验</option>
<option value="personal">个人订阅</option>
@@ -1282,73 +1296,62 @@ function CodexSettings() {
<label className="checks">
<input
type="checkbox"
checked={x.publicVisible}
onChange={async (e) => {
try {
setErr("");
await put(`accounts/${x.id}`, decodeOK, {
displayName: x.displayName,
expectedKind: x.expectedKind,
publicVisible: e.target.checked,
});
await load();
} catch (q) {
setErr(toErrorMessage(q));
}
}}
checked={account.publicVisible}
onChange={(event) =>
void updateAccount(account, {
publicVisible: event.target.checked,
})
}
/>
公开显示到未登录总览
</label>
<div className="account-buttons">
<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;
try {
setBusy(true);
setActive(account.id);
setErr("");
await post(`accounts/${x.id}/logout`, decodeOK);
if (deviceLogin?.accountId === x.id) setDeviceLogin(null);
await post(
`accounts/${account.id}/sync`,
decodeOK,
{},
undefined,
60_000,
);
await load();
} catch (q) {
setErr(toErrorMessage(q));
} catch (error) {
setErr(toErrorMessage(error));
} finally {
setBusy(false);
}
}}
>
退出
{busy && active === account.id ? "同步中…" : "立即同步"}
</button>
<button
className="icon danger"
disabled={busy}
title="删除连接"
aria-label={`删除“${x.displayName}”`}
title="删除本地绑定"
aria-label={`删除“${account.displayName}”`}
onClick={async () => {
if (
!confirm(
`删除“${x.displayName}”及其全部凭据和历史数据?此操作无法撤销。`,
`删除“${account.displayName}”的本地绑定及全部历史数据?此操作不会删除 CLIProxyAPI 中的凭据,且无法撤销。`,
)
)
return;
try {
setErr("");
await del(`accounts/${x.id}`, decodeOK);
if (deviceLogin?.accountId === x.id) setDeviceLogin(null);
if (active === x.id) setActive(0);
if (+localStorage.accountId === x.id)
await del(`accounts/${account.id}`, decodeOK);
if (active === account.id) setActive(0);
if (+localStorage.accountId === account.id)
localStorage.removeItem("accountId");
await load();
} catch (q) {
setErr(toErrorMessage(q));
} catch (error) {
setErr(toErrorMessage(error));
}
}}
>
@@ -1356,22 +1359,6 @@ function CodexSettings() {
</button>
</div>
</div>
{deviceLogin?.accountId === x.id && (
<div className="codebox">
<span>
在浏览器中访问{" "}
<a
href={deviceLogin.verificationUrl}
target="_blank"
rel="noreferrer"
>
{deviceLogin.verificationUrl}
</a>
,然后输入设备码
</span>
<strong>{deviceLogin.userCode}</strong>
</div>
)}
</section>
))}
</div>
+14 -1
View File
@@ -1339,12 +1339,20 @@ html[data-theme="dark"] .codebox {
.add-account-controls {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: end;
gap: 10px;
}
.add-account-controls label {
min-width: 250px;
flex: 1 1 220px;
min-width: 220px;
}
.add-account-controls .checks {
flex: 0 1 auto;
min-width: 0;
}
.add-account-controls button {
@@ -1419,6 +1427,11 @@ html[data-theme="dark"] .codebox {
min-width: 285px;
}
.account-card-actions > label:not(.checks) input,
.account-card-actions > label:not(.checks) select {
width: 100%;
}
.account-buttons {
display: flex;
gap: 8px;
+16 -14
View File
@@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest";
import { ApiError, toErrorMessage } from "./api";
import {
decodeAction,
decodeAccount,
decodeDashboard,
decodeDeviceLogin,
decodeGeneral,
decodeTelegram,
} from "./types";
@@ -77,19 +77,21 @@ describe("API decoders", () => {
).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");
it("解码账号的 CPA authIndex", () => {
const account = decodeAccount({
id: 1,
displayName: "主账号",
authIndex: "index-1",
email: null,
planType: "plus",
expectedKind: "personal",
publicVisible: false,
actualKind: "personal",
validationStatus: "matched",
possibleDuplicate: false,
connected: true,
});
expect(account.authIndex).toBe("index-1");
});
it("保留 Telegram 操作警告", () => {
+4 -21
View File
@@ -17,12 +17,13 @@ export type AsyncState<T> =
export interface Status {
initialized: boolean;
appServer: boolean;
cpa: boolean;
version: string;
}
export interface Account {
id: number;
displayName: string;
authIndex: string;
email: string | null;
planType: string | null;
expectedKind: ExpectedKind;
@@ -32,11 +33,6 @@ export interface Account {
possibleDuplicate: boolean;
connected: boolean;
}
export interface DeviceLogin {
accountId: number;
verificationUrl: string;
userCode: string;
}
export interface Limit {
limitId: string;
limitName: string | null;
@@ -134,7 +130,7 @@ export const decodeStatus: Decoder<Status> = (value) => {
const x = record(value);
return {
initialized: boolean(x.initialized, "initialized"),
appServer: boolean(x.appServer, "appServer"),
cpa: boolean(x.cpa, "cpa"),
version: string(x.version, "version"),
};
};
@@ -143,6 +139,7 @@ export const decodeAccount: Decoder<Account> = (value) => {
return {
id: number(x.id, "id"),
displayName: string(x.displayName, "displayName"),
authIndex: string(x.authIndex ?? "", "authIndex"),
email: nullableString(x.email, "email"),
planType: nullableString(x.planType, "planType"),
expectedKind: enumValue(
@@ -296,20 +293,6 @@ export const decodeSMTP: Decoder<SMTPSettingsResponse> = (value) => {
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") };