dsh-plugin-image-workbench 0.0.0-stage → 1.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +258 -0
- package/README.md +260 -2
- package/client.js +241 -0
- package/cordis.patch.yml +14 -0
- package/host.js +692 -0
- package/install.mjs +333 -0
- package/package.json +58 -4
- package/scripts/fetch-style-images.mjs +104 -0
- package/scripts/vendor-snapshot.py +154 -0
- package/server/access.py +26 -0
- package/server/app.py +1329 -0
- package/server/catalog.json +1 -0
- package/server/catalog.py +147 -0
- package/server/composer.py +391 -0
- package/server/drafts.py +158 -0
- package/server/history.py +575 -0
- package/server/imaging.py +172 -0
- package/server/pool.py +1335 -0
- package/server/pool_admin.py +921 -0
- package/server/pool_service.py +1073 -0
- package/server/requirements.txt +6 -0
- package/server/smoke_check.py +402 -0
- package/server/static/access.html +202 -0
- package/server/static/index.html +4725 -0
- package/vendor/chatgpt2api/README.md +13 -0
- package/vendor/chatgpt2api/SOURCE.json +11 -0
- package/vendor/chatgpt2api/runtime/uv.exe +0 -0
- package/vendor/chatgpt2api/source/.env.example +82 -0
- package/vendor/chatgpt2api/source/.python-version +1 -0
- package/vendor/chatgpt2api/source/LICENSE +21 -0
- package/vendor/chatgpt2api/source/README.md +391 -0
- package/vendor/chatgpt2api/source/VERSION +1 -0
- package/vendor/chatgpt2api/source/api/__init__.py +2 -0
- package/vendor/chatgpt2api/source/api/accounts.py +537 -0
- package/vendor/chatgpt2api/source/api/ai.py +217 -0
- package/vendor/chatgpt2api/source/api/app.py +62 -0
- package/vendor/chatgpt2api/source/api/errors.py +46 -0
- package/vendor/chatgpt2api/source/api/image_inputs.py +309 -0
- package/vendor/chatgpt2api/source/api/image_tasks.py +120 -0
- package/vendor/chatgpt2api/source/api/support.py +134 -0
- package/vendor/chatgpt2api/source/api/system.py +365 -0
- package/vendor/chatgpt2api/source/assets/atlascloud.svg +1 -0
- package/vendor/chatgpt2api/source/assets/ipwo.png +0 -0
- package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -0
- package/vendor/chatgpt2api/source/config.default.json +108 -0
- package/vendor/chatgpt2api/source/main.py +9 -0
- package/vendor/chatgpt2api/source/pyproject.toml +27 -0
- package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -0
- package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -0
- package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -0
- package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -0
- package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -0
- package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -0
- package/vendor/chatgpt2api/source/services/__init__.py +0 -0
- package/vendor/chatgpt2api/source/services/account_service.py +1757 -0
- package/vendor/chatgpt2api/source/services/auth_service.py +253 -0
- package/vendor/chatgpt2api/source/services/backup_service.py +671 -0
- package/vendor/chatgpt2api/source/services/config.py +667 -0
- package/vendor/chatgpt2api/source/services/content_filter.py +242 -0
- package/vendor/chatgpt2api/source/services/cpa_service.py +315 -0
- package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -0
- package/vendor/chatgpt2api/source/services/image_service.py +376 -0
- package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -0
- package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -0
- package/vendor/chatgpt2api/source/services/image_task_service.py +550 -0
- package/vendor/chatgpt2api/source/services/log_service.py +337 -0
- package/vendor/chatgpt2api/source/services/model_service.py +182 -0
- package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -0
- package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -0
- package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -0
- package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -0
- package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -0
- package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -0
- package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -0
- package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -0
- package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -0
- package/vendor/chatgpt2api/source/services/proxy_service.py +648 -0
- package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -0
- package/vendor/chatgpt2api/source/services/storage/base.py +38 -0
- package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -0
- package/vendor/chatgpt2api/source/services/storage/factory.py +104 -0
- package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -0
- package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -0
- package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -0
- package/vendor/chatgpt2api/source/utils/__init__.py +0 -0
- package/vendor/chatgpt2api/source/utils/helper.py +469 -0
- package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -0
- package/vendor/chatgpt2api/source/utils/log.py +110 -0
- package/vendor/chatgpt2api/source/utils/pkce.py +19 -0
- package/vendor/chatgpt2api/source/utils/pow.py +207 -0
- package/vendor/chatgpt2api/source/utils/sentinel.py +159 -0
- package/vendor/chatgpt2api/source/utils/turnstile.py +184 -0
- package/vendor/chatgpt2api/source/uv.lock +904 -0
- package/vendor/chatgpt2api/source/web/bun.lock +1501 -0
- package/vendor/chatgpt2api/source/web/components.json +24 -0
- package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -0
- package/vendor/chatgpt2api/source/web/next.config.ts +40 -0
- package/vendor/chatgpt2api/source/web/package.json +58 -0
- package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -0
- package/vendor/chatgpt2api/source/web/public/github.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/next.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/openai.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/vercel.svg +1 -0
- package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -0
- package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -0
- package/vendor/chatgpt2api/source/web/src/app/favicon.ico +0 -0
- package/vendor/chatgpt2api/source/web/src/app/globals.css +231 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -0
- package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -0
- package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -0
- package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -0
- package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -0
- package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -0
- package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -0
- package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -0
- package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -0
- package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -0
- package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -0
- package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -0
- package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -0
- package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -0
- package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -0
- package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -0
- package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -0
- package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -0
- package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -0
- package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -0
- package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -0
- package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -0
- package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -0
- package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -0
- package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -0
- package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -0
- package/vendor/chatgpt2api/source/web/tsconfig.json +42 -0
|
@@ -0,0 +1,886 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
Eye,
|
|
6
|
+
EyeOff,
|
|
7
|
+
Import,
|
|
8
|
+
Layers,
|
|
9
|
+
Link2,
|
|
10
|
+
LoaderCircle,
|
|
11
|
+
Mail,
|
|
12
|
+
Pencil,
|
|
13
|
+
Plus,
|
|
14
|
+
RefreshCcw,
|
|
15
|
+
Save,
|
|
16
|
+
Search,
|
|
17
|
+
ServerCog,
|
|
18
|
+
Trash2,
|
|
19
|
+
Unplug,
|
|
20
|
+
} from "lucide-react";
|
|
21
|
+
import { toast } from "sonner";
|
|
22
|
+
|
|
23
|
+
import { Badge } from "@/components/ui/badge";
|
|
24
|
+
import { Button } from "@/components/ui/button";
|
|
25
|
+
import { Card, CardContent } from "@/components/ui/card";
|
|
26
|
+
import { Checkbox } from "@/components/ui/checkbox";
|
|
27
|
+
import {
|
|
28
|
+
Dialog,
|
|
29
|
+
DialogContent,
|
|
30
|
+
DialogDescription,
|
|
31
|
+
DialogFooter,
|
|
32
|
+
DialogHeader,
|
|
33
|
+
DialogTitle,
|
|
34
|
+
} from "@/components/ui/dialog";
|
|
35
|
+
import { Input } from "@/components/ui/input";
|
|
36
|
+
import {
|
|
37
|
+
Select,
|
|
38
|
+
SelectContent,
|
|
39
|
+
SelectItem,
|
|
40
|
+
SelectTrigger,
|
|
41
|
+
SelectValue,
|
|
42
|
+
} from "@/components/ui/select";
|
|
43
|
+
import {
|
|
44
|
+
createSub2APIServer,
|
|
45
|
+
deleteSub2APIServer,
|
|
46
|
+
fetchSub2APIServerAccounts,
|
|
47
|
+
fetchSub2APIServerGroups,
|
|
48
|
+
fetchSub2APIServers,
|
|
49
|
+
startSub2APIImport,
|
|
50
|
+
updateSub2APIServer,
|
|
51
|
+
type Sub2APIRemoteAccount,
|
|
52
|
+
type Sub2APIRemoteGroup,
|
|
53
|
+
type Sub2APIServer,
|
|
54
|
+
} from "@/lib/api";
|
|
55
|
+
|
|
56
|
+
const PAGE_SIZE_OPTIONS = ["50", "100", "200"] as const;
|
|
57
|
+
|
|
58
|
+
type AuthMode = "password" | "api_key";
|
|
59
|
+
|
|
60
|
+
function normalizeAccounts(items: Sub2APIRemoteAccount[]) {
|
|
61
|
+
const seen = new Set<string>();
|
|
62
|
+
const accounts: Sub2APIRemoteAccount[] = [];
|
|
63
|
+
for (const item of items) {
|
|
64
|
+
const id = String(item.id || "").trim();
|
|
65
|
+
if (!id || seen.has(id)) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
seen.add(id);
|
|
69
|
+
accounts.push({
|
|
70
|
+
id,
|
|
71
|
+
name: String(item.name || "").trim(),
|
|
72
|
+
email: String(item.email || "").trim(),
|
|
73
|
+
plan_type: String(item.plan_type || "").trim(),
|
|
74
|
+
status: String(item.status || "").trim(),
|
|
75
|
+
expires_at: String(item.expires_at || "").trim(),
|
|
76
|
+
has_refresh_token: Boolean(item.has_refresh_token),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return accounts;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function Sub2APIConnections() {
|
|
83
|
+
const didLoadRef = useRef(false);
|
|
84
|
+
const pollTimerRef = useRef<number | null>(null);
|
|
85
|
+
|
|
86
|
+
const [servers, setServers] = useState<Sub2APIServer[]>([]);
|
|
87
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
88
|
+
|
|
89
|
+
const [dialogOpen, setDialogOpen] = useState(false);
|
|
90
|
+
const [editingServer, setEditingServer] = useState<Sub2APIServer | null>(null);
|
|
91
|
+
const [formName, setFormName] = useState("");
|
|
92
|
+
const [formBaseUrl, setFormBaseUrl] = useState("");
|
|
93
|
+
const [formEmail, setFormEmail] = useState("");
|
|
94
|
+
const [formPassword, setFormPassword] = useState("");
|
|
95
|
+
const [formApiKey, setFormApiKey] = useState("");
|
|
96
|
+
const [formGroupId, setFormGroupId] = useState("");
|
|
97
|
+
const [authMode, setAuthMode] = useState<AuthMode>("password");
|
|
98
|
+
const [showSecret, setShowSecret] = useState(false);
|
|
99
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
100
|
+
|
|
101
|
+
const [remoteGroups, setRemoteGroups] = useState<Sub2APIRemoteGroup[] | null>(null);
|
|
102
|
+
const [isLoadingGroups, setIsLoadingGroups] = useState(false);
|
|
103
|
+
|
|
104
|
+
const [deletingId, setDeletingId] = useState<string | null>(null);
|
|
105
|
+
const [loadingAccountsId, setLoadingAccountsId] = useState<string | null>(null);
|
|
106
|
+
|
|
107
|
+
const [browserOpen, setBrowserOpen] = useState(false);
|
|
108
|
+
const [browserServer, setBrowserServer] = useState<Sub2APIServer | null>(null);
|
|
109
|
+
const [remoteAccounts, setRemoteAccounts] = useState<Sub2APIRemoteAccount[]>([]);
|
|
110
|
+
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
111
|
+
const [accountQuery, setAccountQuery] = useState("");
|
|
112
|
+
const [accountPage, setAccountPage] = useState(1);
|
|
113
|
+
const [pageSize, setPageSize] = useState<(typeof PAGE_SIZE_OPTIONS)[number]>("100");
|
|
114
|
+
const [isStartingImport, setIsStartingImport] = useState(false);
|
|
115
|
+
|
|
116
|
+
const loadServers = async () => {
|
|
117
|
+
setIsLoading(true);
|
|
118
|
+
try {
|
|
119
|
+
const data = await fetchSub2APIServers();
|
|
120
|
+
setServers(data.servers);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
toast.error(error instanceof Error ? error.message : "加载 Sub2API 连接失败");
|
|
123
|
+
} finally {
|
|
124
|
+
setIsLoading(false);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (didLoadRef.current) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
didLoadRef.current = true;
|
|
133
|
+
void loadServers();
|
|
134
|
+
}, []);
|
|
135
|
+
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
const hasRunningJobs = servers.some(
|
|
138
|
+
(server) => server.import_job?.status === "pending" || server.import_job?.status === "running",
|
|
139
|
+
);
|
|
140
|
+
if (!hasRunningJobs) {
|
|
141
|
+
if (pollTimerRef.current !== null) {
|
|
142
|
+
window.clearInterval(pollTimerRef.current);
|
|
143
|
+
pollTimerRef.current = null;
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
pollTimerRef.current = window.setInterval(() => {
|
|
149
|
+
void fetchSub2APIServers()
|
|
150
|
+
.then((data) => {
|
|
151
|
+
setServers(data.servers);
|
|
152
|
+
})
|
|
153
|
+
.catch((error) => {
|
|
154
|
+
if (pollTimerRef.current !== null) {
|
|
155
|
+
window.clearInterval(pollTimerRef.current);
|
|
156
|
+
pollTimerRef.current = null;
|
|
157
|
+
}
|
|
158
|
+
toast.error(error instanceof Error ? error.message : "查询导入进度失败");
|
|
159
|
+
});
|
|
160
|
+
}, 1500);
|
|
161
|
+
|
|
162
|
+
return () => {
|
|
163
|
+
if (pollTimerRef.current !== null) {
|
|
164
|
+
window.clearInterval(pollTimerRef.current);
|
|
165
|
+
pollTimerRef.current = null;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
}, [servers]);
|
|
169
|
+
|
|
170
|
+
const openAddDialog = () => {
|
|
171
|
+
setEditingServer(null);
|
|
172
|
+
setFormName("");
|
|
173
|
+
setFormBaseUrl("");
|
|
174
|
+
setFormEmail("");
|
|
175
|
+
setFormPassword("");
|
|
176
|
+
setFormApiKey("");
|
|
177
|
+
setFormGroupId("");
|
|
178
|
+
setAuthMode("password");
|
|
179
|
+
setShowSecret(false);
|
|
180
|
+
setRemoteGroups(null);
|
|
181
|
+
setDialogOpen(true);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const openEditDialog = (server: Sub2APIServer) => {
|
|
185
|
+
setEditingServer(server);
|
|
186
|
+
setFormName(server.name);
|
|
187
|
+
setFormBaseUrl(server.base_url);
|
|
188
|
+
setFormEmail(server.email);
|
|
189
|
+
setFormPassword("");
|
|
190
|
+
setFormApiKey("");
|
|
191
|
+
setFormGroupId(server.group_id || "");
|
|
192
|
+
setAuthMode(server.has_api_key ? "api_key" : "password");
|
|
193
|
+
setShowSecret(false);
|
|
194
|
+
setRemoteGroups(null);
|
|
195
|
+
setDialogOpen(true);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const handleFetchGroups = async () => {
|
|
199
|
+
if (!editingServer) {
|
|
200
|
+
toast.error("请先保存连接后再拉取分组");
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
setIsLoadingGroups(true);
|
|
204
|
+
try {
|
|
205
|
+
const data = await fetchSub2APIServerGroups(editingServer.id);
|
|
206
|
+
setRemoteGroups(data.groups);
|
|
207
|
+
if (data.groups.length === 0) {
|
|
208
|
+
toast.message("远端没有配置分组");
|
|
209
|
+
} else {
|
|
210
|
+
toast.success(`读取到 ${data.groups.length} 个分组`);
|
|
211
|
+
}
|
|
212
|
+
} catch (error) {
|
|
213
|
+
toast.error(error instanceof Error ? error.message : "拉取分组失败");
|
|
214
|
+
} finally {
|
|
215
|
+
setIsLoadingGroups(false);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const handleSave = async () => {
|
|
220
|
+
if (!formBaseUrl.trim()) {
|
|
221
|
+
toast.error("请输入 Sub2API 地址");
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (authMode === "password") {
|
|
225
|
+
if (!formEmail.trim()) {
|
|
226
|
+
toast.error("请输入管理员邮箱");
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (!editingServer && !formPassword.trim()) {
|
|
230
|
+
toast.error("请输入管理员密码");
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
} else if (!editingServer && !formApiKey.trim()) {
|
|
234
|
+
toast.error("请输入 Admin API Key");
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
setIsSaving(true);
|
|
239
|
+
try {
|
|
240
|
+
if (editingServer) {
|
|
241
|
+
const updates: Parameters<typeof updateSub2APIServer>[1] = {
|
|
242
|
+
name: formName.trim(),
|
|
243
|
+
base_url: formBaseUrl.trim(),
|
|
244
|
+
group_id: formGroupId.trim(),
|
|
245
|
+
};
|
|
246
|
+
if (authMode === "password") {
|
|
247
|
+
updates.email = formEmail.trim();
|
|
248
|
+
if (formPassword.trim()) {
|
|
249
|
+
updates.password = formPassword.trim();
|
|
250
|
+
}
|
|
251
|
+
updates.api_key = "";
|
|
252
|
+
} else {
|
|
253
|
+
if (formApiKey.trim()) {
|
|
254
|
+
updates.api_key = formApiKey.trim();
|
|
255
|
+
}
|
|
256
|
+
updates.email = "";
|
|
257
|
+
updates.password = "";
|
|
258
|
+
}
|
|
259
|
+
const data = await updateSub2APIServer(editingServer.id, updates);
|
|
260
|
+
setServers(data.servers);
|
|
261
|
+
toast.success("连接已更新");
|
|
262
|
+
} else {
|
|
263
|
+
const data = await createSub2APIServer({
|
|
264
|
+
name: formName.trim(),
|
|
265
|
+
base_url: formBaseUrl.trim(),
|
|
266
|
+
email: authMode === "password" ? formEmail.trim() : "",
|
|
267
|
+
password: authMode === "password" ? formPassword.trim() : "",
|
|
268
|
+
api_key: authMode === "api_key" ? formApiKey.trim() : "",
|
|
269
|
+
group_id: formGroupId.trim(),
|
|
270
|
+
});
|
|
271
|
+
setServers(data.servers);
|
|
272
|
+
toast.success("连接已添加");
|
|
273
|
+
}
|
|
274
|
+
setDialogOpen(false);
|
|
275
|
+
} catch (error) {
|
|
276
|
+
toast.error(error instanceof Error ? error.message : "保存失败");
|
|
277
|
+
} finally {
|
|
278
|
+
setIsSaving(false);
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const handleDelete = async (server: Sub2APIServer) => {
|
|
283
|
+
setDeletingId(server.id);
|
|
284
|
+
try {
|
|
285
|
+
const data = await deleteSub2APIServer(server.id);
|
|
286
|
+
setServers(data.servers);
|
|
287
|
+
toast.success("连接已删除");
|
|
288
|
+
} catch (error) {
|
|
289
|
+
toast.error(error instanceof Error ? error.message : "删除失败");
|
|
290
|
+
} finally {
|
|
291
|
+
setDeletingId(null);
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const handleBrowseAccounts = async (server: Sub2APIServer) => {
|
|
296
|
+
setLoadingAccountsId(server.id);
|
|
297
|
+
try {
|
|
298
|
+
const data = await fetchSub2APIServerAccounts(server.id);
|
|
299
|
+
const accounts = normalizeAccounts(data.accounts);
|
|
300
|
+
setBrowserServer(server);
|
|
301
|
+
setRemoteAccounts(accounts);
|
|
302
|
+
setSelectedIds([]);
|
|
303
|
+
setAccountQuery("");
|
|
304
|
+
setAccountPage(1);
|
|
305
|
+
setBrowserOpen(true);
|
|
306
|
+
toast.success(`读取成功,共 ${accounts.length} 个 OpenAI 账号`);
|
|
307
|
+
} catch (error) {
|
|
308
|
+
toast.error(error instanceof Error ? error.message : "读取 Sub2API 账号失败");
|
|
309
|
+
} finally {
|
|
310
|
+
setLoadingAccountsId(null);
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const filteredAccounts = useMemo(() => {
|
|
315
|
+
const query = accountQuery.trim().toLowerCase();
|
|
316
|
+
if (!query) {
|
|
317
|
+
return remoteAccounts;
|
|
318
|
+
}
|
|
319
|
+
return remoteAccounts.filter((item) => {
|
|
320
|
+
return (
|
|
321
|
+
item.email.toLowerCase().includes(query) ||
|
|
322
|
+
item.name.toLowerCase().includes(query) ||
|
|
323
|
+
item.plan_type.toLowerCase().includes(query) ||
|
|
324
|
+
item.id.toLowerCase().includes(query)
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
}, [accountQuery, remoteAccounts]);
|
|
328
|
+
|
|
329
|
+
const currentPageSize = Number(pageSize);
|
|
330
|
+
const accountPageCount = Math.max(1, Math.ceil(filteredAccounts.length / currentPageSize));
|
|
331
|
+
const safeAccountPage = Math.min(accountPage, accountPageCount);
|
|
332
|
+
const pagedAccounts = filteredAccounts.slice(
|
|
333
|
+
(safeAccountPage - 1) * currentPageSize,
|
|
334
|
+
safeAccountPage * currentPageSize,
|
|
335
|
+
);
|
|
336
|
+
const allFilteredSelected =
|
|
337
|
+
filteredAccounts.length > 0 && filteredAccounts.every((item) => selectedIds.includes(item.id));
|
|
338
|
+
|
|
339
|
+
const toggleAccount = (id: string, checked: boolean) => {
|
|
340
|
+
setSelectedIds((prev) => {
|
|
341
|
+
if (checked) {
|
|
342
|
+
return Array.from(new Set([...prev, id]));
|
|
343
|
+
}
|
|
344
|
+
return prev.filter((item) => item !== id);
|
|
345
|
+
});
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const handleToggleSelectAllFiltered = (checked: boolean) => {
|
|
349
|
+
if (checked) {
|
|
350
|
+
setSelectedIds(Array.from(new Set([...selectedIds, ...filteredAccounts.map((item) => item.id)])));
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const filteredSet = new Set(filteredAccounts.map((item) => item.id));
|
|
354
|
+
setSelectedIds((prev) => prev.filter((id) => !filteredSet.has(id)));
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const handleStartImport = async () => {
|
|
358
|
+
if (!browserServer) {
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (selectedIds.length === 0) {
|
|
362
|
+
toast.error("请先选择要导入的账号");
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
setIsStartingImport(true);
|
|
367
|
+
try {
|
|
368
|
+
const result = await startSub2APIImport(browserServer.id, selectedIds);
|
|
369
|
+
setServers((prev) =>
|
|
370
|
+
prev.map((server) =>
|
|
371
|
+
server.id === browserServer.id ? { ...server, import_job: result.import_job } : server,
|
|
372
|
+
),
|
|
373
|
+
);
|
|
374
|
+
setBrowserOpen(false);
|
|
375
|
+
toast.success("导入任务已启动");
|
|
376
|
+
} catch (error) {
|
|
377
|
+
toast.error(error instanceof Error ? error.message : "启动导入失败");
|
|
378
|
+
} finally {
|
|
379
|
+
setIsStartingImport(false);
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
return (
|
|
384
|
+
<>
|
|
385
|
+
<Card className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
|
|
386
|
+
<CardContent className="space-y-6 p-6">
|
|
387
|
+
<div className="flex items-start justify-between">
|
|
388
|
+
<div className="flex items-center gap-3">
|
|
389
|
+
<div className="flex size-10 items-center justify-center rounded-xl bg-stone-100">
|
|
390
|
+
<ServerCog className="size-5 text-stone-600" />
|
|
391
|
+
</div>
|
|
392
|
+
<div>
|
|
393
|
+
<h2 className="text-lg font-semibold tracking-tight">Sub2API 连接管理</h2>
|
|
394
|
+
<p className="text-sm text-stone-500">
|
|
395
|
+
配置 Sub2API 服务器后,可查询其中的 OpenAI OAuth 账号并批量导入本地号池。
|
|
396
|
+
</p>
|
|
397
|
+
</div>
|
|
398
|
+
</div>
|
|
399
|
+
<div className="flex items-center gap-2">
|
|
400
|
+
{servers.length > 0 ? <Badge className="rounded-md px-2.5 py-1">{servers.length} 个连接</Badge> : null}
|
|
401
|
+
<Button
|
|
402
|
+
className="h-9 rounded-xl bg-stone-950 px-4 text-white hover:bg-stone-800"
|
|
403
|
+
onClick={openAddDialog}
|
|
404
|
+
>
|
|
405
|
+
<Plus className="size-4" />
|
|
406
|
+
添加连接
|
|
407
|
+
</Button>
|
|
408
|
+
</div>
|
|
409
|
+
</div>
|
|
410
|
+
|
|
411
|
+
{isLoading ? (
|
|
412
|
+
<div className="flex items-center justify-center py-10">
|
|
413
|
+
<LoaderCircle className="size-5 animate-spin text-stone-400" />
|
|
414
|
+
</div>
|
|
415
|
+
) : servers.length === 0 ? (
|
|
416
|
+
<div className="flex flex-col items-center justify-center gap-3 rounded-xl bg-stone-50 px-6 py-10 text-center">
|
|
417
|
+
<ServerCog className="size-8 text-stone-300" />
|
|
418
|
+
<div className="space-y-1">
|
|
419
|
+
<p className="text-sm font-medium text-stone-600">暂无 Sub2API 连接</p>
|
|
420
|
+
<p className="text-sm text-stone-400">点击「添加连接」保存你的 Sub2API 信息。</p>
|
|
421
|
+
</div>
|
|
422
|
+
</div>
|
|
423
|
+
) : (
|
|
424
|
+
<div className="space-y-3">
|
|
425
|
+
{servers.map((server) => {
|
|
426
|
+
const isBusy = deletingId === server.id || loadingAccountsId === server.id;
|
|
427
|
+
const importJob = server.import_job ?? null;
|
|
428
|
+
return (
|
|
429
|
+
<div
|
|
430
|
+
key={server.id}
|
|
431
|
+
className="flex flex-col gap-3 rounded-xl border border-stone-200 bg-white px-4 py-3"
|
|
432
|
+
>
|
|
433
|
+
<div className="flex items-center justify-between gap-3">
|
|
434
|
+
<div className="min-w-0">
|
|
435
|
+
<div className="text-sm font-medium text-stone-800">{server.name || server.base_url}</div>
|
|
436
|
+
<div className="truncate text-xs text-stone-400">
|
|
437
|
+
{server.base_url}
|
|
438
|
+
{server.email ? ` · ${server.email}` : server.has_api_key ? " · API Key" : ""}
|
|
439
|
+
{server.group_id ? ` · 分组 ${server.group_id}` : " · 全部分组"}
|
|
440
|
+
</div>
|
|
441
|
+
</div>
|
|
442
|
+
<div className="flex items-center gap-1">
|
|
443
|
+
<button
|
|
444
|
+
type="button"
|
|
445
|
+
className="rounded-lg p-2 text-stone-400 transition hover:bg-stone-100 hover:text-stone-700"
|
|
446
|
+
onClick={() => openEditDialog(server)}
|
|
447
|
+
disabled={isBusy}
|
|
448
|
+
title="编辑"
|
|
449
|
+
>
|
|
450
|
+
<Pencil className="size-4" />
|
|
451
|
+
</button>
|
|
452
|
+
<button
|
|
453
|
+
type="button"
|
|
454
|
+
className="rounded-lg p-2 text-stone-400 transition hover:bg-rose-50 hover:text-rose-500"
|
|
455
|
+
onClick={() => void handleDelete(server)}
|
|
456
|
+
disabled={isBusy}
|
|
457
|
+
title="删除"
|
|
458
|
+
>
|
|
459
|
+
{deletingId === server.id ? (
|
|
460
|
+
<LoaderCircle className="size-4 animate-spin" />
|
|
461
|
+
) : (
|
|
462
|
+
<Trash2 className="size-4" />
|
|
463
|
+
)}
|
|
464
|
+
</button>
|
|
465
|
+
</div>
|
|
466
|
+
</div>
|
|
467
|
+
|
|
468
|
+
<div className="flex items-center gap-2">
|
|
469
|
+
<Button
|
|
470
|
+
variant="outline"
|
|
471
|
+
className="h-8 rounded-lg border-stone-200 bg-white px-3 text-xs text-stone-600"
|
|
472
|
+
onClick={() => void handleBrowseAccounts(server)}
|
|
473
|
+
disabled={isBusy}
|
|
474
|
+
>
|
|
475
|
+
{loadingAccountsId === server.id ? (
|
|
476
|
+
<LoaderCircle className="size-3.5 animate-spin" />
|
|
477
|
+
) : (
|
|
478
|
+
<Import className="size-3.5" />
|
|
479
|
+
)}
|
|
480
|
+
同步
|
|
481
|
+
</Button>
|
|
482
|
+
</div>
|
|
483
|
+
|
|
484
|
+
{importJob ? (
|
|
485
|
+
<div className="space-y-2 rounded-xl bg-stone-50 px-3 py-3">
|
|
486
|
+
<div className="text-xs font-medium tracking-[0.16em] text-stone-400 uppercase">导入任务</div>
|
|
487
|
+
{(() => {
|
|
488
|
+
const progress =
|
|
489
|
+
importJob.total > 0
|
|
490
|
+
? Math.round((importJob.completed / importJob.total) * 100)
|
|
491
|
+
: 0;
|
|
492
|
+
return (
|
|
493
|
+
<div className="rounded-lg border border-stone-200 bg-white px-3 py-3">
|
|
494
|
+
<div className="flex items-center justify-between gap-3">
|
|
495
|
+
<div className="min-w-0">
|
|
496
|
+
<div className="text-sm font-medium text-stone-700">
|
|
497
|
+
状态 {importJob.status},已处理 {importJob.completed}/{importJob.total}
|
|
498
|
+
</div>
|
|
499
|
+
<div className="truncate text-xs text-stone-400">
|
|
500
|
+
任务 {importJob.job_id.slice(0, 8)} · {importJob.created_at}
|
|
501
|
+
</div>
|
|
502
|
+
</div>
|
|
503
|
+
<Badge
|
|
504
|
+
variant={
|
|
505
|
+
importJob.status === "completed"
|
|
506
|
+
? "success"
|
|
507
|
+
: importJob.status === "failed"
|
|
508
|
+
? "danger"
|
|
509
|
+
: "info"
|
|
510
|
+
}
|
|
511
|
+
className="rounded-md"
|
|
512
|
+
>
|
|
513
|
+
{progress}%
|
|
514
|
+
</Badge>
|
|
515
|
+
</div>
|
|
516
|
+
<div className="mt-3 h-2 overflow-hidden rounded-full bg-stone-200">
|
|
517
|
+
<div
|
|
518
|
+
className="h-full rounded-full bg-stone-900 transition-all"
|
|
519
|
+
style={{ width: `${progress}%` }}
|
|
520
|
+
/>
|
|
521
|
+
</div>
|
|
522
|
+
<div className="mt-2 flex flex-wrap gap-2 text-xs text-stone-500">
|
|
523
|
+
<span>新增 {importJob.added}</span>
|
|
524
|
+
<span>跳过 {importJob.skipped}</span>
|
|
525
|
+
<span>刷新 {importJob.refreshed}</span>
|
|
526
|
+
<span>失败 {importJob.failed}</span>
|
|
527
|
+
</div>
|
|
528
|
+
</div>
|
|
529
|
+
);
|
|
530
|
+
})()}
|
|
531
|
+
</div>
|
|
532
|
+
) : null}
|
|
533
|
+
</div>
|
|
534
|
+
);
|
|
535
|
+
})}
|
|
536
|
+
</div>
|
|
537
|
+
)}
|
|
538
|
+
|
|
539
|
+
<div className="rounded-xl bg-stone-50 px-4 py-3 text-sm leading-6 text-stone-500">
|
|
540
|
+
<p className="font-medium text-stone-600">使用说明</p>
|
|
541
|
+
<ul className="mt-1 list-inside list-disc space-y-0.5">
|
|
542
|
+
<li>输入 Sub2API 地址和管理员账户(或 Admin API Key),保存为一个连接。</li>
|
|
543
|
+
<li>点击某个连接的「同步」会拉取其中 platform=openai 且 type=oauth 的账号列表。</li>
|
|
544
|
+
<li>勾选需要的账号后后端会并发拉取 access_token,自动导入本地号池并刷新状态。</li>
|
|
545
|
+
<li>仅会读取 sub2api 凭据中的 access_token;refresh_token 等字段不会写入本地。</li>
|
|
546
|
+
</ul>
|
|
547
|
+
</div>
|
|
548
|
+
</CardContent>
|
|
549
|
+
</Card>
|
|
550
|
+
|
|
551
|
+
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
|
552
|
+
<DialogContent showCloseButton={false} className="rounded-2xl p-6">
|
|
553
|
+
<DialogHeader className="gap-2">
|
|
554
|
+
<DialogTitle>{editingServer ? "编辑连接" : "添加连接"}</DialogTitle>
|
|
555
|
+
<DialogDescription className="text-sm leading-6">
|
|
556
|
+
{editingServer ? "修改 Sub2API 连接信息" : "添加一个新的 Sub2API 连接"}
|
|
557
|
+
</DialogDescription>
|
|
558
|
+
</DialogHeader>
|
|
559
|
+
<div className="space-y-4">
|
|
560
|
+
<div className="space-y-2">
|
|
561
|
+
<label className="text-sm font-medium text-stone-700">名称(可选)</label>
|
|
562
|
+
<Input
|
|
563
|
+
value={formName}
|
|
564
|
+
onChange={(event) => setFormName(event.target.value)}
|
|
565
|
+
placeholder="例如:自建 sub2api"
|
|
566
|
+
className="h-11 rounded-xl border-stone-200 bg-white"
|
|
567
|
+
/>
|
|
568
|
+
</div>
|
|
569
|
+
<div className="space-y-2">
|
|
570
|
+
<label className="flex items-center gap-1.5 text-sm font-medium text-stone-700">
|
|
571
|
+
<Link2 className="size-3.5" />
|
|
572
|
+
Sub2API 地址
|
|
573
|
+
</label>
|
|
574
|
+
<Input
|
|
575
|
+
value={formBaseUrl}
|
|
576
|
+
onChange={(event) => setFormBaseUrl(event.target.value)}
|
|
577
|
+
placeholder="http://your-sub2api-host:8080"
|
|
578
|
+
className="h-11 rounded-xl border-stone-200 bg-white"
|
|
579
|
+
/>
|
|
580
|
+
</div>
|
|
581
|
+
<div className="space-y-2">
|
|
582
|
+
<label className="text-sm font-medium text-stone-700">认证方式</label>
|
|
583
|
+
<Select value={authMode} onValueChange={(value) => setAuthMode(value as AuthMode)}>
|
|
584
|
+
<SelectTrigger className="h-11 rounded-xl border-stone-200 bg-white">
|
|
585
|
+
<SelectValue />
|
|
586
|
+
</SelectTrigger>
|
|
587
|
+
<SelectContent>
|
|
588
|
+
<SelectItem value="password">管理员邮箱 + 密码</SelectItem>
|
|
589
|
+
<SelectItem value="api_key">Admin API Key</SelectItem>
|
|
590
|
+
</SelectContent>
|
|
591
|
+
</Select>
|
|
592
|
+
</div>
|
|
593
|
+
{authMode === "password" ? (
|
|
594
|
+
<>
|
|
595
|
+
<div className="space-y-2">
|
|
596
|
+
<label className="flex items-center gap-1.5 text-sm font-medium text-stone-700">
|
|
597
|
+
<Mail className="size-3.5" />
|
|
598
|
+
管理员邮箱
|
|
599
|
+
</label>
|
|
600
|
+
<Input
|
|
601
|
+
value={formEmail}
|
|
602
|
+
onChange={(event) => setFormEmail(event.target.value)}
|
|
603
|
+
placeholder="admin@example.com"
|
|
604
|
+
className="h-11 rounded-xl border-stone-200 bg-white"
|
|
605
|
+
/>
|
|
606
|
+
</div>
|
|
607
|
+
<div className="space-y-2">
|
|
608
|
+
<label className="flex items-center gap-1.5 text-sm font-medium text-stone-700">
|
|
609
|
+
<Unplug className="size-3.5" />
|
|
610
|
+
管理员密码
|
|
611
|
+
</label>
|
|
612
|
+
<div className="relative">
|
|
613
|
+
<Input
|
|
614
|
+
type={showSecret ? "text" : "password"}
|
|
615
|
+
value={formPassword}
|
|
616
|
+
onChange={(event) => setFormPassword(event.target.value)}
|
|
617
|
+
placeholder={editingServer ? "留空则不修改密码" : "管理员密码"}
|
|
618
|
+
className="h-11 rounded-xl border-stone-200 bg-white pr-10"
|
|
619
|
+
/>
|
|
620
|
+
<button
|
|
621
|
+
type="button"
|
|
622
|
+
className="absolute top-1/2 right-3 -translate-y-1/2 text-stone-400 transition hover:text-stone-600"
|
|
623
|
+
onClick={() => setShowSecret((prev) => !prev)}
|
|
624
|
+
>
|
|
625
|
+
{showSecret ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
|
626
|
+
</button>
|
|
627
|
+
</div>
|
|
628
|
+
</div>
|
|
629
|
+
</>
|
|
630
|
+
) : (
|
|
631
|
+
<div className="space-y-2">
|
|
632
|
+
<label className="flex items-center gap-1.5 text-sm font-medium text-stone-700">
|
|
633
|
+
<Unplug className="size-3.5" />
|
|
634
|
+
Admin API Key
|
|
635
|
+
</label>
|
|
636
|
+
<div className="relative">
|
|
637
|
+
<Input
|
|
638
|
+
type={showSecret ? "text" : "password"}
|
|
639
|
+
value={formApiKey}
|
|
640
|
+
onChange={(event) => setFormApiKey(event.target.value)}
|
|
641
|
+
placeholder={editingServer ? "留空则不修改密钥" : "Sub2API Admin API Key"}
|
|
642
|
+
className="h-11 rounded-xl border-stone-200 bg-white pr-10"
|
|
643
|
+
/>
|
|
644
|
+
<button
|
|
645
|
+
type="button"
|
|
646
|
+
className="absolute top-1/2 right-3 -translate-y-1/2 text-stone-400 transition hover:text-stone-600"
|
|
647
|
+
onClick={() => setShowSecret((prev) => !prev)}
|
|
648
|
+
>
|
|
649
|
+
{showSecret ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
|
650
|
+
</button>
|
|
651
|
+
</div>
|
|
652
|
+
</div>
|
|
653
|
+
)}
|
|
654
|
+
<div className="space-y-2">
|
|
655
|
+
<label className="flex items-center gap-1.5 text-sm font-medium text-stone-700">
|
|
656
|
+
<Layers className="size-3.5" />
|
|
657
|
+
分组(可选)
|
|
658
|
+
</label>
|
|
659
|
+
{remoteGroups && remoteGroups.length > 0 ? (
|
|
660
|
+
<Select value={formGroupId || "__all__"} onValueChange={(value) => setFormGroupId(value === "__all__" ? "" : value)}>
|
|
661
|
+
<SelectTrigger className="h-11 rounded-xl border-stone-200 bg-white">
|
|
662
|
+
<SelectValue placeholder="选择分组" />
|
|
663
|
+
</SelectTrigger>
|
|
664
|
+
<SelectContent>
|
|
665
|
+
<SelectItem value="__all__">全部分组(不限制)</SelectItem>
|
|
666
|
+
<SelectItem value="ungrouped">未分组</SelectItem>
|
|
667
|
+
{remoteGroups.map((group) => (
|
|
668
|
+
<SelectItem key={group.id} value={group.id}>
|
|
669
|
+
{group.name || `Group ${group.id}`}
|
|
670
|
+
{group.platform ? `(${group.platform})` : ""}
|
|
671
|
+
{group.account_count
|
|
672
|
+
? ` · ${group.active_account_count}/${group.account_count}`
|
|
673
|
+
: ""}
|
|
674
|
+
</SelectItem>
|
|
675
|
+
))}
|
|
676
|
+
</SelectContent>
|
|
677
|
+
</Select>
|
|
678
|
+
) : (
|
|
679
|
+
<Input
|
|
680
|
+
value={formGroupId}
|
|
681
|
+
onChange={(event) => setFormGroupId(event.target.value)}
|
|
682
|
+
placeholder="留空则同步所有分组;或填写分组 ID / ungrouped"
|
|
683
|
+
className="h-11 rounded-xl border-stone-200 bg-white"
|
|
684
|
+
/>
|
|
685
|
+
)}
|
|
686
|
+
{editingServer ? (
|
|
687
|
+
<div className="flex items-center justify-between gap-2 text-xs text-stone-500">
|
|
688
|
+
<span>同步时会用分组 ID 过滤,留空 = 同步所有 OpenAI OAuth 账号。</span>
|
|
689
|
+
<Button
|
|
690
|
+
variant="outline"
|
|
691
|
+
className="h-8 rounded-lg border-stone-200 bg-white px-2 text-xs text-stone-600"
|
|
692
|
+
onClick={() => void handleFetchGroups()}
|
|
693
|
+
disabled={isLoadingGroups}
|
|
694
|
+
>
|
|
695
|
+
{isLoadingGroups ? (
|
|
696
|
+
<LoaderCircle className="size-3.5 animate-spin" />
|
|
697
|
+
) : (
|
|
698
|
+
<RefreshCcw className="size-3.5" />
|
|
699
|
+
)}
|
|
700
|
+
{remoteGroups ? "重新拉取" : "拉取分组"}
|
|
701
|
+
</Button>
|
|
702
|
+
</div>
|
|
703
|
+
) : (
|
|
704
|
+
<div className="text-xs text-stone-500">
|
|
705
|
+
添加完连接后可在编辑对话框里点「拉取分组」选择具体分组。
|
|
706
|
+
</div>
|
|
707
|
+
)}
|
|
708
|
+
</div>
|
|
709
|
+
</div>
|
|
710
|
+
<DialogFooter className="pt-2">
|
|
711
|
+
<Button
|
|
712
|
+
variant="secondary"
|
|
713
|
+
className="h-10 rounded-xl bg-stone-100 px-5 text-stone-700 hover:bg-stone-200"
|
|
714
|
+
onClick={() => setDialogOpen(false)}
|
|
715
|
+
disabled={isSaving}
|
|
716
|
+
>
|
|
717
|
+
取消
|
|
718
|
+
</Button>
|
|
719
|
+
<Button
|
|
720
|
+
className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
|
|
721
|
+
onClick={() => void handleSave()}
|
|
722
|
+
disabled={isSaving}
|
|
723
|
+
>
|
|
724
|
+
{isSaving ? <LoaderCircle className="size-4 animate-spin" /> : <Save className="size-4" />}
|
|
725
|
+
{editingServer ? "保存修改" : "添加"}
|
|
726
|
+
</Button>
|
|
727
|
+
</DialogFooter>
|
|
728
|
+
</DialogContent>
|
|
729
|
+
</Dialog>
|
|
730
|
+
|
|
731
|
+
<Dialog open={browserOpen} onOpenChange={setBrowserOpen}>
|
|
732
|
+
<DialogContent showCloseButton={false} className="max-h-[90vh] max-w-5xl rounded-2xl p-6">
|
|
733
|
+
<DialogHeader className="gap-2">
|
|
734
|
+
<DialogTitle>选择要导入的账号</DialogTitle>
|
|
735
|
+
<DialogDescription className="text-sm leading-6">
|
|
736
|
+
{browserServer ? `来自 ${browserServer.name || browserServer.base_url}` : "Sub2API 上的 OpenAI OAuth 账号"}
|
|
737
|
+
</DialogDescription>
|
|
738
|
+
</DialogHeader>
|
|
739
|
+
|
|
740
|
+
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
|
741
|
+
<div className="relative min-w-[260px]">
|
|
742
|
+
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-stone-400" />
|
|
743
|
+
<Input
|
|
744
|
+
value={accountQuery}
|
|
745
|
+
onChange={(event) => {
|
|
746
|
+
setAccountQuery(event.target.value);
|
|
747
|
+
setAccountPage(1);
|
|
748
|
+
}}
|
|
749
|
+
placeholder="搜索邮箱、套餐或名称"
|
|
750
|
+
className="h-10 rounded-xl border-stone-200 bg-white pl-10"
|
|
751
|
+
/>
|
|
752
|
+
</div>
|
|
753
|
+
<div className="flex items-center gap-2">
|
|
754
|
+
<Select
|
|
755
|
+
value={pageSize}
|
|
756
|
+
onValueChange={(value) => {
|
|
757
|
+
setPageSize(value as (typeof PAGE_SIZE_OPTIONS)[number]);
|
|
758
|
+
setAccountPage(1);
|
|
759
|
+
}}
|
|
760
|
+
>
|
|
761
|
+
<SelectTrigger className="h-10 w-[120px] rounded-xl border-stone-200 bg-white">
|
|
762
|
+
<SelectValue />
|
|
763
|
+
</SelectTrigger>
|
|
764
|
+
<SelectContent>
|
|
765
|
+
{PAGE_SIZE_OPTIONS.map((item) => (
|
|
766
|
+
<SelectItem key={item} value={item}>
|
|
767
|
+
{item} / 页
|
|
768
|
+
</SelectItem>
|
|
769
|
+
))}
|
|
770
|
+
</SelectContent>
|
|
771
|
+
</Select>
|
|
772
|
+
<Button
|
|
773
|
+
variant="outline"
|
|
774
|
+
className="h-10 rounded-xl border-stone-200 bg-white px-4 text-stone-700"
|
|
775
|
+
onClick={() => handleToggleSelectAllFiltered(!allFilteredSelected)}
|
|
776
|
+
>
|
|
777
|
+
{allFilteredSelected ? "取消全选" : "全选筛选结果"}
|
|
778
|
+
</Button>
|
|
779
|
+
</div>
|
|
780
|
+
</div>
|
|
781
|
+
|
|
782
|
+
<div className="rounded-xl border border-stone-200">
|
|
783
|
+
<div className="flex items-center justify-between border-b border-stone-100 px-4 py-3 text-sm text-stone-500">
|
|
784
|
+
<div className="flex items-center gap-3">
|
|
785
|
+
<Checkbox
|
|
786
|
+
checked={allFilteredSelected}
|
|
787
|
+
onCheckedChange={(checked) => handleToggleSelectAllFiltered(Boolean(checked))}
|
|
788
|
+
/>
|
|
789
|
+
<span>筛选结果 {filteredAccounts.length} 个</span>
|
|
790
|
+
</div>
|
|
791
|
+
<span>已选 {selectedIds.length} 个</span>
|
|
792
|
+
</div>
|
|
793
|
+
<div className="max-h-[420px] overflow-auto">
|
|
794
|
+
{pagedAccounts.length === 0 ? (
|
|
795
|
+
<div className="flex items-center justify-center py-12 text-sm text-stone-400">没有匹配的账号</div>
|
|
796
|
+
) : (
|
|
797
|
+
<div className="divide-y divide-stone-100">
|
|
798
|
+
{pagedAccounts.map((item) => (
|
|
799
|
+
<label
|
|
800
|
+
key={item.id}
|
|
801
|
+
className="flex cursor-pointer items-center gap-3 px-4 py-3 hover:bg-stone-50"
|
|
802
|
+
>
|
|
803
|
+
<Checkbox
|
|
804
|
+
checked={selectedIds.includes(item.id)}
|
|
805
|
+
onCheckedChange={(checked) => toggleAccount(item.id, Boolean(checked))}
|
|
806
|
+
/>
|
|
807
|
+
<div className="min-w-0 flex-1">
|
|
808
|
+
<div className="flex items-center gap-2">
|
|
809
|
+
<span className="truncate text-sm font-medium text-stone-700">
|
|
810
|
+
{item.email || item.name || item.id}
|
|
811
|
+
</span>
|
|
812
|
+
{item.plan_type ? (
|
|
813
|
+
<Badge className="rounded-md bg-stone-100 text-stone-600">{item.plan_type}</Badge>
|
|
814
|
+
) : null}
|
|
815
|
+
{item.status ? (
|
|
816
|
+
<Badge
|
|
817
|
+
variant={item.status === "active" ? "success" : "info"}
|
|
818
|
+
className="rounded-md"
|
|
819
|
+
>
|
|
820
|
+
{item.status}
|
|
821
|
+
</Badge>
|
|
822
|
+
) : null}
|
|
823
|
+
</div>
|
|
824
|
+
<div className="truncate text-xs text-stone-400">
|
|
825
|
+
id {item.id}
|
|
826
|
+
{item.expires_at ? ` · 过期 ${item.expires_at}` : ""}
|
|
827
|
+
</div>
|
|
828
|
+
</div>
|
|
829
|
+
</label>
|
|
830
|
+
))}
|
|
831
|
+
</div>
|
|
832
|
+
)}
|
|
833
|
+
</div>
|
|
834
|
+
</div>
|
|
835
|
+
|
|
836
|
+
<div className="flex items-center justify-between text-sm text-stone-500">
|
|
837
|
+
<span>
|
|
838
|
+
第 {filteredAccounts.length === 0 ? 0 : (safeAccountPage - 1) * currentPageSize + 1} -{" "}
|
|
839
|
+
{Math.min(safeAccountPage * currentPageSize, filteredAccounts.length)} 条,共 {filteredAccounts.length} 条
|
|
840
|
+
</span>
|
|
841
|
+
<div className="flex items-center gap-2">
|
|
842
|
+
<Button
|
|
843
|
+
variant="outline"
|
|
844
|
+
className="h-9 rounded-xl border-stone-200 bg-white px-3"
|
|
845
|
+
onClick={() => setAccountPage((prev) => Math.max(1, prev - 1))}
|
|
846
|
+
disabled={safeAccountPage <= 1}
|
|
847
|
+
>
|
|
848
|
+
上一页
|
|
849
|
+
</Button>
|
|
850
|
+
<span>
|
|
851
|
+
{safeAccountPage}/{accountPageCount}
|
|
852
|
+
</span>
|
|
853
|
+
<Button
|
|
854
|
+
variant="outline"
|
|
855
|
+
className="h-9 rounded-xl border-stone-200 bg-white px-3"
|
|
856
|
+
onClick={() => setAccountPage((prev) => Math.min(accountPageCount, prev + 1))}
|
|
857
|
+
disabled={safeAccountPage >= accountPageCount}
|
|
858
|
+
>
|
|
859
|
+
下一页
|
|
860
|
+
</Button>
|
|
861
|
+
</div>
|
|
862
|
+
</div>
|
|
863
|
+
|
|
864
|
+
<DialogFooter className="pt-2">
|
|
865
|
+
<Button
|
|
866
|
+
variant="secondary"
|
|
867
|
+
className="h-10 rounded-xl bg-stone-100 px-5 text-stone-700 hover:bg-stone-200"
|
|
868
|
+
onClick={() => setBrowserOpen(false)}
|
|
869
|
+
disabled={isStartingImport}
|
|
870
|
+
>
|
|
871
|
+
取消
|
|
872
|
+
</Button>
|
|
873
|
+
<Button
|
|
874
|
+
className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
|
|
875
|
+
onClick={() => void handleStartImport()}
|
|
876
|
+
disabled={isStartingImport || selectedIds.length === 0}
|
|
877
|
+
>
|
|
878
|
+
{isStartingImport ? <LoaderCircle className="size-4 animate-spin" /> : <Import className="size-4" />}
|
|
879
|
+
导入选中账号
|
|
880
|
+
</Button>
|
|
881
|
+
</DialogFooter>
|
|
882
|
+
</DialogContent>
|
|
883
|
+
</Dialog>
|
|
884
|
+
</>
|
|
885
|
+
);
|
|
886
|
+
}
|