@@ -505,10 +512,11 @@ function EndpointDoc({ id, method, path, title, description, curlExample, respon
}
export default function ApiReference() {
- const { t } = useTranslation()
+ const { t, i18n } = useTranslation()
const baseUrl = useMemo(() => window.location.origin, [])
const [firstKey, setFirstKey] = useState('')
const [allKeys, setAllKeys] = useState<{ name: string; key: string }[]>([])
+ const copy = (zh: string, en: string) => i18n.language.toLowerCase().startsWith('en') ? en : zh
// 加载 API Key 列表
useEffect(() => {
@@ -533,6 +541,21 @@ export default function ApiReference() {
{ id: 'import-accounts', label: t('apiRef.importAccounts.title'), method: 'POST' },
{ id: 'delete-account', label: '/accounts/:id', method: 'DELETE' },
{ id: 'list-accounts', label: '/accounts', method: 'GET' },
+ { id: 'claude-management', label: t('claude.providerTitle'), method: '' },
+ { id: 'claude-list', label: '/accounts?channel=claude', method: 'GET' },
+ { id: 'claude-auth-url', label: '/claude/oauth/auth-url', method: 'POST' },
+ { id: 'claude-exchange-code', label: '/claude/oauth/exchange-code', method: 'POST' },
+ { id: 'claude-import', label: '/claude/import', method: 'POST' },
+ { id: 'claude-refresh-token', label: '/accounts/:id/refresh', method: 'POST' },
+ { id: 'claude-refresh-models', label: '/accounts/:id/claude/models', method: 'POST' },
+ { id: 'claude-refresh-all-models', label: '/claude/models/refresh', method: 'POST' },
+ { id: 'claude-sync-models', label: '/accounts/:id/models/sync-upstream', method: 'POST' },
+ { id: 'claude-update-models', label: '/accounts/:id/models', method: 'PATCH' },
+ { id: 'claude-refresh-usage', label: '/accounts/:id/usage/refresh', method: 'POST' },
+ { id: 'claude-probe-models', label: '/accounts/:id/models/probe', method: 'POST' },
+ { id: 'claude-test-connection', label: '/accounts/:id/test', method: 'GET' },
+ { id: 'claude-get-config', label: '/settings/claude-config', method: 'GET' },
+ { id: 'claude-update-config', label: '/settings/claude-config', method: 'PUT' },
]
const [activeNav, setActiveNav] = useState(navItems[0].id)
@@ -877,7 +900,7 @@ export default function ApiReference() {
baseUrl={baseUrl}
allKeys={allKeys}
defaultBody={`{
- "model": "claude-sonnet-4-5-20250514",
+ "model": "claude-sonnet-4-5",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
}`}
@@ -887,7 +910,7 @@ export default function ApiReference() {
--header 'Content-Type: application/json' \\
--header 'anthropic-version: 2023-06-01' \\
--data '{
- "model": "claude-sonnet-4-5-20250514",
+ "model": "claude-sonnet-4-5",
"max_tokens": 1024,
"messages": [
{"role": "user", "content": "Hello, Claude!"}
@@ -898,7 +921,7 @@ export default function ApiReference() {
"id": "msg_abc123",
"type": "message",
"role": "assistant",
- "model": "claude-sonnet-4-5-20250514",
+ "model": "claude-sonnet-4-5",
"content": [
{
"type": "text",
@@ -1210,6 +1233,480 @@ curl --request POST \\
}` },
]}
/>
+
+ {/* Claude / Anthropic 管理 API */}
+
+
+
{t('claude.providerTitle')}
+ OAuth · Messages API
+
+
+ {copy(
+ '以下接口用于导入、维护和验证 Claude OAuth 账号。所有接口均需要 X-Admin-Key;示例中的 Token、code、state 与账号 ID 都是占位符。',
+ 'Use these endpoints to import, maintain, and verify Claude OAuth accounts. Every endpoint requires X-Admin-Key; all tokens, codes, states, and account IDs below are placeholders.',
+ )}
+
+
+
+
'`}
+ responseExamples={[
+ { code: 200, body: `{
+ "accounts": [
+ {
+ "id": 42,
+ "name": "claude-team",
+ "email": "user@example.com",
+ "claude_api": true,
+ "plan_type": "team",
+ "status": "active",
+ "models": ["claude-haiku-4-5", "claude-sonnet-4-5"],
+ "claude_usage_probe_at": "2026-08-30T01:23:45Z",
+ "usage_percent_5h": 12.5,
+ "usage_percent_7d": 8.2
+ }
+ ]
+}` },
+ { code: 401, body: `{"error":"Unauthorized"}` },
+ ]}
+ />
+
+ ' \\
+ --header 'Content-Type: application/json' \\
+ --data '{}'`}
+ responseExamples={[
+ { code: 200, body: `{
+ "auth_url": "https://claude.ai/oauth/authorize?...&state=",
+ "state": ""
+}` },
+ { code: 401, body: `{"error":"Unauthorized"}` },
+ ]}
+ />
+
+ ",
+ "code": "",
+ "name": "claude-team",
+ "proxy_url": "",
+ "use_proxy_pool": true,
+ "timezone": "Asia/Shanghai"
+}`}
+ curlExample={`curl --request POST \\
+ --url ${baseUrl}/api/admin/accounts/claude/oauth/exchange-code \\
+ --header 'X-Admin-Key: ' \\
+ --header 'Content-Type: application/json' \\
+ --data '{
+ "state": "",
+ "code": "",
+ "name": "claude-team",
+ "use_proxy_pool": true,
+ "timezone": "Asia/Shanghai"
+}'`}
+ responseExamples={[
+ { code: 200, body: `{
+ "message": "成功添加 Claude 账号",
+ "id": 42,
+ "email": "user@example.com"
+}` },
+ { code: 400, body: `{"error":"登录会话已过期或不存在,请重新获取授权 URL"}` },
+ { code: 409, body: `{"error":"Claude 账号已存在 (id=42)"}` },
+ ]}
+ />
+
+ ",
+ "refresh_token": "",
+ "email": "user@example.com",
+ "account_id": "",
+ "expires_at": "2026-08-30T02:00:00Z",
+ "name": "claude-imported",
+ "proxy_url": "",
+ "use_proxy_pool": true,
+ "timezone": "Asia/Shanghai"
+}`}
+ curlExample={`curl --request POST \\
+ --url ${baseUrl}/api/admin/accounts/claude/import \\
+ --header 'X-Admin-Key: ' \\
+ --header 'Content-Type: application/json' \\
+ --data '{
+ "access_token": "",
+ "refresh_token": "",
+ "account_id": "",
+ "name": "claude-imported",
+ "use_proxy_pool": true,
+ "timezone": "Asia/Shanghai"
+}'`}
+ responseExamples={[
+ { code: 200, body: `{
+ "message": "成功添加 Claude 账号",
+ "id": 43,
+ "email": "user@example.com"
+}` },
+ { code: 400, body: `{"error":"access_token 与 refresh_token 均为必填"}` },
+ { code: 409, body: `{"error":"Claude 账号已存在 (id=42)"}` },
+ ]}
+ />
+
+ '`}
+ responseExamples={[
+ { code: 200, body: `{"message":"账号刷新成功"}` },
+ { code: 404, body: `{"error":"账号不存在"}` },
+ { code: 500, body: `{"error":"刷新失败: upstream error"}` },
+ ]}
+ />
+
+ '`}
+ responseExamples={[
+ { code: 200, body: `{
+ "message": "已更新可用模型",
+ "models": ["claude-haiku-4-5", "claude-sonnet-4-5"],
+ "count": 2
+}` },
+ { code: 400, body: `{"error":"账号缺少 access_token,请先刷新或重新导入"}` },
+ { code: 502, body: `{"error":"拉取可用模型失败: upstream error"}` },
+ ]}
+ />
+
+ ' \\
+ --header 'Content-Type: application/json' \\
+ --data '{}'`}
+ responseExamples={[
+ { code: 200, body: `{
+ "message": "已刷新 Claude 账号可用模型",
+ "refreshed": 3,
+ "failed": 1,
+ "model_count": 5
+}` },
+ { code: 500, body: `{"error":"failed to list Claude accounts"}` },
+ ]}
+ />
+
+ '`}
+ responseExamples={[
+ { code: 200, body: `{
+ "models": ["claude-haiku-4-5", "claude-sonnet-4-5"]
+}` },
+ { code: 502, body: `{"error":"拉取 Claude 上游模型清单失败: upstream error"}` },
+ ]}
+ />
+
+ ' \\
+ --header 'Content-Type: application/json' \\
+ --data '{"models":["claude-haiku-4-5","claude-sonnet-4-5"]}'`}
+ responseExamples={[
+ { code: 200, body: `{"models":["claude-haiku-4-5","claude-sonnet-4-5"]}` },
+ { code: 400, body: `{"error":"Claude 账号模型必须使用 claude-* 原生模型: gpt-5.5"}` },
+ { code: 404, body: `{"error":"账号不在运行时池中"}` },
+ ]}
+ />
+
+ '`}
+ responseExamples={[
+ { code: 200, body: `{
+ "refreshed": true,
+ "usage_percent_5h": 12.5,
+ "usage_percent_7d": 8.2,
+ "reset_5h_at": "2026-08-30T05:00:00Z",
+ "reset_7d_at": "2026-09-05T00:00:00Z",
+ "claude_usage_probe_at": "2026-08-30T01:23:45Z"
+}` },
+ { code: 502, body: `{"error":"刷新用量失败: upstream timeout"}` },
+ ]}
+ />
+
+ '`}
+ responseExamples={[
+ { code: 200, body: `{
+ "account_id": 42,
+ "days": 30,
+ "total_requests": 128,
+ "success_requests": 125,
+ "error_requests": 3,
+ "input_tokens": 12000,
+ "output_tokens": 4500
+}` },
+ { code: 400, body: `{"error":"days 参数无效,需要 0-3650 的整数"}` },
+ ]}
+ />
+
+ '
+
+# Optional SSE progress
+curl --request POST \\
+ --url '${baseUrl}/api/admin/accounts/42/models/probe?stream=true' \\
+ --header 'X-Admin-Key: '`}
+ responseExamples={[
+ { code: 200, body: `{
+ "available": ["claude-haiku-4-5"],
+ "results": [
+ {"model":"claude-haiku-4-5","outcome":"available","detail":"模型响应正常"},
+ {"model":"claude-opus-4-5","outcome":"throttled","detail":"上游返回 429 限流"}
+ ]
+}` },
+ { code: 200, body: `data: {"type":"start","total":2,"models":["claude-haiku-4-5","claude-opus-4-5"]}
+
+data: {"type":"result","model":"claude-haiku-4-5","outcome":"available"}
+
+data: {"type":"done","available":["claude-haiku-4-5"]}` },
+ ]}
+ />
+
+ '`}
+ responseExamples={[
+ { code: 200, body: `data: {"type":"test_start","model":"claude-haiku-4-5"}
+
+data: {"type":"content","text":"OK"}
+
+data: {"type":"test_complete","success":true}` },
+ { code: 200, body: `data: {"type":"test_start","model":"claude-haiku-4-5"}
+
+data: {"type":"error","error":"Claude 上游返回了有效响应,但账号仍处于配额/限流状态"}` },
+ ]}
+ />
+
+ '`}
+ responseExamples={[
+ { code: 200, body: `{
+ "fingerprint_mode": "preserve",
+ "default_timezone": "Asia/Shanghai",
+ "session_window_limit": 0
+}` },
+ ]}
+ />
+
+ ' \\
+ --header 'Content-Type: application/json' \\
+ --data '{
+ "fingerprint_mode": "preserve",
+ "default_timezone": "Asia/Shanghai",
+ "session_window_limit": 0
+}'`}
+ responseExamples={[
+ { code: 200, body: `{
+ "message": "已保存 ClaudeCode 全局配置",
+ "fingerprint_mode": "preserve",
+ "default_timezone": "Asia/Shanghai",
+ "session_window_limit": 0
+}` },
+ { code: 400, body: `{"error":"fingerprint_mode must be one of: preserve, force"}` },
+ ]}
+ />
>
)
}
diff --git a/frontend/src/pages/ClaudeAccounts.tsx b/frontend/src/pages/ClaudeAccounts.tsx
new file mode 100644
index 000000000..8e335ea34
--- /dev/null
+++ b/frontend/src/pages/ClaudeAccounts.tsx
@@ -0,0 +1,2961 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import type { ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ X,
+ Activity,
+ Sparkles,
+ Coins,
+ BarChart3,
+ Pencil,
+ ExternalLink,
+ RefreshCw,
+ Lock,
+ MoreHorizontal,
+ Trash2,
+ Columns3,
+ Plus,
+ CheckCircle,
+ XCircle,
+ Loader2,
+ FlaskConical,
+ SlidersHorizontal,
+} from "lucide-react";
+
+import { api, getAdminKey } from "../api";
+import type { ProxyRow } from "../api";
+import type {
+ AccountRow,
+ AccountGroup,
+ AccountListSummary,
+ AccountEmailDomainFacet,
+ AccountPageStatsItem,
+ AccountHealthBucket,
+ ClaudeImportTokenRequest,
+} from "../types";
+import AccountUsageModal from "../components/AccountUsageModal";
+import AccountDetailSheet from "../components/AccountDetailSheet";
+import AccountHealthBar from "../components/AccountHealthBar";
+import RequestCountPills from "../components/RequestCountPills";
+import { CompactStat } from "../components/CompactStat";
+import AccountGroupMultiSelect from "../components/AccountGroupMultiSelect";
+import AccountQuotaDistributionChart from "../components/AccountQuotaDistributionChart";
+import AccountRateLimitRecoveryChart from "../components/AccountRateLimitRecoveryChart";
+import type { AccountAnalysisResponse } from "../types";
+import { ProxyField } from "../components/ProxyField";
+import { AccountGroupManagerModal, ACCOUNT_GROUP_COLORS } from "../components/AccountGroupManagerModal";
+import { Select } from "../components/ui/select";
+import ChannelLogo from "../components/ChannelLogo";
+import Modal from "../components/Modal";
+import PageHeader from "../components/PageHeader";
+import StatusBadge from "../components/StatusBadge";
+import Pagination from "../components/Pagination";
+import AccountGroupFilterSelect, {
+ EMPTY_ACCOUNT_GROUP_FILTER,
+ isAccountGroupFilterEmpty,
+ pruneAccountGroupFilter,
+} from "../components/AccountGroupFilterSelect";
+import type { AccountGroupFilterValue } from "../components/AccountGroupFilterSelect";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { cn } from "@/lib/utils";
+import {
+ accountStateTableRowClass,
+ renderAccountStateOverlay,
+} from "../components/AccountStateOverlay";
+import { useToast } from "../hooks/useToast";
+import { useConfirmDialog } from "../hooks/useConfirmDialog";
+import { getErrorMessage } from "../utils/error";
+import { getAccountStatusBadgeStatus } from "../lib/usageFormat";
+
+const FALLBACK_GROUP_COLOR = "#2563eb";
+function normalizeGroupColor(color?: string): string {
+ const v = (color || "").trim();
+ return /^#[0-9a-fA-F]{6}$/.test(v) ? v : FALLBACK_GROUP_COLOR;
+}
+
+// extractCode 从粘贴内容里取授权码:支持整条回调 URL、code#state、或纯 code。
+function extractCode(input: string): string {
+ const raw = input.trim();
+ if (!raw) return "";
+ if (raw.startsWith("http://") || raw.startsWith("https://")) {
+ try {
+ const u = new URL(raw);
+ const code = u.searchParams.get("code");
+ if (code) return code.trim();
+ } catch {
+ // fall through
+ }
+ }
+ return raw;
+}
+
+// claudeUsagePct 取用量百分比(0-100)。后端解析 Anthropic 统一限流头后,
+// usage_percent_5h/7d 为真实窗口利用率;null/undefined 表示尚无上游观测。
+function claudeUsagePct(v: unknown): number | null {
+ if (v === null || v === undefined || (typeof v === "string" && v.trim() === "")) return null;
+ const n = typeof v === "number" ? v : Number(v);
+ return Number.isFinite(n) && n >= 0 ? Math.min(100, n) : null;
+}
+
+function usageTone(pct: number): string {
+ return pct >= 90 ? "bg-rose-500" : pct >= 70 ? "bg-amber-500" : "bg-emerald-500";
+}
+
+// formatCompactNum 紧凑数字:1234 → 1.2k。
+function formatCompactNum(v: unknown): string {
+ const n = typeof v === "number" ? v : Number(v);
+ if (!Number.isFinite(n) || n <= 0) return "0";
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
+ return String(Math.round(n));
+}
+
+// pad2 两位补零。
+const pad2 = (n: number) => String(n).padStart(2, "0");
+
+// formatShortDateTime "MM-DD HH:mm" 短格式(与 Codex 卡片的 ⏱ 重置时间一致口径)。
+function formatShortDateTime(iso?: string): { label: string; title: string } | null {
+ if (!iso) return null;
+ const d = new Date(iso);
+ if (!Number.isFinite(d.getTime())) return null;
+ return {
+ label: `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`,
+ title: d.toLocaleString(),
+ };
+}
+
+// formatRelativeShort 相对时间:刚刚 / Xm / Xh / Xd 前。
+function formatRelativeShort(iso: string | undefined, t: (k: string) => string): string {
+ if (!iso) return "-";
+ const ts = new Date(iso).getTime();
+ if (!Number.isFinite(ts)) return "-";
+ const diff = Math.max(0, Date.now() - ts);
+ const m = Math.floor(diff / 60000);
+ if (m < 1) return t("claude.justNow");
+ if (m < 60) return `${m}m`;
+ const h = Math.floor(m / 60);
+ if (h < 24) return `${h}h${m % 60}m`;
+ return `${Math.floor(h / 24)}d${h % 24}h`;
+}
+
+// maybeOfferSaveProxyToPool 手动输入(非代理池)的代理保存后,若该代理不在代理管理中,
+// 询问是否存入代理池,方便后续复用与负载均衡。confirm 返回 true 才写入。
+async function maybeOfferSaveProxyToPool(
+ url: string,
+ proxies: ProxyRow[],
+ confirm: (opts: { title: string; description: string }) => Promise,
+ showToast: (msg: string, type?: "success" | "error") => void,
+ t: (k: string, o?: Record) => string,
+): Promise {
+ const trimmed = url.trim();
+ if (!trimmed) return;
+ if (proxies.some((p) => p.url === trimmed)) return; // 已在池中
+ const ok = await confirm({
+ title: t("claude.saveProxyToPoolTitle"),
+ description: trimmed,
+ });
+ if (!ok) return;
+ try {
+ await api.addProxies({ url: trimmed });
+ showToast(t("claude.saveProxyToPoolDone"), "success");
+ } catch (error) {
+ showToast(getErrorMessage(error), "error");
+ }
+}
+
+// avatarInitial 头像首字母。
+function avatarInitial(acc: AccountRow): string {
+ const s = (acc.email || acc.name || "").trim();
+ return s ? s[0].toUpperCase() : "C";
+}
+
+// claudePlanBadge 按订阅档位配色(pro/max-5x/max-20x/team/enterprise/free)。
+function claudePlanBadge(plan: string): { label: string; cls: string } {
+ const p = plan.trim().toLowerCase();
+ const base = "inline-flex items-center rounded-md px-1.5 py-0.5 text-[11px] font-medium ring-1 ring-inset";
+ switch (p) {
+ case "pro":
+ return { label: "Pro", cls: `${base} bg-purple-50 text-purple-700 ring-purple-600/20 dark:bg-purple-950 dark:text-purple-300 dark:ring-purple-400/20` };
+ case "max-5x":
+ return { label: "Max 5x", cls: `${base} bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-300 dark:ring-amber-400/20` };
+ case "max-20x":
+ return { label: "Max 20x", cls: `${base} bg-rose-50 text-rose-700 ring-rose-600/20 dark:bg-rose-950 dark:text-rose-300 dark:ring-rose-400/20` };
+ case "max":
+ return { label: "Max", cls: `${base} bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-300 dark:ring-amber-400/20` };
+ case "team":
+ return { label: "Team", cls: `${base} bg-sky-50 text-sky-700 ring-sky-600/20 dark:bg-sky-950 dark:text-sky-300 dark:ring-sky-400/20` };
+ case "enterprise":
+ return { label: "Enterprise", cls: `${base} bg-indigo-50 text-indigo-700 ring-indigo-600/20 dark:bg-indigo-950 dark:text-indigo-300 dark:ring-indigo-400/20` };
+ case "business":
+ return { label: "Business", cls: `${base} bg-indigo-50 text-indigo-700 ring-indigo-600/20 dark:bg-indigo-950 dark:text-indigo-300 dark:ring-indigo-400/20` };
+ case "free":
+ return { label: "Free", cls: `${base} bg-zinc-100 text-zinc-600 ring-zinc-500/20 dark:bg-zinc-900 dark:text-zinc-400 dark:ring-zinc-500/20` };
+ default:
+ return { label: plan, cls: `${base} bg-purple-50 text-purple-700 ring-purple-600/20 dark:bg-purple-950 dark:text-purple-300 dark:ring-purple-400/20` };
+ }
+}
+
+// Claude 模型白名单边界:该页面只允许原生 Claude 模型,不能把其它
+// provider 的模型误写入 Claude 账号。后端 endpoint 仍会做通用名称校验,
+// 这里再做一次 provider-aware 过滤,避免管理端误配导致调度边界漂移。
+const CLAUDE_MODEL_ID_RE = /^claude-[a-z0-9][a-z0-9._-]*$/i;
+
+function isClaudeModelID(value: unknown): value is string {
+ return typeof value === "string" && CLAUDE_MODEL_ID_RE.test(value.trim());
+}
+
+function normalizeClaudeModelList(values: unknown): string[] {
+ if (!Array.isArray(values)) return [];
+ const seen = new Set();
+ const result: string[] = [];
+ for (const value of values) {
+ if (!isClaudeModelID(value)) continue;
+ const model = value.trim();
+ const key = model.toLowerCase();
+ if (seen.has(key)) continue;
+ seen.add(key);
+ result.push(model);
+ }
+ return result;
+}
+
+function parseClaudeModelTokens(raw: string): { accepted: string[]; rejected: string[] } {
+ const accepted: string[] = [];
+ const rejected: string[] = [];
+ for (const token of raw.split(/[\s,,、]+/).map((item) => item.trim()).filter(Boolean)) {
+ if (isClaudeModelID(token)) accepted.push(token);
+ else rejected.push(token);
+ }
+ return { accepted: normalizeClaudeModelList(accepted), rejected };
+}
+
+function mergeClaudeModelLists(...lists: unknown[]): string[] {
+ return normalizeClaudeModelList(lists.flatMap((list) => Array.isArray(list) ? list : []));
+}
+
+// 状态过滤项 → 后端 status 参数。
+type ClaudeStatusFilter =
+ | "all"
+ | "normal"
+ | "scheduling"
+ | "rate_limited"
+ | "abnormal"
+ | "banned"
+ | "error"
+ | "unsampled"
+ | "disabled"
+ | "locked";
+
+type AuthFilter = "all" | "oauth" | "api_key";
+type HealthTier = "healthy" | "warm" | "risky" | "banned";
+
+type SortKey = "default" | "group" | "priority" | "usage" | "requests" | "today";
+const SORT_MAP: Record[0]["sort"]>; order: "asc" | "desc" }> = {
+ default: { sort: "updated_at", order: "desc" },
+ group: { sort: "group", order: "asc" },
+ priority: { sort: "scheduler_priority", order: "desc" },
+ usage: { sort: "usage", order: "desc" },
+ requests: { sort: "requests", order: "desc" },
+ today: { sort: "today", order: "desc" },
+};
+
+// 可显隐列(序号/邮箱/操作为固定核心列,不参与切换)。持久化到 localStorage,与 Codex 一致。
+const CLAUDE_TOGGLE_COLUMNS = [
+ "groups",
+ "priority",
+ "plan",
+ "status",
+ "today",
+ "requests",
+ "usage",
+ "cost",
+ "importTime",
+ "updatedAt",
+] as const;
+type ClaudeCol = (typeof CLAUDE_TOGGLE_COLUMNS)[number];
+type ClaudeColVisibility = Record;
+const CLAUDE_COLS_KEY = "codex2api:claude-accounts:visible-columns";
+
+function defaultClaudeCols(): ClaudeColVisibility {
+ return Object.fromEntries(CLAUDE_TOGGLE_COLUMNS.map((c) => [c, true])) as ClaudeColVisibility;
+}
+
+function loadClaudeCols(): ClaudeColVisibility {
+ const fallback = defaultClaudeCols();
+ try {
+ const raw = window.localStorage.getItem(CLAUDE_COLS_KEY);
+ if (!raw) return fallback;
+ const parsed = JSON.parse(raw) as Partial;
+ return Object.fromEntries(
+ CLAUDE_TOGGLE_COLUMNS.map((c) => [c, typeof parsed[c] === "boolean" ? parsed[c] : true]),
+ ) as ClaudeColVisibility;
+ } catch {
+ return fallback;
+ }
+}
+
+// LiveCountdown 显示限流/重置的剩余时间,每秒刷新。
+// plain=true 为弱化文本样式(用量条下的 ⏱ 重置行);默认琥珀徽章(限流冷却)。
+function LiveCountdown({ until, label, plain = false }: { until?: string; label: string; plain?: boolean }) {
+ const [now, setNow] = useState(() => Date.now());
+ useEffect(() => {
+ if (!until) return;
+ const id = window.setInterval(() => setNow(Date.now()), 1000);
+ return () => window.clearInterval(id);
+ }, [until]);
+ if (!until) return null;
+ const target = new Date(until).getTime();
+ if (!Number.isFinite(target)) return null;
+ const remain = Math.max(0, Math.floor((target - now) / 1000));
+ if (remain <= 0) return null;
+ const d = Math.floor(remain / 86400);
+ const h = Math.floor((remain % 86400) / 3600);
+ const m = Math.floor((remain % 3600) / 60);
+ const s = remain % 60;
+ const text = d > 0 ? `${d}d${h}h` : h > 0 ? `${h}h${m}m` : m > 0 ? `${m}m${s}s` : `${s}s`;
+ if (plain) {
+ return (
+
+ {label} {text}
+
+ );
+ }
+ return (
+
+ {label} {text}
+
+ );
+}
+
+// UsageWindow 单条用量窗口(5h / 7d)。视觉对齐 Codex 的 UsageBar/UsageWindowStat:
+// - percent 有真实观测(Anthropic 统一限流头)→ 进度条 + 百分比 + ⏱重置倒计时;
+// - 仅有网关侧明细(req/tok/$)→ 明细行;
+// - 两者都无 → 不渲染(由父级统一显示 "-")。
+function UsageWindow({
+ label,
+ pct,
+ reset,
+ resetLabel,
+ detail,
+}: {
+ label: string;
+ pct: number | null;
+ reset?: string;
+ resetLabel: string;
+ detail?: AccountRow["usage_5h_detail"];
+}) {
+ const hasDetail = !!detail && ((detail.requests ?? 0) > 0 || (detail.tokens ?? 0) > 0);
+ const billed = typeof detail?.account_billed === "number" && detail.account_billed > 0 ? detail.account_billed : null;
+ if (pct === null && !hasDetail) return null;
+ const rt = formatShortDateTime(reset);
+ // 明细(req/tok/$)进 tooltip,行内只留 标签+进度条+百分比+⏱重置,收窄整列。
+ const detailTitle = [
+ hasDetail ? `${formatCompactNum(detail?.requests)} req / ${formatCompactNum(detail?.tokens)} tok` : "",
+ billed !== null ? `$${billed.toFixed(4)}` : "",
+ rt ? `${resetLabel} ${rt.title}` : "",
+ ]
+ .filter(Boolean)
+ .join(" · ");
+ return (
+
+ {label}
+
+ {pct !== null ? (
+
+ ) : null}
+
+
+ {pct !== null ? `${pct.toFixed(1)}%` : "—"}
+
+ {rt ? (
+ ⏱{rt.label}
+ ) : null}
+
+ );
+}
+
+function ClaudeConcurrencyBadge({ acc }: { acc: AccountRow }) {
+ const { t } = useTranslation();
+ const active = Math.max(0, acc.active_requests ?? 0);
+ const occupied = Math.max(active, acc.occupied_requests ?? active);
+ if (occupied === 0) return null;
+ const buffered = occupied - active;
+ const showOccupied = acc.session_slot_buffer_enabled === true;
+ return (
+
+
+ {showOccupied ? `${active}/${occupied}` : active}
+
+ );
+}
+
+export default function ClaudeAccounts({ headerSlot }: { headerSlot?: ReactNode } = {}) {
+ const { t } = useTranslation();
+ const { showToast } = useToast();
+ const { confirm, confirmDialog } = useConfirmDialog();
+
+ const [accounts, setAccounts] = useState([]);
+ const [summary, setSummary] = useState(null);
+ const [tags, setTags] = useState([]);
+ const [domains, setDomains] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [loading, setLoading] = useState(true);
+ const [loadError, setLoadError] = useState(null);
+ const [proxyPool, setProxyPool] = useState([]);
+ const [groups, setGroups] = useState([]);
+
+ const [showAdd, setShowAdd] = useState(false);
+ const [showManageGroups, setShowManageGroups] = useState(false);
+ const [assignTarget, setAssignTarget] = useState(null);
+ const [usageTarget, setUsageTarget] = useState(null);
+ const [editTarget, setEditTarget] = useState(null);
+ const [modelsTarget, setModelsTarget] = useState(null);
+ const [detailTarget, setDetailTarget] = useState(null);
+ const [testingTarget, setTestingTarget] = useState(null);
+ const detailAbortRef = useRef(null);
+ const detailRequestSeqRef = useRef(0);
+ useEffect(() => () => detailAbortRef.current?.abort(), []);
+ // page-stats 独立拉取:分页基础行不含 5h/7d/今日 的网关侧用量明细,单独补齐(与 Codex 页同构)。
+ const [pageStats, setPageStats] = useState>({});
+ const [pageStatsToken, setPageStatsToken] = useState(0);
+ const [liveState, setLiveState] = useState>({});
+ const [liveSessionSlotBufferEnabled, setLiveSessionSlotBufferEnabled] = useState(false);
+ // 健康状态条(近 200 分钟成败分桶,与 Codex 卡片同源接口)。
+ const [healthBars, setHealthBars] = useState>({});
+ // 额度分布 + 限流恢复分析(号池模式面板,与 Codex 同源接口/组件)。
+ const [analysis, setAnalysis] = useState(null);
+ const [showAnalysis, setShowAnalysis] = useState(true);
+ const [analysisLoading, setAnalysisLoading] = useState(false);
+ const [analysisError, setAnalysisError] = useState(null);
+ const analysisAbortRef = useRef(null);
+
+ const loadAnalysis = useCallback(async () => {
+ if (!showAnalysis) return;
+ analysisAbortRef.current?.abort();
+ const controller = new AbortController();
+ analysisAbortRef.current = controller;
+ setAnalysisLoading(true);
+ setAnalysisError(null);
+ try {
+ const res = await api.getAccountAnalysis("claude", controller.signal);
+ if (!controller.signal.aborted) setAnalysis(res);
+ } catch (error) {
+ if (!controller.signal.aborted) setAnalysisError(getErrorMessage(error));
+ } finally {
+ if (analysisAbortRef.current === controller) {
+ analysisAbortRef.current = null;
+ setAnalysisLoading(false);
+ }
+ }
+ }, [showAnalysis]);
+
+ const samplingSignature = useMemo(
+ () => accounts.map((acc) => `${acc.id}:${acc.claude_usage_probe_at ?? ""}:${acc.claude_usage_probe_error ?? ""}`).join("|"),
+ [accounts],
+ );
+ useEffect(() => {
+ void loadAnalysis();
+ return () => analysisAbortRef.current?.abort();
+ }, [loadAnalysis, samplingSignature]);
+
+ // 过滤 / 排序 / 分页
+ const [search, setSearch] = useState("");
+ const [debouncedSearch, setDebouncedSearch] = useState("");
+ const [statusFilter, setStatusFilter] = useState("all");
+ const [healthTier, setHealthTier] = useState(null);
+ const [planFilter, setPlanFilter] = useState("all");
+ const [authFilter, setAuthFilter] = useState("all");
+ const [tagFilter, setTagFilter] = useState("all");
+ const [domainFilter, setDomainFilter] = useState("all");
+ const [groupFilter, setGroupFilter] = useState(EMPTY_ACCOUNT_GROUP_FILTER);
+ const [sortKey, setSortKey] = useState("default");
+ const [page, setPage] = useState(1);
+ const [pageSize, setPageSize] = useState(20);
+ const [hideDomainTags, setHideDomainTags] = useState(false);
+ const [visibleCols, setVisibleCols] = useState(loadClaudeCols);
+ useEffect(() => {
+ try {
+ window.localStorage.setItem(CLAUDE_COLS_KEY, JSON.stringify(visibleCols));
+ } catch {
+ /* localStorage 不可用时忽略 */
+ }
+ }, [visibleCols]);
+ const [knownPlans, setKnownPlans] = useState([]);
+ const [selected, setSelected] = useState>(new Set());
+ const reloadAbortRef = useRef(null);
+ const reloadGenerationRef = useRef(0);
+
+ // 搜索防抖
+ useEffect(() => {
+ const id = window.setTimeout(() => setDebouncedSearch(search.trim()), 300);
+ return () => window.clearTimeout(id);
+ }, [search]);
+
+ // 筛选变化时回到第一页
+ useEffect(() => {
+ setPage(1);
+ }, [debouncedSearch, statusFilter, healthTier, planFilter, authFilter, tagFilter, domainFilter, groupFilter, sortKey, pageSize]);
+
+ const claudeGroups = useMemo(() => groups.filter((g) => g.channel === "claude"), [groups]);
+ const groupMap = useMemo(() => new Map(claudeGroups.map((g) => [g.id, g])), [claudeGroups]);
+
+ const reloadGroups = useCallback(async () => {
+ try {
+ const res = await api.listAccountGroups();
+ setGroups(res.groups ?? []);
+ } catch {
+ /* ignore */
+ }
+ }, []);
+
+ const reload = useCallback(async (options?: { silent?: boolean }) => {
+ reloadAbortRef.current?.abort();
+ if (!options?.silent) setLoading(true);
+ if (!options?.silent) setLoadError(null);
+ const controller = new AbortController();
+ reloadAbortRef.current = controller;
+ const generation = ++reloadGenerationRef.current;
+ try {
+ const { sort, order } = SORT_MAP[sortKey];
+ const res = await api.getAccountsPage(
+ {
+ channel: "claude",
+ page,
+ pageSize,
+ search: debouncedSearch || undefined,
+ status: statusFilter === "all" ? undefined : statusFilter,
+ healthTier: healthTier ?? undefined,
+ plan: planFilter === "all" ? undefined : planFilter,
+ authKind: authFilter === "all" ? undefined : authFilter,
+ tag: tagFilter === "all" ? undefined : tagFilter,
+ emailDomain: domainFilter === "all" ? undefined : domainFilter,
+ groupInclude: groupFilter.include,
+ groupExclude: groupFilter.exclude,
+ ungrouped: groupFilter.ungrouped,
+ sort,
+ order,
+ },
+ controller.signal,
+ );
+ if (controller.signal.aborted || generation !== reloadGenerationRef.current) return;
+ const rows = res.accounts ?? [];
+ setLoadError(null);
+ setAccounts(rows);
+ setSummary(res.summary ?? null);
+ setTags(res.facets?.tags ?? []);
+ setDomains(res.facets?.email_domains ?? []);
+ setTotal(res.total ?? rows.length);
+ if (res.page && res.page !== page) setPage(res.page);
+ // 累积已知套餐,供套餐 Tab 使用。
+ setKnownPlans((prev) => {
+ const set = new Set(prev);
+ for (const r of rows) if (r.plan_type) set.add(r.plan_type);
+ return set.size === prev.length ? prev : Array.from(set);
+ });
+ } catch (error) {
+ if (!controller.signal.aborted && generation === reloadGenerationRef.current) {
+ const message = getErrorMessage(error);
+ setLoadError(message);
+ if (!options?.silent) showToast(message, "error");
+ }
+ } finally {
+ if (!options?.silent && !controller.signal.aborted && generation === reloadGenerationRef.current) setLoading(false);
+ }
+ }, [
+ page,
+ pageSize,
+ debouncedSearch,
+ statusFilter,
+ healthTier,
+ planFilter,
+ authFilter,
+ tagFilter,
+ domainFilter,
+ groupFilter,
+ sortKey,
+ showToast,
+ ]);
+
+ useEffect(() => {
+ void reload();
+ return () => reloadAbortRef.current?.abort();
+ }, [reload]);
+
+ // 导入接口只负责入队,首轮 native Messages 采样在后台完成。对仍未
+ // 采样的 Claude 账号做有限次数静默轮询,让页面自动显示采样结果,同时
+ // 避免无限刷新或在后台标签页持续制造请求。
+ const pendingSamplingKey = useMemo(
+ () => accounts
+ .filter((acc) => acc.claude_api && !acc.claude_usage_probe_at && !acc.claude_usage_probe_error)
+ .map((acc) => acc.id)
+ .join(","),
+ [accounts],
+ );
+ useEffect(() => {
+ if (!pendingSamplingKey) return undefined;
+ let attempts = 0;
+ let requestInFlight = false;
+ const maxAttempts = 20;
+ const samplingPollTimer = window.setInterval(() => {
+ if (attempts >= maxAttempts) {
+ window.clearInterval(samplingPollTimer);
+ return;
+ }
+ if (document.visibilityState === "hidden") return;
+ if (requestInFlight) return;
+ attempts += 1;
+ requestInFlight = true;
+ void reload({ silent: true }).finally(() => {
+ requestInFlight = false;
+ });
+ }, 3000);
+ return () => window.clearInterval(samplingPollTimer);
+ }, [pendingSamplingKey, reload]);
+
+ const mergeLiveStateIntoAccount = useCallback((account: AccountRow): AccountRow => {
+ const live = liveState[String(account.id)];
+ return live
+ ? {
+ ...account,
+ active_requests: live.active_requests,
+ occupied_requests: live.occupied_requests,
+ session_slot_buffer_enabled: liveSessionSlotBufferEnabled,
+ }
+ : account;
+ }, [liveSessionSlotBufferEnabled, liveState]);
+
+ useEffect(() => {
+ if (!detailTarget) return;
+ const live = liveState[String(detailTarget.id)];
+ if (!live) return;
+ setDetailTarget((current) => current && current.id === detailTarget.id
+ ? {
+ ...current,
+ active_requests: live.active_requests,
+ occupied_requests: live.occupied_requests,
+ session_slot_buffer_enabled: liveSessionSlotBufferEnabled,
+ }
+ : current);
+ }, [detailTarget?.id, liveSessionSlotBufferEnabled, liveState]);
+
+ const refreshOpenDetail = useCallback(async (id: number) => {
+ if (detailTarget?.id !== id) return;
+ detailAbortRef.current?.abort();
+ const controller = new AbortController();
+ detailAbortRef.current = controller;
+ const requestSeq = ++detailRequestSeqRef.current;
+ try {
+ const detail = await api.getAccount(id, controller.signal);
+ if (!controller.signal.aborted && requestSeq === detailRequestSeqRef.current) {
+ setDetailTarget((current) => current?.id === id ? mergeLiveStateIntoAccount(detail) : current);
+ }
+ } catch {
+ // The list refresh remains authoritative if the optional detail refresh fails.
+ } finally {
+ if (detailAbortRef.current === controller) detailAbortRef.current = null;
+ }
+ }, [detailTarget?.id, mergeLiveStateIntoAccount]);
+
+ const openDetail = useCallback(async (acc: AccountRow) => {
+ detailAbortRef.current?.abort();
+ const controller = new AbortController();
+ detailAbortRef.current = controller;
+ const requestSeq = ++detailRequestSeqRef.current;
+ setDetailTarget(mergeLiveStateIntoAccount(acc));
+ try {
+ const detail = await api.getAccount(acc.id, controller.signal);
+ if (!controller.signal.aborted && requestSeq === detailRequestSeqRef.current) {
+ setDetailTarget(mergeLiveStateIntoAccount(detail));
+ }
+ } catch {
+ // 列表行本身已包含安全的基础信息,详情请求失败时仍可查看。
+ } finally {
+ if (detailAbortRef.current === controller) detailAbortRef.current = null;
+ }
+ }, [mergeLiveStateIntoAccount]);
+
+ const closeDetail = useCallback(() => {
+ detailAbortRef.current?.abort();
+ detailRequestSeqRef.current += 1;
+ setDetailTarget(null);
+ }, []);
+
+ // 模型白名单编辑始终从详情接口读取最新代际,避免用户在列表停留期间
+ // 账号刷新/换 token 后把旧配置覆盖回去。Modal 内保存时还会做一次
+ // updated_at 乐观并发校验,后端 endpoint 只负责持久化已过滤的模型名。
+ const openModelsEditor = useCallback(async (acc: AccountRow) => {
+ try {
+ const detail = await api.getAccount(acc.id);
+ if (detail.claude_api !== true) {
+ throw new Error(t("claude.modelsWhitelistNotClaude"));
+ }
+ setModelsTarget(detail);
+ } catch (error) {
+ showToast(getErrorMessage(error), "error");
+ }
+ }, [showToast, t]);
+
+ const handleSaveDetailCooldownPolicy = useCallback(async (account: AccountRow, data: {
+ mode: "off" | "fixed" | "adaptive" | null;
+ seconds: number | null;
+ backoff_enabled: boolean | null;
+ }) => {
+ try {
+ await api.updateAccountModelCooldownPolicy(account.id, data);
+ showToast(t("accounts.modelCooldownPolicySaved"), "success");
+ await refreshOpenDetail(account.id);
+ void reload();
+ } catch (error) {
+ showToast(getErrorMessage(error), "error");
+ }
+ }, [refreshOpenDetail, reload, showToast, t]);
+
+ const handleClearDetailCooldown = useCallback(async (account: AccountRow, model: string) => {
+ try {
+ await api.clearAccountModelCooldown(account.id, model);
+ showToast(t("accounts.modelCooldownCleared", { model }), "success");
+ await refreshOpenDetail(account.id);
+ void reload();
+ } catch (error) {
+ showToast(getErrorMessage(error), "error");
+ }
+ }, [refreshOpenDetail, reload, showToast, t]);
+
+ const handleClearAllDetailCooldowns = useCallback(async (account: AccountRow) => {
+ try {
+ const result = await api.clearAllAccountModelCooldowns(account.id);
+ showToast(t("accounts.allModelCooldownsCleared", { count: result.cleared }), "success");
+ await refreshOpenDetail(account.id);
+ void reload();
+ } catch (error) {
+ showToast(getErrorMessage(error), "error");
+ }
+ }, [refreshOpenDetail, reload, showToast, t]);
+
+ const handleClaudeTestSettled = useCallback(() => {
+ void reload({ silent: true });
+ void loadAnalysis();
+ }, [loadAnalysis, reload]);
+
+ // 拉取当前页账号的网关侧用量明细(req/tok/$,5h/7d/今日窗口)。
+ const accountIDsKey = useMemo(() => accounts.map((a) => a.id).join(","), [accounts]);
+ useEffect(() => {
+ if (!accountIDsKey) {
+ setPageStats({});
+ return;
+ }
+ const controller = new AbortController();
+ void api
+ .getAccountPageStats(accountIDsKey.split(",").map(Number), controller.signal)
+ .then((res) => {
+ if (!controller.signal.aborted) setPageStats(res.stats ?? {});
+ })
+ .catch(() => {
+ /* stats 失败不阻断列表 */
+ });
+ return () => controller.abort();
+ }, [accountIDsKey, pageStatsToken]);
+
+ // 当前页会话占用是易变状态,单独轻量轮询,避免把整页账号快照频繁
+ // 重拉;切页/卸载时立即取消,保证旧页数据不会覆盖新页。
+ useEffect(() => {
+ if (!accountIDsKey) {
+ setLiveState({});
+ setLiveSessionSlotBufferEnabled(false);
+ return undefined;
+ }
+ const controller = new AbortController();
+ let active = true;
+ let requestInFlight = false;
+ let requestSeq = 0;
+ const ids = accountIDsKey.split(",").map(Number);
+ const loadLiveState = async () => {
+ if (requestInFlight) return;
+ requestInFlight = true;
+ const currentSeq = ++requestSeq;
+ try {
+ const res = await api.getAccountLiveState(ids, controller.signal);
+ if (active && !controller.signal.aborted && currentSeq === requestSeq) {
+ setLiveState(res.accounts ?? {});
+ setLiveSessionSlotBufferEnabled(res.session_slot_buffer_enabled === true);
+ }
+ } catch {
+ // 实时状态失败不阻断账号列表,保留上一次快照。
+ } finally {
+ requestInFlight = false;
+ }
+ };
+ void loadLiveState();
+ const timer = window.setInterval(() => {
+ if (document.visibilityState === "visible") void loadLiveState();
+ }, 5000);
+ return () => {
+ active = false;
+ controller.abort();
+ window.clearInterval(timer);
+ };
+ }, [accountIDsKey]);
+
+ // 刷新单个账号用量:触发上游探针(有则)+ 重拉本页 page-stats 明细。
+ const handleRefreshUsage = useCallback(
+ async (acc: AccountRow) => {
+ try {
+ const refreshed = await api.refreshAccountUsage(acc.id);
+ setAccounts((prev) =>
+ prev.map((row) =>
+ row.id === acc.id
+ ? {
+ ...row,
+ ...(refreshed.usage_percent_5h !== undefined ? { usage_percent_5h: refreshed.usage_percent_5h } : {}),
+ ...(refreshed.usage_percent_7d !== undefined ? { usage_percent_7d: refreshed.usage_percent_7d } : {}),
+ ...(refreshed.reset_5h_at ? { reset_5h_at: refreshed.reset_5h_at } : {}),
+ ...(refreshed.reset_7d_at ? { reset_7d_at: refreshed.reset_7d_at } : {}),
+ ...(row.claude_api && refreshed.claude_usage_probe_at
+ ? {
+ claude_usage_probe_at: refreshed.claude_usage_probe_at,
+ claude_usage_probe_error: refreshed.claude_usage_probe_error,
+ }
+ : {}),
+ }
+ : row,
+ ),
+ );
+ } catch (error) {
+ showToast(getErrorMessage(error), "error");
+ }
+ setPageStatsToken((v) => v + 1);
+ void reload({ silent: true });
+ },
+ [reload, showToast],
+ );
+
+ // 健康状态条数据。
+ useEffect(() => {
+ if (!accountIDsKey) {
+ setHealthBars({});
+ return;
+ }
+ let cancelled = false;
+ void api
+ .getAccountHealthBars(accountIDsKey.split(",").map(Number))
+ .then((res) => {
+ if (!cancelled) setHealthBars(res.buckets ?? {});
+ })
+ .catch(() => {
+ /* 健康条失败不阻断列表 */
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [accountIDsKey]);
+
+ // 渲染行 = 基础行 + page-stats 补齐(只补缺失字段,基础行已有的以基础行为准)。
+ const displayRows = useMemo(() => {
+ return accounts.map((acc) => {
+ const stats = pageStats[String(acc.id)];
+ const live = liveState[String(acc.id)];
+ if (!stats && !live) return acc;
+ const merged = { ...acc };
+ if (live) {
+ merged.active_requests = live.active_requests;
+ merged.occupied_requests = live.occupied_requests;
+ merged.session_slot_buffer_enabled = liveSessionSlotBufferEnabled;
+ }
+ if (stats) {
+ if (!merged.usage_5h_detail && stats.usage_5h_detail) merged.usage_5h_detail = stats.usage_5h_detail;
+ if (!merged.usage_7d_detail && stats.usage_7d_detail) merged.usage_7d_detail = stats.usage_7d_detail;
+ if (!merged.usage_today_detail && stats.usage_today_detail) merged.usage_today_detail = stats.usage_today_detail;
+ if (merged.official_usd == null && stats.official_usd != null) merged.official_usd = stats.official_usd;
+ if (merged.official_usd_7d == null && stats.official_usd_7d != null) merged.official_usd_7d = stats.official_usd_7d;
+ }
+ return merged;
+ });
+ }, [accounts, liveSessionSlotBufferEnabled, liveState, pageStats]);
+
+ useEffect(() => {
+ void reloadGroups();
+ let cancelled = false;
+ void api
+ .listProxies()
+ .then((res) => {
+ if (!cancelled) setProxyPool(res.proxies ?? []);
+ })
+ .catch(() => {
+ if (!cancelled) setProxyPool([]);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [reloadGroups]);
+
+ useEffect(() => {
+ setGroupFilter((current) => pruneAccountGroupFilter(current, claudeGroups));
+ }, [claudeGroups]);
+
+ // ── 账号操作 ──────────────────────────────────────────────
+ const handleDelete = useCallback(
+ async (acc: AccountRow) => {
+ const ok = await confirm({
+ title: t("claude.deleteConfirm"),
+ description: acc.email || acc.name || `#${acc.id}`,
+ });
+ if (!ok) return;
+ try {
+ await api.deleteAccount(acc.id);
+ void reload();
+ } catch (error) {
+ showToast(getErrorMessage(error), "error");
+ }
+ },
+ [confirm, reload, showToast, t],
+ );
+
+ const handleRefresh = useCallback(
+ async (acc: AccountRow) => {
+ try {
+ await api.refreshAccount(acc.id);
+ await refreshOpenDetail(acc.id);
+ void reload();
+ } catch (error) {
+ showToast(getErrorMessage(error), "error");
+ }
+ },
+ [refreshOpenDetail, reload, showToast],
+ );
+
+ const handleRefreshModels = useCallback(
+ async (acc: AccountRow) => {
+ try {
+ const res = await api.refreshClaudeModels(acc.id);
+ showToast(t("claude.modelsRefreshed", { count: res.count }));
+ await refreshOpenDetail(acc.id);
+ void reload();
+ } catch (error) {
+ showToast(getErrorMessage(error), "error");
+ }
+ },
+ [refreshOpenDetail, reload, showToast, t],
+ );
+
+ const handleRefreshAllModels = useCallback(async () => {
+ try {
+ const result = await api.refreshAllClaudeModels();
+ showToast(t("claude.allModelsRefreshedSummary", result), result.failed > 0 ? "warning" : "success");
+ void reload();
+ } catch (error) {
+ showToast(getErrorMessage(error), "error");
+ }
+ }, [reload, showToast, t]);
+
+ const handleToggleEnabled = useCallback(
+ async (acc: AccountRow) => {
+ const next = acc.enabled === false;
+ try {
+ await api.toggleAccountEnabled(acc.id, next);
+ showToast(next ? t("claude.enabledToast") : t("claude.disabledToast"), "success");
+ await refreshOpenDetail(acc.id);
+ void reload();
+ } catch (error) {
+ showToast(getErrorMessage(error), "error");
+ }
+ },
+ [refreshOpenDetail, reload, showToast, t],
+ );
+
+ const handleToggleLock = useCallback(
+ async (acc: AccountRow) => {
+ const next = !acc.locked;
+ try {
+ await api.toggleAccountLock(acc.id, next);
+ showToast(next ? t("claude.lockedToast") : t("claude.unlockedToast"), "success");
+ await refreshOpenDetail(acc.id);
+ void reload();
+ } catch (error) {
+ showToast(getErrorMessage(error), "error");
+ }
+ },
+ [refreshOpenDetail, reload, showToast, t],
+ );
+
+ const handleResetStatus = useCallback(
+ async (acc: AccountRow) => {
+ try {
+ await api.resetAccountStatus(acc.id);
+ showToast(t("claude.statusReset"), "success");
+ await refreshOpenDetail(acc.id);
+ void reload();
+ } catch (error) {
+ showToast(getErrorMessage(error), "error");
+ }
+ },
+ [refreshOpenDetail, reload, showToast, t],
+ );
+
+ // ── 批量操作 ──────────────────────────────────────────────
+ const selectedIds = useMemo(() => Array.from(selected), [selected]);
+ const toggleSelect = useCallback((id: number) => {
+ setSelected((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ }, []);
+ const allSelected = accounts.length > 0 && accounts.every((a) => selected.has(a.id));
+ const toggleSelectAll = useCallback(() => {
+ setSelected((prev) => {
+ if (accounts.every((a) => prev.has(a.id))) return new Set();
+ return new Set(accounts.map((a) => a.id));
+ });
+ }, [accounts]);
+
+ const runBatch = useCallback(
+ async (patch: { enabled?: boolean; locked?: boolean }) => {
+ if (selectedIds.length === 0) return;
+ try {
+ await api.batchUpdateAccounts({ ids: selectedIds, ...patch });
+ setSelected(new Set());
+ void reload();
+ } catch (error) {
+ showToast(getErrorMessage(error), "error");
+ }
+ },
+ [selectedIds, reload, showToast],
+ );
+
+ // ── 派生 UI 数据 ──────────────────────────────────────────
+ const statChips = useMemo(() => {
+ const s = summary;
+ const c: Array<{ id: ClaudeStatusFilter; label: string; count: number; tone?: string }> = [
+ { id: "all", label: t("claude.statAll"), count: s?.total ?? total },
+ { id: "normal", label: t("claude.statNormal"), count: s?.normal ?? 0, tone: "text-emerald-600 dark:text-emerald-400" },
+ { id: "scheduling", label: t("claude.statScheduling"), count: s?.active ?? 0, tone: "text-sky-600 dark:text-sky-400" },
+ { id: "rate_limited", label: t("claude.statRateLimited"), count: s?.rate_limited ?? 0, tone: "text-amber-600 dark:text-amber-400" },
+ { id: "abnormal", label: t("claude.statAbnormal"), count: s?.abnormal ?? 0, tone: "text-rose-600 dark:text-rose-400" },
+ { id: "banned", label: t("claude.statBanned"), count: s?.banned ?? 0, tone: "text-rose-600 dark:text-rose-400" },
+ { id: "error", label: t("claude.statError"), count: s?.error ?? 0, tone: "text-rose-600 dark:text-rose-400" },
+ { id: "unsampled", label: t("claude.statUnsampled"), count: s?.unsampled ?? 0 },
+ { id: "disabled", label: t("claude.statDisabled"), count: s?.disabled ?? 0 },
+ { id: "locked", label: t("claude.statLocked"), count: s?.locked ?? 0 },
+ ];
+ return c;
+ }, [summary, total, t]);
+
+ const healthChips = useMemo(() => {
+ const s = summary;
+ return [
+ { id: "healthy" as HealthTier, label: t("claude.healthHealthy"), count: s?.healthy ?? 0, dot: "bg-emerald-500" },
+ { id: "warm" as HealthTier, label: t("claude.healthWarm"), count: s?.warm ?? 0, dot: "bg-amber-500" },
+ { id: "risky" as HealthTier, label: t("claude.healthRisky"), count: s?.risky ?? 0, dot: "bg-rose-500" },
+ { id: "banned" as HealthTier, label: t("claude.healthBanned"), count: s?.banned ?? 0, dot: "bg-zinc-500" },
+ ];
+ }, [summary, t]);
+
+ const planTabs = useMemo(() => {
+ const plans = knownPlans.filter(Boolean).sort();
+ return ["all", ...plans];
+ }, [knownPlans]);
+
+ // Claude 账号当前只支持 OAuth;不展示一个永远为 0 的 API Key 筛选,避免
+ // 运营误以为 Claude API Key 可以走同一原生链路。
+ const authTabs: Array<{ id: AuthFilter; label: string; count?: number }> = [
+ { id: "all", label: t("claude.authAll") },
+ { id: "oauth", label: t("claude.authOAuth"), count: summary?.oauth || summary?.total || 0 },
+ ];
+
+ const filtersActive =
+ statusFilter !== "all" ||
+ healthTier !== null ||
+ planFilter !== "all" ||
+ authFilter !== "all" ||
+ tagFilter !== "all" ||
+ domainFilter !== "all" ||
+ !isAccountGroupFilterEmpty(groupFilter) ||
+ sortKey !== "default" ||
+ debouncedSearch.length > 0;
+
+ const clearFilters = useCallback(() => {
+ setStatusFilter("all");
+ setHealthTier(null);
+ setPlanFilter("all");
+ setAuthFilter("all");
+ setTagFilter("all");
+ setDomainFilter("all");
+ setGroupFilter(EMPTY_ACCOUNT_GROUP_FILTER);
+ setSortKey("default");
+ setSearch("");
+ }, []);
+
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
+
+ const selectFieldCls =
+ "h-8 rounded-md border border-input bg-background px-2 text-xs text-foreground outline-none focus-visible:border-ring";
+
+ return (
+
+
{ void reload(); void loadAnalysis(); }}
+ actions={
+
+
+
+
+
+
+ }
+ />
+
+ {/* 统计卡(复用共享 CompactStat,与 Codex 同款:状态药丸 + 5h/7d·封禁/错误 details) */}
+
+ setStatusFilter("all")}
+ />
+ setStatusFilter(statusFilter === "normal" ? "all" : "normal")}
+ />
+ setStatusFilter(statusFilter === "scheduling" ? "all" : "scheduling")}
+ />
+ setStatusFilter(statusFilter === "rate_limited" ? "all" : "rate_limited")}
+ />
+ setStatusFilter(statusFilter === "abnormal" ? "all" : "abnormal")}
+ />
+
+
+ {/* 额度分布 + 限流恢复(号池模式分析面板,与 Codex 同款组件) */}
+ {showAnalysis && analysis ? (
+
+
void loadAnalysis()}
+ onProbeError={(message) => showToast(message, "error")}
+ descKey="claude.quotaDesc"
+ emptyKey="claude.quotaEmpty"
+ showProbe={false}
+ />
+
+
+ ) : showAnalysis ? (
+
+ {analysisLoading ? t("common.loading") : analysisError ? (
+
+ {analysisError}
+
+
+ ) : t("common.loading")}
+
+ ) : null}
+
+ {/* 统计芯片 */}
+
+ {statChips.map((chip) => {
+ const active = statusFilter === chip.id;
+ return (
+
+ );
+ })}
+
+
+ {/* 调度视图(点击按健康档过滤) */}
+
+ {t("claude.schedulingView")}
+ {healthChips.map((h) => {
+ const active = healthTier === h.id;
+ return (
+
+ );
+ })}
+
+
+ {/* 套餐 Tab */}
+ {planTabs.length > 1 ? (
+
+ {planTabs.map((p) => {
+ const active = planFilter === p;
+ return (
+
+ );
+ })}
+
+ ) : null}
+
+ {/* 过滤条:OAuth/API + 分组 + 标签 + 域名 + 排序 + 搜索 */}
+
+
+ {authTabs.map((a) => (
+
+ ))}
+
+
+
+
+
+
+ {/* 批量操作条 */}
+ {selectedIds.length > 0 ? (
+
+ {t("claude.selectedCount", { count: selectedIds.length })}
+
+
+
+
+
+
+ ) : null}
+
+ {/* 账号列表 */}
+ {loadError && accounts.length > 0 ? (
+
+ {loadError}
+
+
+ ) : null}
+ {loading && accounts.length === 0 ? (
+ {t("common.loading")}
+ ) : loadError && accounts.length === 0 ? (
+
+
{loadError}
+
+
+ ) : total === 0 && !filtersActive ? (
+
+ {t("claude.empty")}
+
+ ) : accounts.length === 0 ? (
+
+ {t("claude.emptyFiltered")}
+
+ ) : (
+
+ )}
+
+ {total > 0 ? (
+
+
{
+ setPageSize(next);
+ setPage(1);
+ }}
+ pageSizeOptions={[10, 20, 50, 100]}
+ />
+
+ ) : null}
+
+ {showAdd ? (
+ setShowAdd(false)}
+ onAdded={() => {
+ setShowAdd(false);
+ void reload();
+ }}
+ />
+ ) : null}
+
+ {showManageGroups ? (
+ setShowManageGroups(false)}
+ onChanged={() => {
+ void reloadGroups();
+ void reload();
+ }}
+ />
+ ) : null}
+
+ {assignTarget ? (
+ setAssignTarget(null)}
+ onSaved={() => {
+ setAssignTarget(null);
+ // 先刷新分组列表(内联新建的组要进 groupMap,否则芯片渲染不出),再刷新账号行。
+ void reloadGroups();
+ void reload();
+ }}
+ />
+ ) : null}
+
+ {usageTarget ? (
+ setUsageTarget(null)}
+ showCreditSettings={false}
+ officialUsage={false}
+ />
+ ) : null}
+
+ {editTarget ? (
+ setEditTarget(null)}
+ onSaved={() => {
+ setEditTarget(null);
+ void reload();
+ }}
+ />
+ ) : null}
+
+ {modelsTarget ? (
+ setModelsTarget(null)}
+ onSaved={() => {
+ setModelsTarget(null);
+ void reload({ silent: true });
+ if (detailTarget?.id === modelsTarget.id) void refreshOpenDetail(modelsTarget.id);
+ }}
+ />
+ ) : null}
+
+ {detailTarget ? (
+ groupMap.get(id)).filter(Boolean) as AccountGroup[]}
+ healthBuckets={healthBars[String(detailTarget.id)]}
+ usageSlot={
+
+
+
+
+ }
+ providerSlot={
+
+
+
{t("claude.providerTitle")}
+
+
+
+
{t("claude.authOAuth")}{t("claude.providerProtocol")}
+
{t("claude.subscriptionPlan")}{(() => { const badge = claudePlanBadge(detailTarget.plan_type || "claude"); return {badge.label}; })()}
+
{t("claude.subscriptionExpires")}{formatShortDateTime(detailTarget.subscription_expires_at)?.label ?? t("claude.metadataUnknown")}
+
{t("claude.fingerprintModeLabel")}{detailTarget.claude_fingerprint_mode === "force" ? t("claude.fpForce") : detailTarget.claude_fingerprint_mode === "preserve" ? t("claude.fpPreserve") : t("claude.fpFollowGlobal")}
+
{t("claude.timezoneLabel")}{detailTarget.timezone || t("claude.metadataUnknown")}
+
{t("claude.modelsLabel")}{detailTarget.models?.length ? t("claude.modelsWhitelistCount", { count: normalizeClaudeModelList(detailTarget.models).length }) : t("claude.modelsWhitelistAll")}
+
{t("claude.lastSample")}{detailTarget.claude_usage_probe_at ? formatRelativeShort(detailTarget.claude_usage_probe_at, t) : t("claude.samplingState.notSampled")}
+ {detailTarget.claude_usage_probe_error ?
{detailTarget.claude_usage_probe_error}
: null}
+
+
+ }
+ onClose={closeDetail}
+ onEdit={() => { setEditTarget(detailTarget); closeDetail(); }}
+ onUsage={() => { setUsageTarget(detailTarget); closeDetail(); }}
+ onTest={() => { closeDetail(); setTestingTarget(detailTarget); }}
+ onRefresh={() => void handleRefresh(detailTarget)}
+ onGenerateAuthJson={() => undefined}
+ onToggleEnabled={() => void handleToggleEnabled(detailTarget)}
+ onToggleLock={() => void handleToggleLock(detailTarget)}
+ onResetStatus={() => void handleResetStatus(detailTarget)}
+ onSaveModelCooldownPolicy={(data) => void handleSaveDetailCooldownPolicy(detailTarget, data)}
+ onClearModelCooldown={(model) => void handleClearDetailCooldown(detailTarget, model)}
+ onClearAllModelCooldowns={() => void handleClearAllDetailCooldowns(detailTarget)}
+ onResetCredits={() => undefined}
+ onDelete={() => { closeDetail(); void handleDelete(detailTarget); }}
+ />
+ ) : null}
+
+ {testingTarget ? (
+ setTestingTarget(null)}
+ onSettled={handleClaudeTestSettled}
+ />
+ ) : null}
+
+ {confirmDialog}
+
+ );
+}
+
+// ── 号池模式表格行(视觉对齐 Codex Pool Mode 表格;数据取 Claude 真实链路) ──
+function ClaudeAccountRow({
+ acc,
+ no,
+ selected,
+ onToggleSelect,
+ groupMap,
+ healthBuckets,
+ hideDomainTags,
+ columns,
+ onRefresh,
+ onRefreshModels,
+ onToggleEnabled,
+ onToggleLock,
+ onResetStatus,
+ onAssignGroups,
+ onUsage,
+ onUsageRefreshed,
+ onOpenDetail,
+ onTest,
+ onEdit,
+ onEditModels,
+ onDelete,
+}: {
+ acc: AccountRow;
+ no: number;
+ selected: boolean;
+ onToggleSelect: () => void;
+ groupMap: Map;
+ healthBuckets?: AccountHealthBucket[];
+ hideDomainTags: boolean;
+ columns: ClaudeColVisibility;
+ onRefresh: () => void;
+ onRefreshModels: () => void;
+ onToggleEnabled: () => void;
+ onToggleLock: () => void;
+ onResetStatus: () => void;
+ onAssignGroups: () => void;
+ onUsage: () => void;
+ onUsageRefreshed: () => void | Promise;
+ onOpenDetail: () => void;
+ onTest: () => void;
+ onEdit: () => void;
+ onEditModels: () => void;
+ onDelete: () => void;
+}) {
+ const { t } = useTranslation();
+ const pct5h = claudeUsagePct(acc.usage_percent_5h);
+ const pct7d = claudeUsagePct(acc.usage_percent_7d);
+ const disabled = acc.enabled === false;
+ const cooldownReason = (acc.status || "").toLowerCase().includes("rate") ? acc.error_message : "";
+ const accGroups = (acc.group_ids || []).map((id) => groupMap.get(id)).filter(Boolean) as AccountGroup[];
+ const today = acc.usage_today_detail;
+ const billed5h = typeof acc.usage_5h_detail?.account_billed === "number" ? acc.usage_5h_detail.account_billed : 0;
+ const billed7d = typeof acc.usage_7d_detail?.account_billed === "number" ? acc.usage_7d_detail.account_billed : 0;
+ const todayBilled = typeof today?.account_billed === "number" ? today.account_billed : 0;
+ const created = formatShortDateTime(acc.created_at);
+
+ const iconBtn =
+ "inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground";
+
+ return (
+
+ {/* 勾选 */}
+ |
+
+ |
+ {/* 序号 */}
+ {no} |
+ {/* 邮箱 */}
+
+
+
+
+
+
+
+
+ ID {acc.id}
+ {acc.models?.length ? {t("claude.modelCount", { count: acc.models.length })} : null}
+ {acc.last_used_at ? {t("claude.lastUsed")}: {formatRelativeShort(acc.last_used_at, t)} : null}
+ {!hideDomainTags && acc.email_domain ? (
+ @{acc.email_domain}
+ ) : null}
+ {acc.locked ? (
+
+
+ {t("claude.statLocked")}
+
+ ) : null}
+
+
+
+ |
+ {columns.groups ? (
+
+
+ {accGroups.map((g) => {
+ const color = normalizeGroupColor(g.color);
+ return (
+
+ );
+ })}
+
+
+ |
+ ) : null}
+ {columns.priority ? (
+
+
+ P {acc.scheduler_priority ?? 0}
+
+ |
+ ) : null}
+ {columns.plan ? (
+
+ {acc.plan_type ? (
+ (() => {
+ const b = claudePlanBadge(acc.plan_type);
+ return {b.label};
+ })()
+ ) : (
+ -
+ )}
+ |
+ ) : null}
+ {columns.status ? (
+
+
+ {renderAccountStateOverlay(acc, t, {
+ compact: true,
+ markerOnly: true,
+ onRecover: acc.status === "overload_paused" ? onResetStatus : undefined,
+ }) ?? (
+ <>
+
+
+
+
+ {acc.claude_api ? (
+
+ {acc.claude_usage_probe_error
+ ? t("claude.samplingState.error")
+ : acc.claude_usage_probe_at
+ ? t("claude.samplingState.sampled")
+ : t("claude.samplingState.unsampled")}
+
+ ) : null}
+
+ {acc.claude_api ? (
+
+ {t("claude.lastSample")}: {acc.claude_usage_probe_at ? formatRelativeShort(acc.claude_usage_probe_at, t) : t("claude.samplingState.notSampled")}
+ {acc.claude_usage_probe_error ? ` · ${acc.claude_usage_probe_error}` : ""}
+
+ ) : null}
+
+ >
+ )}
+
+ |
+ ) : null}
+ {columns.today ? (
+
+ {today ? (
+
+
+ 0 ? "font-semibold text-foreground" : "text-muted-foreground/50")}>
+ 0 ? "text-sky-500" : "text-muted-foreground/40")} aria-hidden />
+ {(today.requests ?? 0).toLocaleString()}
+
+ 0 ? "font-semibold text-foreground" : "text-muted-foreground/50")}>
+ 0 ? "text-purple-500 dark:text-purple-400" : "text-muted-foreground/40")} aria-hidden />
+ {formatCompactNum(today.tokens)}
+
+
+ 0
+ ? "bg-emerald-500/10 font-medium text-emerald-700 ring-emerald-500/20 dark:text-emerald-400"
+ : "bg-slate-500/10 text-slate-500 ring-slate-500/20 dark:text-slate-400",
+ )}
+ >
+ 0 ? "text-emerald-500" : "opacity-50")} aria-hidden />
+ ${todayBilled > 0 ? (todayBilled < 0.01 ? "<0.01" : todayBilled.toFixed(2)) : "0.00"}
+
+
+ ) : (
+ -
+ )}
+ |
+ ) : null}
+ {columns.requests ? (
+
+
+
+
+ |
+ ) : null}
+ {columns.usage ? (
+
+
+
+ {pct5h !== null || pct7d !== null || acc.usage_5h_detail || acc.usage_7d_detail ? (
+ <>
+
+
+ >
+ ) : (
+ -
+ )}
+
+
+
+ |
+ ) : null}
+ {columns.cost ? (
+
+
+ 5h: ${billed5h.toFixed(2)} / 7d: ${billed7d.toFixed(2)}
+
+ |
+ ) : null}
+ {columns.importTime ? (
+
+ {created?.label ?? "-"}
+ |
+ ) : null}
+ {columns.updatedAt ? (
+ {formatRelativeShort(acc.updated_at, t)} |
+ ) : null}
+ {/* 操作 */}
+
+
+
+
+
+
+
+
+
+
+ |
+
+ );
+}
+
+// ColumnsMenu 列显隐下拉(与 Codex 的列控制一致):勾选切换,状态持久化到 localStorage。
+function ColumnsMenu({
+ visible,
+ onChange,
+}: {
+ visible: ClaudeColVisibility;
+ onChange: (next: ClaudeColVisibility) => void;
+}) {
+ const { t } = useTranslation();
+ const [open, setOpen] = useState(false);
+ const rootRef = useRef(null);
+ useEffect(() => {
+ if (!open) return;
+ const onDown = (e: MouseEvent) => {
+ if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
+ };
+ const onEsc = (e: KeyboardEvent) => {
+ if (e.key === "Escape") setOpen(false);
+ };
+ document.addEventListener("mousedown", onDown);
+ document.addEventListener("keydown", onEsc);
+ return () => {
+ document.removeEventListener("mousedown", onDown);
+ document.removeEventListener("keydown", onEsc);
+ };
+ }, [open]);
+
+ const labelFor: Record = {
+ groups: t("accounts.groupsLabel"),
+ priority: t("accounts.schedulerPriorityColumn"),
+ plan: t("accounts.plan"),
+ status: t("accounts.status"),
+ today: t("claude.todayLabel"),
+ requests: t("accounts.requests"),
+ usage: t("accounts.usage"),
+ cost: t("claude.costLabel"),
+ importTime: t("accounts.importTime"),
+ updatedAt: t("accounts.updatedAt"),
+ };
+ const hiddenCount = CLAUDE_TOGGLE_COLUMNS.filter((c) => !visible[c]).length;
+
+ return (
+
+
+ {open ? (
+
+ {CLAUDE_TOGGLE_COLUMNS.map((c) => (
+
+ ))}
+
+ ) : null}
+
+ );
+}
+
+// UsageRefreshButton 用量刷新按钮:点击时旋转动画,请求完成后停止(与全站刷新按钮一致)。
+function UsageRefreshButton({ onRefresh, title }: { onRefresh: () => void | Promise; title: string }) {
+ const [spinning, setSpinning] = useState(false);
+ return (
+
+ );
+}
+
+// RowOverflowMenu "…" 溢出菜单:表格在 overflow 容器内,菜单用 fixed 定位避免被裁剪。
+function RowOverflowMenu({
+ items,
+}: {
+ items: Array<{ key: string; label: string; onClick: () => void; danger?: boolean }>;
+}) {
+ const [open, setOpen] = useState(false);
+ const [pos, setPos] = useState<{ top: number; right: number } | null>(null);
+ const btnRef = useRef(null);
+ const menuRef = useRef(null);
+
+ useEffect(() => {
+ if (!open) return;
+ const close = () => setOpen(false);
+ const onDown = (e: MouseEvent) => {
+ if (!menuRef.current?.contains(e.target as Node) && !btnRef.current?.contains(e.target as Node)) close();
+ };
+ const onEsc = (e: KeyboardEvent) => {
+ if (e.key === "Escape") close();
+ };
+ document.addEventListener("mousedown", onDown);
+ document.addEventListener("keydown", onEsc);
+ window.addEventListener("scroll", close, true);
+ window.addEventListener("resize", close);
+ return () => {
+ document.removeEventListener("mousedown", onDown);
+ document.removeEventListener("keydown", onEsc);
+ window.removeEventListener("scroll", close, true);
+ window.removeEventListener("resize", close);
+ };
+ }, [open]);
+
+ return (
+ <>
+
+ {open && pos ? (
+
+ {items.map((item) => (
+
+ ))}
+
+ ) : null}
+ >
+ );
+}
+
+// ── 账号分组指派弹窗 ──────────────────────────────────────
+function AssignGroupsModal({
+ account,
+ groups,
+ onClose,
+ onSaved,
+ onGroupsChanged,
+}: {
+ account: AccountRow;
+ groups: AccountGroup[];
+ onClose: () => void;
+ onSaved: () => void;
+ onGroupsChanged?: () => void | Promise;
+}) {
+ const { t } = useTranslation();
+ const { showToast } = useToast();
+ const [selected, setSelected] = useState(account.group_ids ?? []);
+ const [busy, setBusy] = useState(false);
+
+ // 内联建组:与其他页一致,复用 createAccountGroup(channel=claude),返回新 id 供自动勾选。
+ const createGroupInline = useCallback(
+ async (name: string): Promise => {
+ try {
+ // 颜色按调色板循环取(与 Codex 内联建组一致),避免新组都是同一颜色。
+ const color = ACCOUNT_GROUP_COLORS[groups.length % ACCOUNT_GROUP_COLORS.length];
+ const res = await api.createAccountGroup({ name: name.trim(), channel: "claude", color });
+ // 新组即时同步到父级 claudeGroups,保证保存后行内芯片能从 groupMap 取到它。
+ await onGroupsChanged?.();
+ return res.id ?? null;
+ } catch (error) {
+ showToast(getErrorMessage(error), "error");
+ return null;
+ }
+ },
+ [groups.length, onGroupsChanged, showToast],
+ );
+
+ const save = useCallback(async () => {
+ setBusy(true);
+ try {
+ await api.batchUpdateAccounts({ ids: [account.id], group_ids: selected });
+ showToast(t("claude.groupsUpdated"), "success");
+ onSaved();
+ } catch (error) {
+ showToast(getErrorMessage(error), "error");
+ } finally {
+ setBusy(false);
+ }
+ }, [account.id, selected, onSaved, showToast, t]);
+
+ return (
+
+
+
+
+ }
+ >
+