dsh-plugin-image-workbench 1.1.5 → 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.
Files changed (172) 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 +1329 -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/vendor/chatgpt2api/source/.env.example +82 -82
  25. package/vendor/chatgpt2api/source/.python-version +1 -1
  26. package/vendor/chatgpt2api/source/LICENSE +21 -21
  27. package/vendor/chatgpt2api/source/README.md +391 -391
  28. package/vendor/chatgpt2api/source/VERSION +1 -1
  29. package/vendor/chatgpt2api/source/api/__init__.py +2 -2
  30. package/vendor/chatgpt2api/source/api/accounts.py +537 -537
  31. package/vendor/chatgpt2api/source/api/ai.py +217 -217
  32. package/vendor/chatgpt2api/source/api/app.py +62 -62
  33. package/vendor/chatgpt2api/source/api/errors.py +46 -46
  34. package/vendor/chatgpt2api/source/api/image_inputs.py +309 -309
  35. package/vendor/chatgpt2api/source/api/image_tasks.py +120 -120
  36. package/vendor/chatgpt2api/source/api/support.py +134 -134
  37. package/vendor/chatgpt2api/source/api/system.py +365 -365
  38. package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -50
  39. package/vendor/chatgpt2api/source/config.default.json +108 -108
  40. package/vendor/chatgpt2api/source/main.py +9 -9
  41. package/vendor/chatgpt2api/source/pyproject.toml +27 -27
  42. package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -186
  43. package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -163
  44. package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -13
  45. package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -385
  46. package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -129
  47. package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -92
  48. package/vendor/chatgpt2api/source/services/account_service.py +1757 -1757
  49. package/vendor/chatgpt2api/source/services/auth_service.py +253 -253
  50. package/vendor/chatgpt2api/source/services/backup_service.py +671 -671
  51. package/vendor/chatgpt2api/source/services/config.py +667 -667
  52. package/vendor/chatgpt2api/source/services/content_filter.py +242 -242
  53. package/vendor/chatgpt2api/source/services/cpa_service.py +315 -315
  54. package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -260
  55. package/vendor/chatgpt2api/source/services/image_service.py +376 -376
  56. package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -405
  57. package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -76
  58. package/vendor/chatgpt2api/source/services/image_task_service.py +550 -550
  59. package/vendor/chatgpt2api/source/services/log_service.py +337 -337
  60. package/vendor/chatgpt2api/source/services/model_service.py +182 -182
  61. package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -267
  62. package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -2763
  63. package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -32
  64. package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -2
  65. package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -307
  66. package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -238
  67. package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -1644
  68. package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -124
  69. package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -19
  70. package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -316
  71. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -86
  72. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -42
  73. package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -53
  74. package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -453
  75. package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -160
  76. package/vendor/chatgpt2api/source/services/proxy_service.py +648 -648
  77. package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -5
  78. package/vendor/chatgpt2api/source/services/storage/base.py +38 -38
  79. package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -194
  80. package/vendor/chatgpt2api/source/services/storage/factory.py +104 -104
  81. package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -183
  82. package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -95
  83. package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -531
  84. package/vendor/chatgpt2api/source/utils/helper.py +469 -469
  85. package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -346
  86. package/vendor/chatgpt2api/source/utils/log.py +110 -110
  87. package/vendor/chatgpt2api/source/utils/pkce.py +19 -19
  88. package/vendor/chatgpt2api/source/utils/pow.py +207 -207
  89. package/vendor/chatgpt2api/source/utils/sentinel.py +159 -159
  90. package/vendor/chatgpt2api/source/utils/turnstile.py +184 -184
  91. package/vendor/chatgpt2api/source/uv.lock +904 -904
  92. package/vendor/chatgpt2api/source/web/bun.lock +1501 -1501
  93. package/vendor/chatgpt2api/source/web/components.json +24 -24
  94. package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -17
  95. package/vendor/chatgpt2api/source/web/next.config.ts +40 -40
  96. package/vendor/chatgpt2api/source/web/package.json +58 -58
  97. package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -5
  98. package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -925
  99. package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -1307
  100. package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -224
  101. package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -444
  102. package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -9
  103. package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -9
  104. package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -172
  105. package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -169
  106. package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -39
  107. package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -59
  108. package/vendor/chatgpt2api/source/web/src/app/globals.css +230 -230
  109. package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -542
  110. package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -554
  111. package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -176
  112. package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -1799
  113. package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -732
  114. package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -50
  115. package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -101
  116. package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -356
  117. package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -30
  118. package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -341
  119. package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -447
  120. package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -71
  121. package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -491
  122. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -108
  123. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -169
  124. package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -185
  125. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -379
  126. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -127
  127. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -200
  128. package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -12
  129. package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -886
  130. package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -81
  131. package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -422
  132. package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -135
  133. package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -1069
  134. package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -49
  135. package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -24
  136. package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -434
  137. package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -46
  138. package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -18
  139. package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -14
  140. package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -263
  141. package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -256
  142. package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -41
  143. package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -59
  144. package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -46
  145. package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -61
  146. package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -27
  147. package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -267
  148. package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -132
  149. package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -13
  150. package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -19
  151. package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -24
  152. package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -149
  153. package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -38
  154. package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -175
  155. package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -143
  156. package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -29
  157. package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -91
  158. package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -269
  159. package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -18
  160. package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -127
  161. package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -6
  162. package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -85
  163. package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -926
  164. package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -26
  165. package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -25
  166. package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -93
  167. package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -89
  168. package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -6
  169. package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -112
  170. package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -33
  171. package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -318
  172. package/vendor/chatgpt2api/source/web/tsconfig.json +42 -42
