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,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
+ }