"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { Eye, EyeOff, Import, Layers, Link2, LoaderCircle, Mail, Pencil, Plus, RefreshCcw, Save, Search, ServerCog, Trash2, Unplug, } from "lucide-react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { createSub2APIServer, deleteSub2APIServer, fetchSub2APIServerAccounts, fetchSub2APIServerGroups, fetchSub2APIServers, startSub2APIImport, updateSub2APIServer, type Sub2APIRemoteAccount, type Sub2APIRemoteGroup, type Sub2APIServer, } from "@/lib/api"; const PAGE_SIZE_OPTIONS = ["50", "100", "200"] as const; type AuthMode = "password" | "api_key"; function normalizeAccounts(items: Sub2APIRemoteAccount[]) { const seen = new Set(); const accounts: Sub2APIRemoteAccount[] = []; for (const item of items) { const id = String(item.id || "").trim(); if (!id || seen.has(id)) { continue; } seen.add(id); accounts.push({ id, name: String(item.name || "").trim(), email: String(item.email || "").trim(), plan_type: String(item.plan_type || "").trim(), status: String(item.status || "").trim(), expires_at: String(item.expires_at || "").trim(), has_refresh_token: Boolean(item.has_refresh_token), }); } return accounts; } export function Sub2APIConnections() { const didLoadRef = useRef(false); const pollTimerRef = useRef(null); const [servers, setServers] = useState([]); const [isLoading, setIsLoading] = useState(true); const [dialogOpen, setDialogOpen] = useState(false); const [editingServer, setEditingServer] = useState(null); const [formName, setFormName] = useState(""); const [formBaseUrl, setFormBaseUrl] = useState(""); const [formEmail, setFormEmail] = useState(""); const [formPassword, setFormPassword] = useState(""); const [formApiKey, setFormApiKey] = useState(""); const [formGroupId, setFormGroupId] = useState(""); const [authMode, setAuthMode] = useState("password"); const [showSecret, setShowSecret] = useState(false); const [isSaving, setIsSaving] = useState(false); const [remoteGroups, setRemoteGroups] = useState(null); const [isLoadingGroups, setIsLoadingGroups] = useState(false); const [deletingId, setDeletingId] = useState(null); const [loadingAccountsId, setLoadingAccountsId] = useState(null); const [browserOpen, setBrowserOpen] = useState(false); const [browserServer, setBrowserServer] = useState(null); const [remoteAccounts, setRemoteAccounts] = useState([]); const [selectedIds, setSelectedIds] = useState([]); const [accountQuery, setAccountQuery] = useState(""); const [accountPage, setAccountPage] = useState(1); const [pageSize, setPageSize] = useState<(typeof PAGE_SIZE_OPTIONS)[number]>("100"); const [isStartingImport, setIsStartingImport] = useState(false); const loadServers = async () => { setIsLoading(true); try { const data = await fetchSub2APIServers(); setServers(data.servers); } catch (error) { toast.error(error instanceof Error ? error.message : "加载 Sub2API 连接失败"); } finally { setIsLoading(false); } }; useEffect(() => { if (didLoadRef.current) { return; } didLoadRef.current = true; void loadServers(); }, []); useEffect(() => { const hasRunningJobs = servers.some( (server) => server.import_job?.status === "pending" || server.import_job?.status === "running", ); if (!hasRunningJobs) { if (pollTimerRef.current !== null) { window.clearInterval(pollTimerRef.current); pollTimerRef.current = null; } return; } pollTimerRef.current = window.setInterval(() => { void fetchSub2APIServers() .then((data) => { setServers(data.servers); }) .catch((error) => { if (pollTimerRef.current !== null) { window.clearInterval(pollTimerRef.current); pollTimerRef.current = null; } toast.error(error instanceof Error ? error.message : "查询导入进度失败"); }); }, 1500); return () => { if (pollTimerRef.current !== null) { window.clearInterval(pollTimerRef.current); pollTimerRef.current = null; } }; }, [servers]); const openAddDialog = () => { setEditingServer(null); setFormName(""); setFormBaseUrl(""); setFormEmail(""); setFormPassword(""); setFormApiKey(""); setFormGroupId(""); setAuthMode("password"); setShowSecret(false); setRemoteGroups(null); setDialogOpen(true); }; const openEditDialog = (server: Sub2APIServer) => { setEditingServer(server); setFormName(server.name); setFormBaseUrl(server.base_url); setFormEmail(server.email); setFormPassword(""); setFormApiKey(""); setFormGroupId(server.group_id || ""); setAuthMode(server.has_api_key ? "api_key" : "password"); setShowSecret(false); setRemoteGroups(null); setDialogOpen(true); }; const handleFetchGroups = async () => { if (!editingServer) { toast.error("请先保存连接后再拉取分组"); return; } setIsLoadingGroups(true); try { const data = await fetchSub2APIServerGroups(editingServer.id); setRemoteGroups(data.groups); if (data.groups.length === 0) { toast.message("远端没有配置分组"); } else { toast.success(`读取到 ${data.groups.length} 个分组`); } } catch (error) { toast.error(error instanceof Error ? error.message : "拉取分组失败"); } finally { setIsLoadingGroups(false); } }; const handleSave = async () => { if (!formBaseUrl.trim()) { toast.error("请输入 Sub2API 地址"); return; } if (authMode === "password") { if (!formEmail.trim()) { toast.error("请输入管理员邮箱"); return; } if (!editingServer && !formPassword.trim()) { toast.error("请输入管理员密码"); return; } } else if (!editingServer && !formApiKey.trim()) { toast.error("请输入 Admin API Key"); return; } setIsSaving(true); try { if (editingServer) { const updates: Parameters[1] = { name: formName.trim(), base_url: formBaseUrl.trim(), group_id: formGroupId.trim(), }; if (authMode === "password") { updates.email = formEmail.trim(); if (formPassword.trim()) { updates.password = formPassword.trim(); } updates.api_key = ""; } else { if (formApiKey.trim()) { updates.api_key = formApiKey.trim(); } updates.email = ""; updates.password = ""; } const data = await updateSub2APIServer(editingServer.id, updates); setServers(data.servers); toast.success("连接已更新"); } else { const data = await createSub2APIServer({ name: formName.trim(), base_url: formBaseUrl.trim(), email: authMode === "password" ? formEmail.trim() : "", password: authMode === "password" ? formPassword.trim() : "", api_key: authMode === "api_key" ? formApiKey.trim() : "", group_id: formGroupId.trim(), }); setServers(data.servers); toast.success("连接已添加"); } setDialogOpen(false); } catch (error) { toast.error(error instanceof Error ? error.message : "保存失败"); } finally { setIsSaving(false); } }; const handleDelete = async (server: Sub2APIServer) => { setDeletingId(server.id); try { const data = await deleteSub2APIServer(server.id); setServers(data.servers); toast.success("连接已删除"); } catch (error) { toast.error(error instanceof Error ? error.message : "删除失败"); } finally { setDeletingId(null); } }; const handleBrowseAccounts = async (server: Sub2APIServer) => { setLoadingAccountsId(server.id); try { const data = await fetchSub2APIServerAccounts(server.id); const accounts = normalizeAccounts(data.accounts); setBrowserServer(server); setRemoteAccounts(accounts); setSelectedIds([]); setAccountQuery(""); setAccountPage(1); setBrowserOpen(true); toast.success(`读取成功,共 ${accounts.length} 个 OpenAI 账号`); } catch (error) { toast.error(error instanceof Error ? error.message : "读取 Sub2API 账号失败"); } finally { setLoadingAccountsId(null); } }; const filteredAccounts = useMemo(() => { const query = accountQuery.trim().toLowerCase(); if (!query) { return remoteAccounts; } return remoteAccounts.filter((item) => { return ( item.email.toLowerCase().includes(query) || item.name.toLowerCase().includes(query) || item.plan_type.toLowerCase().includes(query) || item.id.toLowerCase().includes(query) ); }); }, [accountQuery, remoteAccounts]); const currentPageSize = Number(pageSize); const accountPageCount = Math.max(1, Math.ceil(filteredAccounts.length / currentPageSize)); const safeAccountPage = Math.min(accountPage, accountPageCount); const pagedAccounts = filteredAccounts.slice( (safeAccountPage - 1) * currentPageSize, safeAccountPage * currentPageSize, ); const allFilteredSelected = filteredAccounts.length > 0 && filteredAccounts.every((item) => selectedIds.includes(item.id)); const toggleAccount = (id: string, checked: boolean) => { setSelectedIds((prev) => { if (checked) { return Array.from(new Set([...prev, id])); } return prev.filter((item) => item !== id); }); }; const handleToggleSelectAllFiltered = (checked: boolean) => { if (checked) { setSelectedIds(Array.from(new Set([...selectedIds, ...filteredAccounts.map((item) => item.id)]))); return; } const filteredSet = new Set(filteredAccounts.map((item) => item.id)); setSelectedIds((prev) => prev.filter((id) => !filteredSet.has(id))); }; const handleStartImport = async () => { if (!browserServer) { return; } if (selectedIds.length === 0) { toast.error("请先选择要导入的账号"); return; } setIsStartingImport(true); try { const result = await startSub2APIImport(browserServer.id, selectedIds); setServers((prev) => prev.map((server) => server.id === browserServer.id ? { ...server, import_job: result.import_job } : server, ), ); setBrowserOpen(false); toast.success("导入任务已启动"); } catch (error) { toast.error(error instanceof Error ? error.message : "启动导入失败"); } finally { setIsStartingImport(false); } }; return ( <>

Sub2API 连接管理

配置 Sub2API 服务器后,可查询其中的 OpenAI OAuth 账号并批量导入本地号池。

{servers.length > 0 ? {servers.length} 个连接 : null}
{isLoading ? (
) : servers.length === 0 ? (

暂无 Sub2API 连接

点击「添加连接」保存你的 Sub2API 信息。

) : (
{servers.map((server) => { const isBusy = deletingId === server.id || loadingAccountsId === server.id; const importJob = server.import_job ?? null; return (
{server.name || server.base_url}
{server.base_url} {server.email ? ` · ${server.email}` : server.has_api_key ? " · API Key" : ""} {server.group_id ? ` · 分组 ${server.group_id}` : " · 全部分组"}
{importJob ? (
导入任务
{(() => { const progress = importJob.total > 0 ? Math.round((importJob.completed / importJob.total) * 100) : 0; return (
状态 {importJob.status},已处理 {importJob.completed}/{importJob.total}
任务 {importJob.job_id.slice(0, 8)} · {importJob.created_at}
{progress}%
新增 {importJob.added} 跳过 {importJob.skipped} 刷新 {importJob.refreshed} 失败 {importJob.failed}
); })()}
) : null}
); })}
)}

使用说明

  • 输入 Sub2API 地址和管理员账户(或 Admin API Key),保存为一个连接。
  • 点击某个连接的「同步」会拉取其中 platform=openai 且 type=oauth 的账号列表。
  • 勾选需要的账号后后端会并发拉取 access_token,自动导入本地号池并刷新状态。
  • 仅会读取 sub2api 凭据中的 access_token;refresh_token 等字段不会写入本地。
{editingServer ? "编辑连接" : "添加连接"} {editingServer ? "修改 Sub2API 连接信息" : "添加一个新的 Sub2API 连接"}
setFormName(event.target.value)} placeholder="例如:自建 sub2api" className="h-11 rounded-xl border-stone-200 bg-white" />
setFormBaseUrl(event.target.value)} placeholder="http://your-sub2api-host:8080" className="h-11 rounded-xl border-stone-200 bg-white" />
{authMode === "password" ? ( <>
setFormEmail(event.target.value)} placeholder="admin@example.com" className="h-11 rounded-xl border-stone-200 bg-white" />
setFormPassword(event.target.value)} placeholder={editingServer ? "留空则不修改密码" : "管理员密码"} className="h-11 rounded-xl border-stone-200 bg-white pr-10" />
) : (
setFormApiKey(event.target.value)} placeholder={editingServer ? "留空则不修改密钥" : "Sub2API Admin API Key"} className="h-11 rounded-xl border-stone-200 bg-white pr-10" />
)}
{remoteGroups && remoteGroups.length > 0 ? ( ) : ( setFormGroupId(event.target.value)} placeholder="留空则同步所有分组;或填写分组 ID / ungrouped" className="h-11 rounded-xl border-stone-200 bg-white" /> )} {editingServer ? (
同步时会用分组 ID 过滤,留空 = 同步所有 OpenAI OAuth 账号。
) : (
添加完连接后可在编辑对话框里点「拉取分组」选择具体分组。
)}
选择要导入的账号 {browserServer ? `来自 ${browserServer.name || browserServer.base_url}` : "Sub2API 上的 OpenAI OAuth 账号"}
{ setAccountQuery(event.target.value); setAccountPage(1); }} placeholder="搜索邮箱、套餐或名称" className="h-10 rounded-xl border-stone-200 bg-white pl-10" />
handleToggleSelectAllFiltered(Boolean(checked))} /> 筛选结果 {filteredAccounts.length} 个
已选 {selectedIds.length} 个
{pagedAccounts.length === 0 ? (
没有匹配的账号
) : (
{pagedAccounts.map((item) => ( ))}
)}
第 {filteredAccounts.length === 0 ? 0 : (safeAccountPage - 1) * currentPageSize + 1} -{" "} {Math.min(safeAccountPage * currentPageSize, filteredAccounts.length)} 条,共 {filteredAccounts.length} 条
{safeAccountPage}/{accountPageCount}
); }