dsh-plugin-image-workbench 1.1.5 → 1.1.7

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.
Files changed (173) hide show
  1. package/LICENSE +21 -21
  2. package/README.en.md +5 -3
  3. package/README.md +7 -3
  4. package/client.js +241 -241
  5. package/cordis.patch.yml +14 -14
  6. package/host.js +51 -0
  7. package/install.mjs +333 -387
  8. package/package.json +3 -2
  9. package/scripts/fetch-style-images.mjs +104 -0
  10. package/scripts/vendor-snapshot.py +154 -154
  11. package/server/access.py +26 -26
  12. package/server/app.py +1371 -1329
  13. package/server/catalog.py +147 -147
  14. package/server/composer.py +391 -391
  15. package/server/drafts.py +158 -158
  16. package/server/history.py +575 -575
  17. package/server/imaging.py +172 -172
  18. package/server/pool.py +1335 -1335
  19. package/server/pool_admin.py +921 -921
  20. package/server/pool_service.py +1073 -1073
  21. package/server/requirements.txt +6 -6
  22. package/server/smoke_check.py +402 -402
  23. package/server/static/access.html +202 -202
  24. package/server/static/index.html +15 -0
  25. package/vendor/chatgpt2api/source/.env.example +82 -82
  26. package/vendor/chatgpt2api/source/.python-version +1 -1
  27. package/vendor/chatgpt2api/source/LICENSE +21 -21
  28. package/vendor/chatgpt2api/source/README.md +391 -391
  29. package/vendor/chatgpt2api/source/VERSION +1 -1
  30. package/vendor/chatgpt2api/source/api/__init__.py +2 -2
  31. package/vendor/chatgpt2api/source/api/accounts.py +537 -537
  32. package/vendor/chatgpt2api/source/api/ai.py +217 -217
  33. package/vendor/chatgpt2api/source/api/app.py +62 -62
  34. package/vendor/chatgpt2api/source/api/errors.py +46 -46
  35. package/vendor/chatgpt2api/source/api/image_inputs.py +309 -309
  36. package/vendor/chatgpt2api/source/api/image_tasks.py +120 -120
  37. package/vendor/chatgpt2api/source/api/support.py +134 -134
  38. package/vendor/chatgpt2api/source/api/system.py +365 -365
  39. package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -50
  40. package/vendor/chatgpt2api/source/config.default.json +108 -108
  41. package/vendor/chatgpt2api/source/main.py +9 -9
  42. package/vendor/chatgpt2api/source/pyproject.toml +27 -27
  43. package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -186
  44. package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -163
  45. package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -13
  46. package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -385
  47. package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -129
  48. package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -92
  49. package/vendor/chatgpt2api/source/services/account_service.py +1757 -1757
  50. package/vendor/chatgpt2api/source/services/auth_service.py +253 -253
  51. package/vendor/chatgpt2api/source/services/backup_service.py +671 -671
  52. package/vendor/chatgpt2api/source/services/config.py +667 -667
  53. package/vendor/chatgpt2api/source/services/content_filter.py +242 -242
  54. package/vendor/chatgpt2api/source/services/cpa_service.py +315 -315
  55. package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -260
  56. package/vendor/chatgpt2api/source/services/image_service.py +376 -376
  57. package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -405
  58. package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -76
  59. package/vendor/chatgpt2api/source/services/image_task_service.py +550 -550
  60. package/vendor/chatgpt2api/source/services/log_service.py +337 -337
  61. package/vendor/chatgpt2api/source/services/model_service.py +182 -182
  62. package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -267
  63. package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -2763
  64. package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -32
  65. package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -2
  66. package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -307
  67. package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -238
  68. package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -1644
  69. package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -124
  70. package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -19
  71. package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -316
  72. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -86
  73. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -42
  74. package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -53
  75. package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -453
  76. package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -160
  77. package/vendor/chatgpt2api/source/services/proxy_service.py +648 -648
  78. package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -5
  79. package/vendor/chatgpt2api/source/services/storage/base.py +38 -38
  80. package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -194
  81. package/vendor/chatgpt2api/source/services/storage/factory.py +104 -104
  82. package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -183
  83. package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -95
  84. package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -531
  85. package/vendor/chatgpt2api/source/utils/helper.py +469 -469
  86. package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -346
  87. package/vendor/chatgpt2api/source/utils/log.py +110 -110
  88. package/vendor/chatgpt2api/source/utils/pkce.py +19 -19
  89. package/vendor/chatgpt2api/source/utils/pow.py +207 -207
  90. package/vendor/chatgpt2api/source/utils/sentinel.py +159 -159
  91. package/vendor/chatgpt2api/source/utils/turnstile.py +184 -184
  92. package/vendor/chatgpt2api/source/uv.lock +904 -904
  93. package/vendor/chatgpt2api/source/web/bun.lock +1501 -1501
  94. package/vendor/chatgpt2api/source/web/components.json +24 -24
  95. package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -17
  96. package/vendor/chatgpt2api/source/web/next.config.ts +40 -40
  97. package/vendor/chatgpt2api/source/web/package.json +58 -58
  98. package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -5
  99. package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -925
  100. package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -1307
  101. package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -224
  102. package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -444
  103. package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -9
  104. package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -9
  105. package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -172
  106. package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -169
  107. package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -39
  108. package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -59
  109. package/vendor/chatgpt2api/source/web/src/app/globals.css +230 -230
  110. package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -542
  111. package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -554
  112. package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -176
  113. package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -1799
  114. package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -732
  115. package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -50
  116. package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -101
  117. package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -356
  118. package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -30
  119. package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -341
  120. package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -447
  121. package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -71
  122. package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -491
  123. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -108
  124. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -169
  125. package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -185
  126. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -379
  127. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -127
  128. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -200
  129. package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -12
  130. package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -886
  131. package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -81
  132. package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -422
  133. package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -135
  134. package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -1069
  135. package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -49
  136. package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -24
  137. package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -434
  138. package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -46
  139. package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -18
  140. package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -14
  141. package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -263
  142. package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -256
  143. package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -41
  144. package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -59
  145. package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -46
  146. package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -61
  147. package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -27
  148. package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -267
  149. package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -132
  150. package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -13
  151. package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -19
  152. package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -24
  153. package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -149
  154. package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -38
  155. package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -175
  156. package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -143
  157. package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -29
  158. package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -91
  159. package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -269
  160. package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -18
  161. package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -127
  162. package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -6
  163. package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -85
  164. package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -926
  165. package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -26
  166. package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -25
  167. package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -93
  168. package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -89
  169. package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -6
  170. package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -112
  171. package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -33
  172. package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -318
  173. package/vendor/chatgpt2api/source/web/tsconfig.json +42 -42
@@ -1,886 +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
- }
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
+ }