@@ -1,925 +1,925 @@
1
- "use client";
2
-
3
- import { useRouter } from "next/navigation";
4
- import { useRef, useState, type ChangeEvent } from "react";
5
- import {
6
- ArrowLeft,
7
- Copy,
8
- ExternalLink,
9
- FileJson,
10
- FileText,
11
- Files,
12
- KeyRound,
13
- LoaderCircle,
14
- LogIn,
15
- ServerCog,
16
- Upload,
17
- } from "lucide-react";
18
- import { toast } from "sonner";
19
-
20
- import { Button } from "@/components/ui/button";
21
- import { Card, CardContent } from "@/components/ui/card";
22
- import {
23
- Dialog,
24
- DialogContent,
25
- DialogDescription,
26
- DialogFooter,
27
- DialogHeader,
28
- DialogTitle,
29
- } from "@/components/ui/dialog";
30
- import { Textarea } from "@/components/ui/textarea";
31
- import {
32
- createAccounts,
33
- finishOAuthLogin,
34
- startOAuthLogin,
35
- type Account,
36
- type AccountImportPayload,
37
- type OAuthLoginStartResponse,
38
- } from "@/lib/api";
39
- import { cn } from "@/lib/utils";
40
-
41
- type ImportMethod = "menu" | "token" | "session" | "codex-auth" | "account-json" | "oauth";
42
-
43
- type AccountImportDialogProps = {
44
- disabled?: boolean;
45
- onImported: (items: Account[]) => void;
46
- };
47
-
48
- type PendingAccountJsonImport = {
49
- tokens: string[];
50
- accounts: AccountImportPayload[];
51
- parsedAccountCount: number;
52
- errorCount: number;
53
- };
54
-
55
- const sessionUrl = "https://chatgpt.com/api/auth/session";
56
-
57
- function splitTokens(value: string) {
58
- return value
59
- .split(/\r?\n/)
60
- .map((item) => item.trim())
61
- .filter(Boolean);
62
- }
63
-
64
- function getSessionAccessToken(value: unknown) {
65
- const token = (value as { accessToken?: unknown })?.accessToken;
66
- return typeof token === "string" ? token.trim() : "";
67
- }
68
-
69
- function getAccountJsonAccount(value: unknown): AccountImportPayload | null {
70
- if (!value || typeof value !== "object" || Array.isArray(value)) {
71
- return null;
72
- }
73
- const raw = value as Record<string, unknown>;
74
- const tokenValue = raw.access_token ?? raw.accessToken;
75
- const token = typeof tokenValue === "string" ? tokenValue.trim() : "";
76
- if (!token) {
77
- return null;
78
- }
79
-
80
- const payload: AccountImportPayload = {
81
- ...raw,
82
- access_token: token,
83
- source_type: "codex",
84
- };
85
- delete payload.accessToken;
86
- if (payload.type === "codex") {
87
- payload.export_type = "codex";
88
- delete payload.type;
89
- }
90
- return payload;
91
- }
92
-
93
- function getAccountJsonAccounts(value: unknown): AccountImportPayload[] {
94
- if (Array.isArray(value)) {
95
- return value
96
- .map((item) => getAccountJsonAccount(item))
97
- .filter((item): item is AccountImportPayload => Boolean(item));
98
- }
99
-
100
- const singleAccount = getAccountJsonAccount(value);
101
- if (singleAccount) {
102
- return [singleAccount];
103
- }
104
-
105
- if (value && typeof value === "object") {
106
- const raw = value as Record<string, unknown>;
107
- const nested = raw.accounts ?? raw.items;
108
- if (Array.isArray(nested)) {
109
- return nested
110
- .map((item) => getAccountJsonAccount(item))
111
- .filter((item): item is AccountImportPayload => Boolean(item));
112
- }
113
- }
114
-
115
- return [];
116
- }
117
-
118
- function getCodexAuthAccount(value: unknown): AccountImportPayload | null {
119
- if (!value || typeof value !== "object" || Array.isArray(value)) {
120
- return null;
121
- }
122
- const raw = value as Record<string, unknown>;
123
- const tokenValue = raw.access_token ?? raw.accessToken;
124
- const token = typeof tokenValue === "string" ? tokenValue.trim() : "";
125
- if (!token) {
126
- return null;
127
- }
128
-
129
- const payload: AccountImportPayload = {
130
- ...raw,
131
- access_token: token,
132
- export_type: "codex",
133
- source_type: "codex",
134
- };
135
- delete payload.accessToken;
136
- if (payload.type === "codex") {
137
- delete payload.type;
138
- }
139
- return payload;
140
- }
141
-
142
- function readFileAsText(file: File) {
143
- return new Promise<string>((resolve, reject) => {
144
- const reader = new FileReader();
145
- reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : "");
146
- reader.onerror = () => reject(reader.error ?? new Error(`读取文件失败: ${file.name}`));
147
- reader.readAsText(file);
148
- });
149
- }
150
-
151
- function MethodCard({
152
- title,
153
- description,
154
- icon: Icon,
155
- onClick,
156
- }: {
157
- title: string;
158
- description: string;
159
- icon: typeof KeyRound;
160
- onClick: () => void;
161
- }) {
162
- return (
163
- <button
164
- type="button"
165
- onClick={onClick}
166
- className="w-full rounded-2xl border border-stone-200 bg-white p-0 text-left transition hover:border-stone-300 hover:bg-stone-50"
167
- >
168
- <Card className="rounded-2xl border-0 bg-transparent shadow-none">
169
- <CardContent className="flex items-start gap-4 p-4">
170
- <div className="rounded-xl bg-stone-100 p-3 text-stone-700">
171
- <Icon className="size-5" />
172
- </div>
173
- <div className="space-y-1">
174
- <div className="text-sm font-semibold text-stone-900">{title}</div>
175
- <div className="text-sm leading-6 text-stone-500">{description}</div>
176
- </div>
177
- </CardContent>
178
- </Card>
179
- </button>
180
- );
181
- }
182
-
183
- export function AccountImportDialog({ disabled, onImported }: AccountImportDialogProps) {
184
- const router = useRouter();
185
- const [open, setOpen] = useState(false);
186
- const [method, setMethod] = useState<ImportMethod>("menu");
187
- const [tokenInput, setTokenInput] = useState("");
188
- const [sessionInput, setSessionInput] = useState("");
189
- const [codexAuthInput, setCodexAuthInput] = useState("");
190
- const [isSubmitting, setIsSubmitting] = useState(false);
191
- const [pendingAccountJsonImport, setPendingAccountJsonImport] = useState<PendingAccountJsonImport | null>(null);
192
- const [confirmOpen, setConfirmOpen] = useState(false);
193
- const [oauthEmailHint, setOauthEmailHint] = useState("");
194
- const [oauthSession, setOauthSession] = useState<OAuthLoginStartResponse | null>(null);
195
- const [oauthCallbackInput, setOauthCallbackInput] = useState("");
196
- const [oauthStarting, setOauthStarting] = useState(false);
197
-
198
- const txtInputRef = useRef<HTMLInputElement | null>(null);
199
- const accountJsonInputRef = useRef<HTMLInputElement | null>(null);
200
-
201
- const resetState = () => {
202
- setMethod("menu");
203
- setTokenInput("");
204
- setSessionInput("");
205
- setCodexAuthInput("");
206
- setPendingAccountJsonImport(null);
207
- setConfirmOpen(false);
208
- setOauthEmailHint("");
209
- setOauthSession(null);
210
- setOauthCallbackInput("");
211
- setOauthStarting(false);
212
- };
213
-
214
- const handleOpenChange = (nextOpen: boolean) => {
215
- setOpen(nextOpen);
216
- if (!nextOpen) {
217
- resetState();
218
- }
219
- };
220
-
221
- const submitTokens = async (tokens: string[], successText?: string, accountPayloads: AccountImportPayload[] = []) => {
222
- const normalizedTokens = tokens.map((item) => item.trim()).filter(Boolean);
223
-
224
- if (normalizedTokens.length === 0) {
225
- toast.error("请先提供至少一个可用 Token");
226
- return;
227
- }
228
-
229
- setIsSubmitting(true);
230
- try {
231
- const data = await createAccounts(normalizedTokens, accountPayloads);
232
- onImported(data.items);
233
- setOpen(false);
234
- resetState();
235
-
236
- if ((data.errors?.length ?? 0) > 0) {
237
- const firstError = data.errors?.[0]?.error;
238
- toast.error(
239
- `${successText ?? "导入完成"},新增 ${data.added ?? 0} 个,已刷新 ${data.refreshed ?? 0} 个,失败 ${data.errors?.length ?? 0} 个${firstError ? `,首个错误:${firstError}` : ""}`,
240
- );
241
- } else {
242
- toast.success(
243
- `${successText ?? "导入完成"},新增 ${data.added ?? 0} 个,跳过 ${data.skipped ?? 0} 个重复项,已自动刷新账号信息`,
244
- );
245
- }
246
- } catch (error) {
247
- const message = error instanceof Error ? error.message : "导入账户失败";
248
- toast.error(message);
249
- } finally {
250
- setIsSubmitting(false);
251
- }
252
- };
253
-
254
- const handleImportTokenText = async () => {
255
- await submitTokens(splitTokens(tokenInput), "Access Token 导入完成");
256
- };
257
-
258
- // 起授权:拿 authorize URL,立刻在新窗口打开,方便用户登录
259
- const handleStartOAuth = async () => {
260
- setOauthStarting(true);
261
- try {
262
- const data = await startOAuthLogin(oauthEmailHint.trim());
263
- setOauthSession(data);
264
- setOauthCallbackInput("");
265
- if (typeof window !== "undefined") {
266
- window.open(data.authorize_url, "_blank", "noopener,noreferrer");
267
- }
268
- toast.success("已打开 OpenAI 授权页面,请在登录后复制 callback URL 回来");
269
- } catch (error) {
270
- const message = error instanceof Error ? error.message : "OAuth 起始失败";
271
- toast.error(message);
272
- } finally {
273
- setOauthStarting(false);
274
- }
275
- };
276
-
277
- // 用粘贴回来的 callback URL 完成换 token + 落盘
278
- const handleFinishOAuth = async () => {
279
- if (!oauthSession) {
280
- toast.error("请先点击\"打开授权页面\"获取 session");
281
- return;
282
- }
283
- const trimmed = oauthCallbackInput.trim();
284
- if (!trimmed) {
285
- toast.error("请粘贴 callback URL 或 code");
286
- return;
287
- }
288
-
289
- setIsSubmitting(true);
290
- try {
291
- const data = await finishOAuthLogin(oauthSession.session_id, trimmed);
292
- onImported(data.items);
293
- setOpen(false);
294
- resetState();
295
-
296
- if ((data.errors?.length ?? 0) > 0) {
297
- const firstError = data.errors?.[0]?.error;
298
- toast.error(
299
- `OAuth 登录完成,新增 ${data.added ?? 0} 个,已刷新 ${data.refreshed ?? 0} 个,失败 ${data.errors?.length ?? 0} 个${firstError ? `,首个错误:${firstError}` : ""}`,
300
- );
301
- } else {
302
- toast.success(
303
- `OAuth 登录完成,新增 ${data.added ?? 0} 个,跳过 ${data.skipped ?? 0} 个重复项,已自动刷新账号信息`,
304
- );
305
- }
306
- } catch (error) {
307
- const message = error instanceof Error ? error.message : "OAuth 换 token 失败";
308
- toast.error(message);
309
- } finally {
310
- setIsSubmitting(false);
311
- }
312
- };
313
-
314
- // 复制 authorize URL 到剪贴板(适配浏览器和 fallback)
315
- const handleCopyAuthorizeUrl = async () => {
316
- if (!oauthSession) {
317
- return;
318
- }
319
- try {
320
- if (navigator.clipboard?.writeText) {
321
- await navigator.clipboard.writeText(oauthSession.authorize_url);
322
- toast.success("授权 URL 已复制到剪贴板");
323
- } else {
324
- toast.error("当前环境不支持自动复制,请手动选择并复制");
325
- }
326
- } catch {
327
- toast.error("复制失败,请手动选择并复制");
328
- }
329
- };
330
-
331
- const handleTxtSelected = async (event: ChangeEvent<HTMLInputElement>) => {
332
- const file = event.target.files?.[0];
333
- event.target.value = "";
334
-
335
- if (!file) {
336
- return;
337
- }
338
-
339
- try {
340
- const content = await readFileAsText(file);
341
- const tokens = splitTokens(content);
342
-
343
- if (tokens.length === 0) {
344
- toast.error("TXT 文件里没有读取到有效 Token");
345
- return;
346
- }
347
-
348
- setTokenInput((prev) => {
349
- const next = [...splitTokens(prev), ...tokens];
350
- return next.join("\n");
351
- });
352
- toast.success(`已从 ${file.name} 读取 ${tokens.length} 个 Token`);
353
- } catch (error) {
354
- const message = error instanceof Error ? error.message : "读取 TXT 文件失败";
355
- toast.error(message);
356
- }
357
- };
358
-
359
- const handleImportSessionJson = async () => {
360
- if (!sessionInput.trim()) {
361
- toast.error("请先粘贴完整 Session JSON");
362
- return;
363
- }
364
-
365
- try {
366
- const payload = JSON.parse(sessionInput) as unknown;
367
- const token = getSessionAccessToken(payload);
368
-
369
- if (!token) {
370
- toast.error("未从 Session JSON 中提取到 accessToken");
371
- return;
372
- }
373
-
374
- await submitTokens([token], "Session JSON 导入完成");
375
- } catch (error) {
376
- const message = error instanceof Error ? error.message : "Session JSON 解析失败";
377
- toast.error(message);
378
- }
379
- };
380
-
381
- const handleImportCodexAuthJson = async () => {
382
- if (!codexAuthInput.trim()) {
383
- toast.error("请先粘贴 Codex 认证 JSON");
384
- return;
385
- }
386
-
387
- try {
388
- const payload = JSON.parse(codexAuthInput) as unknown;
389
- const account = getCodexAuthAccount(payload);
390
-
391
- if (!account) {
392
- toast.error("未从 Codex 认证 JSON 中提取到 access_token");
393
- return;
394
- }
395
-
396
- await submitTokens([account.access_token], "Codex 认证 JSON 导入完成", [account]);
397
- } catch (error) {
398
- const message = error instanceof Error ? error.message : "Codex 认证 JSON 解析失败";
399
- toast.error(message);
400
- }
401
- };
402
-
403
- const handleAccountJsonSelected = async (event: ChangeEvent<HTMLInputElement>) => {
404
- const files = Array.from(event.target.files ?? []);
405
- event.target.value = "";
406
-
407
- if (files.length === 0) {
408
- return;
409
- }
410
-
411
- try {
412
- const results = await Promise.all(
413
- files.map(async (file) => {
414
- const raw = await readFileAsText(file);
415
- const parsed = JSON.parse(raw) as unknown;
416
- const accounts = getAccountJsonAccounts(parsed);
417
- return {
418
- accounts,
419
- };
420
- }),
421
- );
422
-
423
- const accounts = results.flatMap((item) => item.accounts);
424
- const tokens = accounts.map((item) => item.access_token);
425
- const parsedAccountCount = accounts.length;
426
- const errorCount = results.filter((item) => item.accounts.length === 0).length;
427
-
428
- if (parsedAccountCount === 0) {
429
- toast.error("这些账号 JSON 文件里没有读取到可用 access_token");
430
- return;
431
- }
432
-
433
- setPendingAccountJsonImport({
434
- tokens,
435
- accounts,
436
- parsedAccountCount,
437
- errorCount,
438
- });
439
- setConfirmOpen(true);
440
- } catch (error) {
441
- const message = error instanceof Error ? error.message : "读取账号 JSON 文件失败";
442
- toast.error(message);
443
- }
444
- };
445
-
446
- const renderMethodBody = () => {
447
- if (method === "token") {
448
- const tokenCount = splitTokens(tokenInput).length;
449
-
450
- return (
451
- <div className="space-y-4">
452
- <div className="flex items-center justify-between">
453
- <button
454
- type="button"
455
- onClick={() => setMethod("menu")}
456
- className="inline-flex items-center gap-1 text-sm text-stone-500 transition hover:text-stone-800"
457
- >
458
- <ArrowLeft className="size-4" />
459
- 返回导入方式
460
- </button>
461
- <span className="text-xs text-stone-400">当前识别 {tokenCount} 个 Token</span>
462
- </div>
463
- <div className="space-y-2">
464
- <label className="text-sm font-medium text-stone-700">Access Token 列表</label>
465
- <Textarea
466
- placeholder="每行一个 Access Token..."
467
- value={tokenInput}
468
- onChange={(event) => setTokenInput(event.target.value)}
469
- className="min-h-56 resize-none rounded-xl border-stone-200"
470
- />
471
- </div>
472
- <div className="rounded-2xl border border-dashed border-stone-200 bg-stone-50 p-4">
473
- <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
474
- <div className="space-y-1">
475
- <div className="text-sm font-medium text-stone-800">从 TXT 文件导入</div>
476
- <div className="text-sm leading-6 text-stone-500">支持 `.txt`,文件内容也是一行一个 Token。</div>
477
- </div>
478
- <Button
479
- type="button"
480
- variant="outline"
481
- className="rounded-xl border-stone-200 bg-white"
482
- onClick={() => txtInputRef.current?.click()}
483
- disabled={isSubmitting}
484
- >
485
- <FileText className="size-4" />
486
- 选择 TXT
487
- </Button>
488
- </div>
489
- </div>
490
- <input
491
- ref={txtInputRef}
492
- type="file"
493
- accept=".txt,text/plain"
494
- className="hidden"
495
- onChange={(event) => void handleTxtSelected(event)}
496
- />
497
- </div>
498
- );
499
- }
500
-
501
- if (method === "session") {
502
- return (
503
- <div className="space-y-4">
504
- <button
505
- type="button"
506
- onClick={() => setMethod("menu")}
507
- className="inline-flex items-center gap-1 text-sm text-stone-500 transition hover:text-stone-800"
508
- >
509
- <ArrowLeft className="size-4" />
510
- 返回导入方式
511
- </button>
512
- <div className="rounded-2xl border border-stone-200 bg-stone-50 p-4 text-sm leading-6 text-stone-600">
513
- 打开
514
- {" "}
515
- <a
516
- href={sessionUrl}
517
- target="_blank"
518
- rel="noreferrer"
519
- className="inline-flex items-center gap-1 font-medium text-stone-900 underline underline-offset-4"
520
- >
521
- {sessionUrl}
522
- <ExternalLink className="size-3.5" />
523
- </a>
524
- ,复制页面返回的完整 JSON,系统会自动提取其中的 `accessToken` 导入。
525
- </div>
526
- <div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm leading-6 text-amber-900">
527
- <div className="font-medium">风险提示</div>
528
- <div>
529
- 不要使用自己的大号,尽量使用不常用的小号进行导入,避免出现封号风险。本项目不承担任何封号风险责任。
530
- </div>
531
- </div>
532
- <div className="space-y-2">
533
- <label className="text-sm font-medium text-stone-700">Session JSON</label>
534
- <Textarea
535
- placeholder='粘贴完整 JSON,例如包含 "accessToken" 的对象...'
536
- value={sessionInput}
537
- onChange={(event) => setSessionInput(event.target.value)}
538
- className="min-h-56 resize-none rounded-xl border-stone-200 font-mono text-xs"
539
- />
540
- </div>
541
- </div>
542
- );
543
- }
544
-
545
- if (method === "oauth") {
546
- return (
547
- <div className="space-y-4">
548
- <button
549
- type="button"
550
- onClick={() => setMethod("menu")}
551
- className="inline-flex items-center gap-1 text-sm text-stone-500 transition hover:text-stone-800"
552
- >
553
- <ArrowLeft className="size-4" />
554
- 返回导入方式
555
- </button>
556
- <div className="rounded-2xl border border-stone-200 bg-stone-50 p-4 text-sm leading-6 text-stone-600 space-y-2">
557
- <div className="font-medium text-stone-800">操作步骤</div>
558
- <ol className="list-decimal pl-5 space-y-1">
559
- <li>(可选)填写你 ChatGPT 账号的邮箱,登录页会预填。</li>
560
- <li>点击下方"打开授权页面",在新标签里登录自己的 ChatGPT 账号。</li>
561
- <li>登录完成后浏览器会跳到 <code className="rounded bg-stone-200 px-1">platform.openai.com/auth/callback?code=...</code>。立刻从地址栏复制整段 URL(或开 F12 在 Network 里抓到 callback 那一行,右键 Copy → Copy URL)。</li>
562
- <li>把 callback URL 粘到下面输入框,点"完成导入"。</li>
563
- </ol>
564
- </div>
565
- <div className="space-y-2">
566
- <label className="text-sm font-medium text-stone-700">邮箱(可选预填)</label>
567
- <input
568
- type="email"
569
- placeholder="you@example.com"
570
- value={oauthEmailHint}
571
- onChange={(event) => setOauthEmailHint(event.target.value)}
572
- disabled={Boolean(oauthSession) || oauthStarting}
573
- className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm outline-none focus:border-stone-400"
574
- />
575
- </div>
576
- {!oauthSession ? (
577
- <Button
578
- type="button"
579
- className="h-10 rounded-xl bg-stone-950 text-white hover:bg-stone-800"
580
- onClick={() => void handleStartOAuth()}
581
- disabled={oauthStarting}
582
- >
583
- {oauthStarting ? <LoaderCircle className="size-4 animate-spin" /> : <ExternalLink className="size-4" />}
584
- 打开授权页面
585
- </Button>
586
- ) : (
587
- <div className="space-y-3">
588
- <div className="rounded-2xl border border-stone-200 bg-white p-3 text-xs leading-6 text-stone-600 break-all font-mono">
589
- {oauthSession.authorize_url}
590
- </div>
591
- <div className="flex flex-wrap gap-2">
592
- <Button
593
- type="button"
594
- variant="outline"
595
- className="rounded-xl border-stone-200 bg-white"
596
- onClick={() => void handleCopyAuthorizeUrl()}
597
- >
598
- <Copy className="size-4" />
599
- 复制授权 URL
600
- </Button>
601
- <Button
602
- type="button"
603
- variant="outline"
604
- className="rounded-xl border-stone-200 bg-white"
605
- onClick={() => window.open(oauthSession.authorize_url, "_blank", "noopener,noreferrer")}
606
- >
607
- <ExternalLink className="size-4" />
608
- 再次打开
609
- </Button>
610
- <Button
611
- type="button"
612
- variant="outline"
613
- className="rounded-xl border-stone-200 bg-white"
614
- onClick={() => {
615
- setOauthSession(null);
616
- setOauthCallbackInput("");
617
- }}
618
- >
619
- 重新生成
620
- </Button>
621
- </div>
622
- <div className="space-y-2">
623
- <label className="text-sm font-medium text-stone-700">粘贴 callback URL(或仅 code)</label>
624
- <Textarea
625
- placeholder={"https://platform.openai.com/auth/callback?code=...&state=..."}
626
- value={oauthCallbackInput}
627
- onChange={(event) => setOauthCallbackInput(event.target.value)}
628
- className="min-h-24 resize-none rounded-xl border-stone-200 font-mono text-xs"
629
- />
630
- </div>
631
- </div>
632
- )}
633
- <div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm leading-6 text-amber-900">
634
- <div className="font-medium">注意</div>
635
- <div>
636
- 授权码(code)只能使用一次。如果浏览器的 callback 页加载完成、显示了 OpenAI 的错误页,那 code 大概率已经被消耗,
637
- 请点击"重新生成"再走一次。整个流程在 10 分钟内完成即可。
638
- </div>
639
- </div>
640
- </div>
641
- );
642
- }
643
-
644
- if (method === "account-json") {
645
- return (
646
- <div className="space-y-4">
647
- <button
648
- type="button"
649
- onClick={() => setMethod("menu")}
650
- className="inline-flex items-center gap-1 text-sm text-stone-500 transition hover:text-stone-800"
651
- >
652
- <ArrowLeft className="size-4" />
653
- 返回导入方式
654
- </button>
655
- <div className="rounded-2xl border border-dashed border-stone-200 bg-stone-50 p-5">
656
- <div className="space-y-2">
657
- <div className="text-sm font-medium text-stone-800">选择本地账号 JSON 文件</div>
658
- <div className="text-sm leading-6 text-stone-500">
659
- 支持本项目导出的单账号对象或全部账号数组,也兼容每个文件一个账号对象的 CPA JSON。
660
- 系统会自动提取 `access_token` 或 `accessToken`。
661
- </div>
662
- </div>
663
- <Button
664
- type="button"
665
- className="mt-4 rounded-xl bg-stone-950 text-white hover:bg-stone-800"
666
- onClick={() => accountJsonInputRef.current?.click()}
667
- disabled={isSubmitting}
668
- >
669
- <Files className="size-4" />
670
- 选择 JSON 文件
671
- </Button>
672
- </div>
673
- <input
674
- ref={accountJsonInputRef}
675
- type="file"
676
- accept=".json,application/json"
677
- multiple
678
- className="hidden"
679
- onChange={(event) => void handleAccountJsonSelected(event)}
680
- />
681
- {pendingAccountJsonImport ? (
682
- <div className="rounded-2xl border border-stone-200 bg-white p-4 text-sm leading-6 text-stone-600">
683
- 最近一次读取到 {pendingAccountJsonImport.parsedAccountCount} 个 Token
684
- {pendingAccountJsonImport.errorCount > 0 ? `,另有 ${pendingAccountJsonImport.errorCount} 个文件未提取成功` : ""}。
685
- </div>
686
- ) : null}
687
- </div>
688
- );
689
- }
690
-
691
- if (method === "codex-auth") {
692
- return (
693
- <div className="space-y-4">
694
- <button
695
- type="button"
696
- onClick={() => setMethod("menu")}
697
- className="inline-flex items-center gap-1 text-sm text-stone-500 transition hover:text-stone-800"
698
- >
699
- <ArrowLeft className="size-4" />
700
- 返回导入方式
701
- </button>
702
- <div className="space-y-2">
703
- <label className="text-sm font-medium text-stone-700">Codex 认证 JSON</label>
704
- <Textarea
705
- placeholder='粘贴包含 "access_token"、"refresh_token"、"id_token" 的 Codex 认证 JSON...'
706
- value={codexAuthInput}
707
- onChange={(event) => setCodexAuthInput(event.target.value)}
708
- className="min-h-64 resize-none rounded-xl border-stone-200 font-mono text-xs"
709
- />
710
- </div>
711
- </div>
712
- );
713
- }
714
-
715
- return (
716
- <div className="space-y-3">
717
- <MethodCard
718
- title="OAuth 登录已有账号(带自动刷新)"
719
- description="用浏览器登录自己的 ChatGPT 账号,回填 callback URL 即可拿到 refresh_token,后台会自动续期。"
720
- icon={LogIn}
721
- onClick={() => setMethod("oauth")}
722
- />
723
- <MethodCard
724
- title="导入 Access Token"
725
- description="支持直接粘贴,一行一个;也支持从 TXT 文件读取,一行一个。"
726
- icon={KeyRound}
727
- onClick={() => setMethod("token")}
728
- />
729
- <MethodCard
730
- title="导入 Session JSON"
731
- description="从 chatgpt.com 的 session 接口复制完整 JSON,自动提取 accessToken。"
732
- icon={FileJson}
733
- onClick={() => setMethod("session")}
734
- />
735
- <MethodCard
736
- title="导入 Codex 认证 JSON"
737
- description="粘贴 Codex 认证 JSON,导入后账号来源标记为 codex。"
738
- icon={FileJson}
739
- onClick={() => setMethod("codex-auth")}
740
- />
741
- <MethodCard
742
- title="导入账号 JSON 文件"
743
- description="支持本项目导出的单账号 JSON 或全部账号数组,也兼容 CPA JSON 文件。"
744
- icon={Files}
745
- onClick={() => setMethod("account-json")}
746
- />
747
- <MethodCard
748
- title="从远程 CPA 服务器导入"
749
- description="前往设置页面配置远程 CPA 服务器后再执行导入。"
750
- icon={Files}
751
- onClick={() => {
752
- setOpen(false);
753
- resetState();
754
- router.push("/settings");
755
- }}
756
- />
757
- <MethodCard
758
- title="从 Sub2API 服务器导入"
759
- description="前往设置页面配置 Sub2API 服务器,再选择其中的 OpenAI 账号导入。"
760
- icon={ServerCog}
761
- onClick={() => {
762
- setOpen(false);
763
- resetState();
764
- router.push("/settings");
765
- }}
766
- />
767
- </div>
768
- );
769
- };
770
-
771
- const footerDisabled = disabled || isSubmitting;
772
-
773
- return (
774
- <>
775
- <Dialog open={open} onOpenChange={handleOpenChange}>
776
- <Button
777
- className="h-10 rounded-xl bg-stone-950 px-4 text-white hover:bg-stone-800"
778
- onClick={() => setOpen(true)}
779
- disabled={disabled}
780
- >
781
- <Upload className="size-4" />
782
- 导入
783
- </Button>
784
- <DialogContent showCloseButton={false} className="rounded-2xl p-6">
785
- <DialogHeader className="gap-2">
786
- <DialogTitle>
787
- {method === "menu"
788
- ? "导入账户"
789
- : method === "token"
790
- ? "导入 Access Token"
791
- : method === "session"
792
- ? "导入 Session JSON"
793
- : method === "codex-auth"
794
- ? "导入 Codex 认证 JSON"
795
- : method === "oauth"
796
- ? "OAuth 登录已有账号"
797
- : "导入账号 JSON"}
798
- </DialogTitle>
799
- <DialogDescription className="text-sm leading-6">
800
- {method === "menu"
801
- ? "选择一种导入方式。导入成功后会自动拉取邮箱、类型和额度。"
802
- : method === "token"
803
- ? "支持手动粘贴或从 TXT 文件导入,一行一个 Token。"
804
- : method === "session"
805
- ? "粘贴完整 Session JSON,系统会自动提取 accessToken。"
806
- : method === "codex-auth"
807
- ? "粘贴 Codex 认证 JSON,系统会按 codex 来源导入。"
808
- : method === "oauth"
809
- ? "用浏览器跑一遍 OpenAI 标准 OAuth,拿回 refresh_token 后系统会自动续期。"
810
- : "支持读取本项目导出的单账号对象或全部账号数组,并在提交前做数量确认。"}
811
- </DialogDescription>
812
- </DialogHeader>
813
-
814
- {renderMethodBody()}
815
-
816
- <DialogFooter className="pt-2">
817
- <Button
818
- variant="secondary"
819
- className="h-10 rounded-xl bg-stone-100 px-5 text-stone-700 hover:bg-stone-200"
820
- onClick={() => setOpen(false)}
821
- disabled={footerDisabled}
822
- >
823
- 取消
824
- </Button>
825
- {method === "token" ? (
826
- <Button
827
- className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
828
- onClick={() => void handleImportTokenText()}
829
- disabled={footerDisabled}
830
- >
831
- {isSubmitting ? <LoaderCircle className="size-4 animate-spin" /> : null}
832
- 导入 Token
833
- </Button>
834
- ) : null}
835
- {method === "session" ? (
836
- <Button
837
- className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
838
- onClick={() => void handleImportSessionJson()}
839
- disabled={footerDisabled}
840
- >
841
- {isSubmitting ? <LoaderCircle className="size-4 animate-spin" /> : null}
842
- 导入 JSON
843
- </Button>
844
- ) : null}
845
- {method === "codex-auth" ? (
846
- <Button
847
- className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
848
- onClick={() => void handleImportCodexAuthJson()}
849
- disabled={footerDisabled}
850
- >
851
- {isSubmitting ? <LoaderCircle className="size-4 animate-spin" /> : null}
852
- 导入 JSON
853
- </Button>
854
- ) : null}
855
- {method === "oauth" ? (
856
- <Button
857
- className={cn(
858
- "h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800",
859
- !oauthSession ? "hidden" : "",
860
- )}
861
- onClick={() => void handleFinishOAuth()}
862
- disabled={footerDisabled || !oauthSession || !oauthCallbackInput.trim()}
863
- >
864
- {isSubmitting ? <LoaderCircle className="size-4 animate-spin" /> : null}
865
- 完成导入
866
- </Button>
867
- ) : null}
868
- {method === "account-json" ? (
869
- <Button
870
- className={cn(
871
- "h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800",
872
- !pendingAccountJsonImport ? "hidden" : "",
873
- )}
874
- onClick={() => setConfirmOpen(true)}
875
- disabled={footerDisabled || !pendingAccountJsonImport}
876
- >
877
- 查看导入确认
878
- </Button>
879
- ) : null}
880
- </DialogFooter>
881
- </DialogContent>
882
- </Dialog>
883
-
884
- <Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
885
- <DialogContent className="rounded-2xl p-6">
886
- <DialogHeader className="gap-2">
887
- <DialogTitle>确认导入账号 Token</DialogTitle>
888
- <DialogDescription className="text-sm leading-6">
889
- {pendingAccountJsonImport
890
- ? `确认识别到 ${pendingAccountJsonImport.parsedAccountCount} 个 Token,是否确认导入?`
891
- : "尚未读取到可导入的 Token。"}
892
- {pendingAccountJsonImport?.errorCount
893
- ? `,另有 ${pendingAccountJsonImport.errorCount} 个文件未提取成功。`
894
- : "。"}
895
- </DialogDescription>
896
- </DialogHeader>
897
- <DialogFooter className="pt-2">
898
- <Button
899
- variant="secondary"
900
- className="h-10 rounded-xl bg-stone-100 px-5 text-stone-700 hover:bg-stone-200"
901
- onClick={() => setConfirmOpen(false)}
902
- disabled={isSubmitting}
903
- >
904
- 返回
905
- </Button>
906
- <Button
907
- className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
908
- onClick={() =>
909
- void submitTokens(
910
- pendingAccountJsonImport?.tokens ?? [],
911
- "账号 JSON 导入完成",
912
- pendingAccountJsonImport?.accounts ?? [],
913
- )
914
- }
915
- disabled={isSubmitting || !pendingAccountJsonImport}
916
- >
917
- {isSubmitting ? <LoaderCircle className="size-4 animate-spin" /> : null}
918
- 确认导入
919
- </Button>
920
- </DialogFooter>
921
- </DialogContent>
922
- </Dialog>
923
- </>
924
- );
925
- }
1
+ "use client";
2
+
3
+ import { useRouter } from "next/navigation";
4
+ import { useRef, useState, type ChangeEvent } from "react";
5
+ import {
6
+ ArrowLeft,
7
+ Copy,
8
+ ExternalLink,
9
+ FileJson,
10
+ FileText,
11
+ Files,
12
+ KeyRound,
13
+ LoaderCircle,
14
+ LogIn,
15
+ ServerCog,
16
+ Upload,
17
+ } from "lucide-react";
18
+ import { toast } from "sonner";
19
+
20
+ import { Button } from "@/components/ui/button";
21
+ import { Card, CardContent } from "@/components/ui/card";
22
+ import {
23
+ Dialog,
24
+ DialogContent,
25
+ DialogDescription,
26
+ DialogFooter,
27
+ DialogHeader,
28
+ DialogTitle,
29
+ } from "@/components/ui/dialog";
30
+ import { Textarea } from "@/components/ui/textarea";
31
+ import {
32
+ createAccounts,
33
+ finishOAuthLogin,
34
+ startOAuthLogin,
35
+ type Account,
36
+ type AccountImportPayload,
37
+ type OAuthLoginStartResponse,
38
+ } from "@/lib/api";
39
+ import { cn } from "@/lib/utils";
40
+
41
+ type ImportMethod = "menu" | "token" | "session" | "codex-auth" | "account-json" | "oauth";
42
+
43
+ type AccountImportDialogProps = {
44
+ disabled?: boolean;
45
+ onImported: (items: Account[]) => void;
46
+ };
47
+
48
+ type PendingAccountJsonImport = {
49
+ tokens: string[];
50
+ accounts: AccountImportPayload[];
51
+ parsedAccountCount: number;
52
+ errorCount: number;
53
+ };
54
+
55
+ const sessionUrl = "https://chatgpt.com/api/auth/session";
56
+
57
+ function splitTokens(value: string) {
58
+ return value
59
+ .split(/\r?\n/)
60
+ .map((item) => item.trim())
61
+ .filter(Boolean);
62
+ }
63
+
64
+ function getSessionAccessToken(value: unknown) {
65
+ const token = (value as { accessToken?: unknown })?.accessToken;
66
+ return typeof token === "string" ? token.trim() : "";
67
+ }
68
+
69
+ function getAccountJsonAccount(value: unknown): AccountImportPayload | null {
70
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
71
+ return null;
72
+ }
73
+ const raw = value as Record<string, unknown>;
74
+ const tokenValue = raw.access_token ?? raw.accessToken;
75
+ const token = typeof tokenValue === "string" ? tokenValue.trim() : "";
76
+ if (!token) {
77
+ return null;
78
+ }
79
+
80
+ const payload: AccountImportPayload = {
81
+ ...raw,
82
+ access_token: token,
83
+ source_type: "codex",
84
+ };
85
+ delete payload.accessToken;
86
+ if (payload.type === "codex") {
87
+ payload.export_type = "codex";
88
+ delete payload.type;
89
+ }
90
+ return payload;
91
+ }
92
+
93
+ function getAccountJsonAccounts(value: unknown): AccountImportPayload[] {
94
+ if (Array.isArray(value)) {
95
+ return value
96
+ .map((item) => getAccountJsonAccount(item))
97
+ .filter((item): item is AccountImportPayload => Boolean(item));
98
+ }
99
+
100
+ const singleAccount = getAccountJsonAccount(value);
101
+ if (singleAccount) {
102
+ return [singleAccount];
103
+ }
104
+
105
+ if (value && typeof value === "object") {
106
+ const raw = value as Record<string, unknown>;
107
+ const nested = raw.accounts ?? raw.items;
108
+ if (Array.isArray(nested)) {
109
+ return nested
110
+ .map((item) => getAccountJsonAccount(item))
111
+ .filter((item): item is AccountImportPayload => Boolean(item));
112
+ }
113
+ }
114
+
115
+ return [];
116
+ }
117
+
118
+ function getCodexAuthAccount(value: unknown): AccountImportPayload | null {
119
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
120
+ return null;
121
+ }
122
+ const raw = value as Record<string, unknown>;
123
+ const tokenValue = raw.access_token ?? raw.accessToken;
124
+ const token = typeof tokenValue === "string" ? tokenValue.trim() : "";
125
+ if (!token) {
126
+ return null;
127
+ }
128
+
129
+ const payload: AccountImportPayload = {
130
+ ...raw,
131
+ access_token: token,
132
+ export_type: "codex",
133
+ source_type: "codex",
134
+ };
135
+ delete payload.accessToken;
136
+ if (payload.type === "codex") {
137
+ delete payload.type;
138
+ }
139
+ return payload;
140
+ }
141
+
142
+ function readFileAsText(file: File) {
143
+ return new Promise<string>((resolve, reject) => {
144
+ const reader = new FileReader();
145
+ reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : "");
146
+ reader.onerror = () => reject(reader.error ?? new Error(`读取文件失败: ${file.name}`));
147
+ reader.readAsText(file);
148
+ });
149
+ }
150
+
151
+ function MethodCard({
152
+ title,
153
+ description,
154
+ icon: Icon,
155
+ onClick,
156
+ }: {
157
+ title: string;
158
+ description: string;
159
+ icon: typeof KeyRound;
160
+ onClick: () => void;
161
+ }) {
162
+ return (
163
+ <button
164
+ type="button"
165
+ onClick={onClick}
166
+ className="w-full rounded-2xl border border-stone-200 bg-white p-0 text-left transition hover:border-stone-300 hover:bg-stone-50"
167
+ >
168
+ <Card className="rounded-2xl border-0 bg-transparent shadow-none">
169
+ <CardContent className="flex items-start gap-4 p-4">
170
+ <div className="rounded-xl bg-stone-100 p-3 text-stone-700">
171
+ <Icon className="size-5" />
172
+ </div>
173
+ <div className="space-y-1">
174
+ <div className="text-sm font-semibold text-stone-900">{title}</div>
175
+ <div className="text-sm leading-6 text-stone-500">{description}</div>
176
+ </div>
177
+ </CardContent>
178
+ </Card>
179
+ </button>
180
+ );
181
+ }
182
+
183
+ export function AccountImportDialog({ disabled, onImported }: AccountImportDialogProps) {
184
+ const router = useRouter();
185
+ const [open, setOpen] = useState(false);
186
+ const [method, setMethod] = useState<ImportMethod>("menu");
187
+ const [tokenInput, setTokenInput] = useState("");
188
+ const [sessionInput, setSessionInput] = useState("");
189
+ const [codexAuthInput, setCodexAuthInput] = useState("");
190
+ const [isSubmitting, setIsSubmitting] = useState(false);
191
+ const [pendingAccountJsonImport, setPendingAccountJsonImport] = useState<PendingAccountJsonImport | null>(null);
192
+ const [confirmOpen, setConfirmOpen] = useState(false);
193
+ const [oauthEmailHint, setOauthEmailHint] = useState("");
194
+ const [oauthSession, setOauthSession] = useState<OAuthLoginStartResponse | null>(null);
195
+ const [oauthCallbackInput, setOauthCallbackInput] = useState("");
196
+ const [oauthStarting, setOauthStarting] = useState(false);
197
+
198
+ const txtInputRef = useRef<HTMLInputElement | null>(null);
199
+ const accountJsonInputRef = useRef<HTMLInputElement | null>(null);
200
+
201
+ const resetState = () => {
202
+ setMethod("menu");
203
+ setTokenInput("");
204
+ setSessionInput("");
205
+ setCodexAuthInput("");
206
+ setPendingAccountJsonImport(null);
207
+ setConfirmOpen(false);
208
+ setOauthEmailHint("");
209
+ setOauthSession(null);
210
+ setOauthCallbackInput("");
211
+ setOauthStarting(false);
212
+ };
213
+
214
+ const handleOpenChange = (nextOpen: boolean) => {
215
+ setOpen(nextOpen);
216
+ if (!nextOpen) {
217
+ resetState();
218
+ }
219
+ };
220
+
221
+ const submitTokens = async (tokens: string[], successText?: string, accountPayloads: AccountImportPayload[] = []) => {
222
+ const normalizedTokens = tokens.map((item) => item.trim()).filter(Boolean);
223
+
224
+ if (normalizedTokens.length === 0) {
225
+ toast.error("请先提供至少一个可用 Token");
226
+ return;
227
+ }
228
+
229
+ setIsSubmitting(true);
230
+ try {
231
+ const data = await createAccounts(normalizedTokens, accountPayloads);
232
+ onImported(data.items);
233
+ setOpen(false);
234
+ resetState();
235
+
236
+ if ((data.errors?.length ?? 0) > 0) {
237
+ const firstError = data.errors?.[0]?.error;
238
+ toast.error(
239
+ `${successText ?? "导入完成"},新增 ${data.added ?? 0} 个,已刷新 ${data.refreshed ?? 0} 个,失败 ${data.errors?.length ?? 0} 个${firstError ? `,首个错误:${firstError}` : ""}`,
240
+ );
241
+ } else {
242
+ toast.success(
243
+ `${successText ?? "导入完成"},新增 ${data.added ?? 0} 个,跳过 ${data.skipped ?? 0} 个重复项,已自动刷新账号信息`,
244
+ );
245
+ }
246
+ } catch (error) {
247
+ const message = error instanceof Error ? error.message : "导入账户失败";
248
+ toast.error(message);
249
+ } finally {
250
+ setIsSubmitting(false);
251
+ }
252
+ };
253
+
254
+ const handleImportTokenText = async () => {
255
+ await submitTokens(splitTokens(tokenInput), "Access Token 导入完成");
256
+ };
257
+
258
+ // 起授权:拿 authorize URL,立刻在新窗口打开,方便用户登录
259
+ const handleStartOAuth = async () => {
260
+ setOauthStarting(true);
261
+ try {
262
+ const data = await startOAuthLogin(oauthEmailHint.trim());
263
+ setOauthSession(data);
264
+ setOauthCallbackInput("");
265
+ if (typeof window !== "undefined") {
266
+ window.open(data.authorize_url, "_blank", "noopener,noreferrer");
267
+ }
268
+ toast.success("已打开 OpenAI 授权页面,请在登录后复制 callback URL 回来");
269
+ } catch (error) {
270
+ const message = error instanceof Error ? error.message : "OAuth 起始失败";
271
+ toast.error(message);
272
+ } finally {
273
+ setOauthStarting(false);
274
+ }
275
+ };
276
+
277
+ // 用粘贴回来的 callback URL 完成换 token + 落盘
278
+ const handleFinishOAuth = async () => {
279
+ if (!oauthSession) {
280
+ toast.error("请先点击\"打开授权页面\"获取 session");
281
+ return;
282
+ }
283
+ const trimmed = oauthCallbackInput.trim();
284
+ if (!trimmed) {
285
+ toast.error("请粘贴 callback URL 或 code");
286
+ return;
287
+ }
288
+
289
+ setIsSubmitting(true);
290
+ try {
291
+ const data = await finishOAuthLogin(oauthSession.session_id, trimmed);
292
+ onImported(data.items);
293
+ setOpen(false);
294
+ resetState();
295
+
296
+ if ((data.errors?.length ?? 0) > 0) {
297
+ const firstError = data.errors?.[0]?.error;
298
+ toast.error(
299
+ `OAuth 登录完成,新增 ${data.added ?? 0} 个,已刷新 ${data.refreshed ?? 0} 个,失败 ${data.errors?.length ?? 0} 个${firstError ? `,首个错误:${firstError}` : ""}`,
300
+ );
301
+ } else {
302
+ toast.success(
303
+ `OAuth 登录完成,新增 ${data.added ?? 0} 个,跳过 ${data.skipped ?? 0} 个重复项,已自动刷新账号信息`,
304
+ );
305
+ }
306
+ } catch (error) {
307
+ const message = error instanceof Error ? error.message : "OAuth 换 token 失败";
308
+ toast.error(message);
309
+ } finally {
310
+ setIsSubmitting(false);
311
+ }
312
+ };
313
+
314
+ // 复制 authorize URL 到剪贴板(适配浏览器和 fallback)
315
+ const handleCopyAuthorizeUrl = async () => {
316
+ if (!oauthSession) {
317
+ return;
318
+ }
319
+ try {
320
+ if (navigator.clipboard?.writeText) {
321
+ await navigator.clipboard.writeText(oauthSession.authorize_url);
322
+ toast.success("授权 URL 已复制到剪贴板");
323
+ } else {
324
+ toast.error("当前环境不支持自动复制,请手动选择并复制");
325
+ }
326
+ } catch {
327
+ toast.error("复制失败,请手动选择并复制");
328
+ }
329
+ };
330
+
331
+ const handleTxtSelected = async (event: ChangeEvent<HTMLInputElement>) => {
332
+ const file = event.target.files?.[0];
333
+ event.target.value = "";
334
+
335
+ if (!file) {
336
+ return;
337
+ }
338
+
339
+ try {
340
+ const content = await readFileAsText(file);
341
+ const tokens = splitTokens(content);
342
+
343
+ if (tokens.length === 0) {
344
+ toast.error("TXT 文件里没有读取到有效 Token");
345
+ return;
346
+ }
347
+
348
+ setTokenInput((prev) => {
349
+ const next = [...splitTokens(prev), ...tokens];
350
+ return next.join("\n");
351
+ });
352
+ toast.success(`已从 ${file.name} 读取 ${tokens.length} 个 Token`);
353
+ } catch (error) {
354
+ const message = error instanceof Error ? error.message : "读取 TXT 文件失败";
355
+ toast.error(message);
356
+ }
357
+ };
358
+
359
+ const handleImportSessionJson = async () => {
360
+ if (!sessionInput.trim()) {
361
+ toast.error("请先粘贴完整 Session JSON");
362
+ return;
363
+ }
364
+
365
+ try {
366
+ const payload = JSON.parse(sessionInput) as unknown;
367
+ const token = getSessionAccessToken(payload);
368
+
369
+ if (!token) {
370
+ toast.error("未从 Session JSON 中提取到 accessToken");
371
+ return;
372
+ }
373
+
374
+ await submitTokens([token], "Session JSON 导入完成");
375
+ } catch (error) {
376
+ const message = error instanceof Error ? error.message : "Session JSON 解析失败";
377
+ toast.error(message);
378
+ }
379
+ };
380
+
381
+ const handleImportCodexAuthJson = async () => {
382
+ if (!codexAuthInput.trim()) {
383
+ toast.error("请先粘贴 Codex 认证 JSON");
384
+ return;
385
+ }
386
+
387
+ try {
388
+ const payload = JSON.parse(codexAuthInput) as unknown;
389
+ const account = getCodexAuthAccount(payload);
390
+
391
+ if (!account) {
392
+ toast.error("未从 Codex 认证 JSON 中提取到 access_token");
393
+ return;
394
+ }
395
+
396
+ await submitTokens([account.access_token], "Codex 认证 JSON 导入完成", [account]);
397
+ } catch (error) {
398
+ const message = error instanceof Error ? error.message : "Codex 认证 JSON 解析失败";
399
+ toast.error(message);
400
+ }
401
+ };
402
+
403
+ const handleAccountJsonSelected = async (event: ChangeEvent<HTMLInputElement>) => {
404
+ const files = Array.from(event.target.files ?? []);
405
+ event.target.value = "";
406
+
407
+ if (files.length === 0) {
408
+ return;
409
+ }
410
+
411
+ try {
412
+ const results = await Promise.all(
413
+ files.map(async (file) => {
414
+ const raw = await readFileAsText(file);
415
+ const parsed = JSON.parse(raw) as unknown;
416
+ const accounts = getAccountJsonAccounts(parsed);
417
+ return {
418
+ accounts,
419
+ };
420
+ }),
421
+ );
422
+
423
+ const accounts = results.flatMap((item) => item.accounts);
424
+ const tokens = accounts.map((item) => item.access_token);
425
+ const parsedAccountCount = accounts.length;
426
+ const errorCount = results.filter((item) => item.accounts.length === 0).length;
427
+
428
+ if (parsedAccountCount === 0) {
429
+ toast.error("这些账号 JSON 文件里没有读取到可用 access_token");
430
+ return;
431
+ }
432
+
433
+ setPendingAccountJsonImport({
434
+ tokens,
435
+ accounts,
436
+ parsedAccountCount,
437
+ errorCount,
438
+ });
439
+ setConfirmOpen(true);
440
+ } catch (error) {
441
+ const message = error instanceof Error ? error.message : "读取账号 JSON 文件失败";
442
+ toast.error(message);
443
+ }
444
+ };
445
+
446
+ const renderMethodBody = () => {
447
+ if (method === "token") {
448
+ const tokenCount = splitTokens(tokenInput).length;
449
+
450
+ return (
451
+ <div className="space-y-4">
452
+ <div className="flex items-center justify-between">
453
+ <button
454
+ type="button"
455
+ onClick={() => setMethod("menu")}
456
+ className="inline-flex items-center gap-1 text-sm text-stone-500 transition hover:text-stone-800"
457
+ >
458
+ <ArrowLeft className="size-4" />
459
+ 返回导入方式
460
+ </button>
461
+ <span className="text-xs text-stone-400">当前识别 {tokenCount} 个 Token</span>
462
+ </div>
463
+ <div className="space-y-2">
464
+ <label className="text-sm font-medium text-stone-700">Access Token 列表</label>
465
+ <Textarea
466
+ placeholder="每行一个 Access Token..."
467
+ value={tokenInput}
468
+ onChange={(event) => setTokenInput(event.target.value)}
469
+ className="min-h-56 resize-none rounded-xl border-stone-200"
470
+ />
471
+ </div>
472
+ <div className="rounded-2xl border border-dashed border-stone-200 bg-stone-50 p-4">
473
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
474
+ <div className="space-y-1">
475
+ <div className="text-sm font-medium text-stone-800">从 TXT 文件导入</div>
476
+ <div className="text-sm leading-6 text-stone-500">支持 `.txt`,文件内容也是一行一个 Token。</div>
477
+ </div>
478
+ <Button
479
+ type="button"
480
+ variant="outline"
481
+ className="rounded-xl border-stone-200 bg-white"
482
+ onClick={() => txtInputRef.current?.click()}
483
+ disabled={isSubmitting}
484
+ >
485
+ <FileText className="size-4" />
486
+ 选择 TXT
487
+ </Button>
488
+ </div>
489
+ </div>
490
+ <input
491
+ ref={txtInputRef}
492
+ type="file"
493
+ accept=".txt,text/plain"
494
+ className="hidden"
495
+ onChange={(event) => void handleTxtSelected(event)}
496
+ />
497
+ </div>
498
+ );
499
+ }
500
+
501
+ if (method === "session") {
502
+ return (
503
+ <div className="space-y-4">
504
+ <button
505
+ type="button"
506
+ onClick={() => setMethod("menu")}
507
+ className="inline-flex items-center gap-1 text-sm text-stone-500 transition hover:text-stone-800"
508
+ >
509
+ <ArrowLeft className="size-4" />
510
+ 返回导入方式
511
+ </button>
512
+ <div className="rounded-2xl border border-stone-200 bg-stone-50 p-4 text-sm leading-6 text-stone-600">
513
+ 打开
514
+ {" "}
515
+ <a
516
+ href={sessionUrl}
517
+ target="_blank"
518
+ rel="noreferrer"
519
+ className="inline-flex items-center gap-1 font-medium text-stone-900 underline underline-offset-4"
520
+ >
521
+ {sessionUrl}
522
+ <ExternalLink className="size-3.5" />
523
+ </a>
524
+ ,复制页面返回的完整 JSON,系统会自动提取其中的 `accessToken` 导入。
525
+ </div>
526
+ <div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm leading-6 text-amber-900">
527
+ <div className="font-medium">风险提示</div>
528
+ <div>
529
+ 不要使用自己的大号,尽量使用不常用的小号进行导入,避免出现封号风险。本项目不承担任何封号风险责任。
530
+ </div>
531
+ </div>
532
+ <div className="space-y-2">
533
+ <label className="text-sm font-medium text-stone-700">Session JSON</label>
534
+ <Textarea
535
+ placeholder='粘贴完整 JSON,例如包含 "accessToken" 的对象...'
536
+ value={sessionInput}
537
+ onChange={(event) => setSessionInput(event.target.value)}
538
+ className="min-h-56 resize-none rounded-xl border-stone-200 font-mono text-xs"
539
+ />
540
+ </div>
541
+ </div>
542
+ );
543
+ }
544
+
545
+ if (method === "oauth") {
546
+ return (
547
+ <div className="space-y-4">
548
+ <button
549
+ type="button"
550
+ onClick={() => setMethod("menu")}
551
+ className="inline-flex items-center gap-1 text-sm text-stone-500 transition hover:text-stone-800"
552
+ >
553
+ <ArrowLeft className="size-4" />
554
+ 返回导入方式
555
+ </button>
556
+ <div className="rounded-2xl border border-stone-200 bg-stone-50 p-4 text-sm leading-6 text-stone-600 space-y-2">
557
+ <div className="font-medium text-stone-800">操作步骤</div>
558
+ <ol className="list-decimal pl-5 space-y-1">
559
+ <li>(可选)填写你 ChatGPT 账号的邮箱,登录页会预填。</li>
560
+ <li>点击下方"打开授权页面",在新标签里登录自己的 ChatGPT 账号。</li>
561
+ <li>登录完成后浏览器会跳到 <code className="rounded bg-stone-200 px-1">platform.openai.com/auth/callback?code=...</code>。立刻从地址栏复制整段 URL(或开 F12 在 Network 里抓到 callback 那一行,右键 Copy → Copy URL)。</li>
562
+ <li>把 callback URL 粘到下面输入框,点"完成导入"。</li>
563
+ </ol>
564
+ </div>
565
+ <div className="space-y-2">
566
+ <label className="text-sm font-medium text-stone-700">邮箱(可选预填)</label>
567
+ <input
568
+ type="email"
569
+ placeholder="you@example.com"
570
+ value={oauthEmailHint}
571
+ onChange={(event) => setOauthEmailHint(event.target.value)}
572
+ disabled={Boolean(oauthSession) || oauthStarting}
573
+ className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm outline-none focus:border-stone-400"
574
+ />
575
+ </div>
576
+ {!oauthSession ? (
577
+ <Button
578
+ type="button"
579
+ className="h-10 rounded-xl bg-stone-950 text-white hover:bg-stone-800"
580
+ onClick={() => void handleStartOAuth()}
581
+ disabled={oauthStarting}
582
+ >
583
+ {oauthStarting ? <LoaderCircle className="size-4 animate-spin" /> : <ExternalLink className="size-4" />}
584
+ 打开授权页面
585
+ </Button>
586
+ ) : (
587
+ <div className="space-y-3">
588
+ <div className="rounded-2xl border border-stone-200 bg-white p-3 text-xs leading-6 text-stone-600 break-all font-mono">
589
+ {oauthSession.authorize_url}
590
+ </div>
591
+ <div className="flex flex-wrap gap-2">
592
+ <Button
593
+ type="button"
594
+ variant="outline"
595
+ className="rounded-xl border-stone-200 bg-white"
596
+ onClick={() => void handleCopyAuthorizeUrl()}
597
+ >
598
+ <Copy className="size-4" />
599
+ 复制授权 URL
600
+ </Button>
601
+ <Button
602
+ type="button"
603
+ variant="outline"
604
+ className="rounded-xl border-stone-200 bg-white"
605
+ onClick={() => window.open(oauthSession.authorize_url, "_blank", "noopener,noreferrer")}
606
+ >
607
+ <ExternalLink className="size-4" />
608
+ 再次打开
609
+ </Button>
610
+ <Button
611
+ type="button"
612
+ variant="outline"
613
+ className="rounded-xl border-stone-200 bg-white"
614
+ onClick={() => {
615
+ setOauthSession(null);
616
+ setOauthCallbackInput("");
617
+ }}
618
+ >
619
+ 重新生成
620
+ </Button>
621
+ </div>
622
+ <div className="space-y-2">
623
+ <label className="text-sm font-medium text-stone-700">粘贴 callback URL(或仅 code)</label>
624
+ <Textarea
625
+ placeholder={"https://platform.openai.com/auth/callback?code=...&state=..."}
626
+ value={oauthCallbackInput}
627
+ onChange={(event) => setOauthCallbackInput(event.target.value)}
628
+ className="min-h-24 resize-none rounded-xl border-stone-200 font-mono text-xs"
629
+ />
630
+ </div>
631
+ </div>
632
+ )}
633
+ <div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm leading-6 text-amber-900">
634
+ <div className="font-medium">注意</div>
635
+ <div>
636
+ 授权码(code)只能使用一次。如果浏览器的 callback 页加载完成、显示了 OpenAI 的错误页,那 code 大概率已经被消耗,
637
+ 请点击"重新生成"再走一次。整个流程在 10 分钟内完成即可。
638
+ </div>
639
+ </div>
640
+ </div>
641
+ );
642
+ }
643
+
644
+ if (method === "account-json") {
645
+ return (
646
+ <div className="space-y-4">
647
+ <button
648
+ type="button"
649
+ onClick={() => setMethod("menu")}
650
+ className="inline-flex items-center gap-1 text-sm text-stone-500 transition hover:text-stone-800"
651
+ >
652
+ <ArrowLeft className="size-4" />
653
+ 返回导入方式
654
+ </button>
655
+ <div className="rounded-2xl border border-dashed border-stone-200 bg-stone-50 p-5">
656
+ <div className="space-y-2">
657
+ <div className="text-sm font-medium text-stone-800">选择本地账号 JSON 文件</div>
658
+ <div className="text-sm leading-6 text-stone-500">
659
+ 支持本项目导出的单账号对象或全部账号数组,也兼容每个文件一个账号对象的 CPA JSON。
660
+ 系统会自动提取 `access_token` 或 `accessToken`。
661
+ </div>
662
+ </div>
663
+ <Button
664
+ type="button"
665
+ className="mt-4 rounded-xl bg-stone-950 text-white hover:bg-stone-800"
666
+ onClick={() => accountJsonInputRef.current?.click()}
667
+ disabled={isSubmitting}
668
+ >
669
+ <Files className="size-4" />
670
+ 选择 JSON 文件
671
+ </Button>
672
+ </div>
673
+ <input
674
+ ref={accountJsonInputRef}
675
+ type="file"
676
+ accept=".json,application/json"
677
+ multiple
678
+ className="hidden"
679
+ onChange={(event) => void handleAccountJsonSelected(event)}
680
+ />
681
+ {pendingAccountJsonImport ? (
682
+ <div className="rounded-2xl border border-stone-200 bg-white p-4 text-sm leading-6 text-stone-600">
683
+ 最近一次读取到 {pendingAccountJsonImport.parsedAccountCount} 个 Token
684
+ {pendingAccountJsonImport.errorCount > 0 ? `,另有 ${pendingAccountJsonImport.errorCount} 个文件未提取成功` : ""}。
685
+ </div>
686
+ ) : null}
687
+ </div>
688
+ );
689
+ }
690
+
691
+ if (method === "codex-auth") {
692
+ return (
693
+ <div className="space-y-4">
694
+ <button
695
+ type="button"
696
+ onClick={() => setMethod("menu")}
697
+ className="inline-flex items-center gap-1 text-sm text-stone-500 transition hover:text-stone-800"
698
+ >
699
+ <ArrowLeft className="size-4" />
700
+ 返回导入方式
701
+ </button>
702
+ <div className="space-y-2">
703
+ <label className="text-sm font-medium text-stone-700">Codex 认证 JSON</label>
704
+ <Textarea
705
+ placeholder='粘贴包含 "access_token"、"refresh_token"、"id_token" 的 Codex 认证 JSON...'
706
+ value={codexAuthInput}
707
+ onChange={(event) => setCodexAuthInput(event.target.value)}
708
+ className="min-h-64 resize-none rounded-xl border-stone-200 font-mono text-xs"
709
+ />
710
+ </div>
711
+ </div>
712
+ );
713
+ }
714
+
715
+ return (
716
+ <div className="space-y-3">
717
+ <MethodCard
718
+ title="OAuth 登录已有账号(带自动刷新)"
719
+ description="用浏览器登录自己的 ChatGPT 账号,回填 callback URL 即可拿到 refresh_token,后台会自动续期。"
720
+ icon={LogIn}
721
+ onClick={() => setMethod("oauth")}
722
+ />
723
+ <MethodCard
724
+ title="导入 Access Token"
725
+ description="支持直接粘贴,一行一个;也支持从 TXT 文件读取,一行一个。"
726
+ icon={KeyRound}
727
+ onClick={() => setMethod("token")}
728
+ />
729
+ <MethodCard
730
+ title="导入 Session JSON"
731
+ description="从 chatgpt.com 的 session 接口复制完整 JSON,自动提取 accessToken。"
732
+ icon={FileJson}
733
+ onClick={() => setMethod("session")}
734
+ />
735
+ <MethodCard
736
+ title="导入 Codex 认证 JSON"
737
+ description="粘贴 Codex 认证 JSON,导入后账号来源标记为 codex。"
738
+ icon={FileJson}
739
+ onClick={() => setMethod("codex-auth")}
740
+ />
741
+ <MethodCard
742
+ title="导入账号 JSON 文件"
743
+ description="支持本项目导出的单账号 JSON 或全部账号数组,也兼容 CPA JSON 文件。"
744
+ icon={Files}
745
+ onClick={() => setMethod("account-json")}
746
+ />
747
+ <MethodCard
748
+ title="从远程 CPA 服务器导入"
749
+ description="前往设置页面配置远程 CPA 服务器后再执行导入。"
750
+ icon={Files}
751
+ onClick={() => {
752
+ setOpen(false);
753
+ resetState();
754
+ router.push("/settings");
755
+ }}
756
+ />
757
+ <MethodCard
758
+ title="从 Sub2API 服务器导入"
759
+ description="前往设置页面配置 Sub2API 服务器,再选择其中的 OpenAI 账号导入。"
760
+ icon={ServerCog}
761
+ onClick={() => {
762
+ setOpen(false);
763
+ resetState();
764
+ router.push("/settings");
765
+ }}
766
+ />
767
+ </div>
768
+ );
769
+ };
770
+
771
+ const footerDisabled = disabled || isSubmitting;
772
+
773
+ return (
774
+ <>
775
+ <Dialog open={open} onOpenChange={handleOpenChange}>
776
+ <Button
777
+ className="h-10 rounded-xl bg-stone-950 px-4 text-white hover:bg-stone-800"
778
+ onClick={() => setOpen(true)}
779
+ disabled={disabled}
780
+ >
781
+ <Upload className="size-4" />
782
+ 导入
783
+ </Button>
784
+ <DialogContent showCloseButton={false} className="rounded-2xl p-6">
785
+ <DialogHeader className="gap-2">
786
+ <DialogTitle>
787
+ {method === "menu"
788
+ ? "导入账户"
789
+ : method === "token"
790
+ ? "导入 Access Token"
791
+ : method === "session"
792
+ ? "导入 Session JSON"
793
+ : method === "codex-auth"
794
+ ? "导入 Codex 认证 JSON"
795
+ : method === "oauth"
796
+ ? "OAuth 登录已有账号"
797
+ : "导入账号 JSON"}
798
+ </DialogTitle>
799
+ <DialogDescription className="text-sm leading-6">
800
+ {method === "menu"
801
+ ? "选择一种导入方式。导入成功后会自动拉取邮箱、类型和额度。"
802
+ : method === "token"
803
+ ? "支持手动粘贴或从 TXT 文件导入,一行一个 Token。"
804
+ : method === "session"
805
+ ? "粘贴完整 Session JSON,系统会自动提取 accessToken。"
806
+ : method === "codex-auth"
807
+ ? "粘贴 Codex 认证 JSON,系统会按 codex 来源导入。"
808
+ : method === "oauth"
809
+ ? "用浏览器跑一遍 OpenAI 标准 OAuth,拿回 refresh_token 后系统会自动续期。"
810
+ : "支持读取本项目导出的单账号对象或全部账号数组,并在提交前做数量确认。"}
811
+ </DialogDescription>
812
+ </DialogHeader>
813
+
814
+ {renderMethodBody()}
815
+
816
+ <DialogFooter className="pt-2">
817
+ <Button
818
+ variant="secondary"
819
+ className="h-10 rounded-xl bg-stone-100 px-5 text-stone-700 hover:bg-stone-200"
820
+ onClick={() => setOpen(false)}
821
+ disabled={footerDisabled}
822
+ >
823
+ 取消
824
+ </Button>
825
+ {method === "token" ? (
826
+ <Button
827
+ className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
828
+ onClick={() => void handleImportTokenText()}
829
+ disabled={footerDisabled}
830
+ >
831
+ {isSubmitting ? <LoaderCircle className="size-4 animate-spin" /> : null}
832
+ 导入 Token
833
+ </Button>
834
+ ) : null}
835
+ {method === "session" ? (
836
+ <Button
837
+ className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
838
+ onClick={() => void handleImportSessionJson()}
839
+ disabled={footerDisabled}
840
+ >
841
+ {isSubmitting ? <LoaderCircle className="size-4 animate-spin" /> : null}
842
+ 导入 JSON
843
+ </Button>
844
+ ) : null}
845
+ {method === "codex-auth" ? (
846
+ <Button
847
+ className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
848
+ onClick={() => void handleImportCodexAuthJson()}
849
+ disabled={footerDisabled}
850
+ >
851
+ {isSubmitting ? <LoaderCircle className="size-4 animate-spin" /> : null}
852
+ 导入 JSON
853
+ </Button>
854
+ ) : null}
855
+ {method === "oauth" ? (
856
+ <Button
857
+ className={cn(
858
+ "h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800",
859
+ !oauthSession ? "hidden" : "",
860
+ )}
861
+ onClick={() => void handleFinishOAuth()}
862
+ disabled={footerDisabled || !oauthSession || !oauthCallbackInput.trim()}
863
+ >
864
+ {isSubmitting ? <LoaderCircle className="size-4 animate-spin" /> : null}
865
+ 完成导入
866
+ </Button>
867
+ ) : null}
868
+ {method === "account-json" ? (
869
+ <Button
870
+ className={cn(
871
+ "h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800",
872
+ !pendingAccountJsonImport ? "hidden" : "",
873
+ )}
874
+ onClick={() => setConfirmOpen(true)}
875
+ disabled={footerDisabled || !pendingAccountJsonImport}
876
+ >
877
+ 查看导入确认
878
+ </Button>
879
+ ) : null}
880
+ </DialogFooter>
881
+ </DialogContent>
882
+ </Dialog>
883
+
884
+ <Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
885
+ <DialogContent className="rounded-2xl p-6">
886
+ <DialogHeader className="gap-2">
887
+ <DialogTitle>确认导入账号 Token</DialogTitle>
888
+ <DialogDescription className="text-sm leading-6">
889
+ {pendingAccountJsonImport
890
+ ? `确认识别到 ${pendingAccountJsonImport.parsedAccountCount} 个 Token,是否确认导入?`
891
+ : "尚未读取到可导入的 Token。"}
892
+ {pendingAccountJsonImport?.errorCount
893
+ ? `,另有 ${pendingAccountJsonImport.errorCount} 个文件未提取成功。`
894
+ : "。"}
895
+ </DialogDescription>
896
+ </DialogHeader>
897
+ <DialogFooter className="pt-2">
898
+ <Button
899
+ variant="secondary"
900
+ className="h-10 rounded-xl bg-stone-100 px-5 text-stone-700 hover:bg-stone-200"
901
+ onClick={() => setConfirmOpen(false)}
902
+ disabled={isSubmitting}
903
+ >
904
+ 返回
905
+ </Button>
906
+ <Button
907
+ className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
908
+ onClick={() =>
909
+ void submitTokens(
910
+ pendingAccountJsonImport?.tokens ?? [],
911
+ "账号 JSON 导入完成",
912
+ pendingAccountJsonImport?.accounts ?? [],
913
+ )
914
+ }
915
+ disabled={isSubmitting || !pendingAccountJsonImport}
916
+ >
917
+ {isSubmitting ? <LoaderCircle className="size-4 animate-spin" /> : null}
918
+ 确认导入
919
+ </Button>
920
+ </DialogFooter>
921
+ </DialogContent>
922
+ </Dialog>
923
+ </>
924
+ );
925
+ }