dsh-plugin-image-workbench 0.0.0-stage → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +258 -0
  3. package/README.md +260 -2
  4. package/client.js +241 -0
  5. package/cordis.patch.yml +14 -0
  6. package/host.js +692 -0
  7. package/install.mjs +333 -0
  8. package/package.json +58 -4
  9. package/scripts/fetch-style-images.mjs +104 -0
  10. package/scripts/vendor-snapshot.py +154 -0
  11. package/server/access.py +26 -0
  12. package/server/app.py +1329 -0
  13. package/server/catalog.json +1 -0
  14. package/server/catalog.py +147 -0
  15. package/server/composer.py +391 -0
  16. package/server/drafts.py +158 -0
  17. package/server/history.py +575 -0
  18. package/server/imaging.py +172 -0
  19. package/server/pool.py +1335 -0
  20. package/server/pool_admin.py +921 -0
  21. package/server/pool_service.py +1073 -0
  22. package/server/requirements.txt +6 -0
  23. package/server/smoke_check.py +402 -0
  24. package/server/static/access.html +202 -0
  25. package/server/static/index.html +4725 -0
  26. package/vendor/chatgpt2api/README.md +13 -0
  27. package/vendor/chatgpt2api/SOURCE.json +11 -0
  28. package/vendor/chatgpt2api/runtime/uv.exe +0 -0
  29. package/vendor/chatgpt2api/source/.env.example +82 -0
  30. package/vendor/chatgpt2api/source/.python-version +1 -0
  31. package/vendor/chatgpt2api/source/LICENSE +21 -0
  32. package/vendor/chatgpt2api/source/README.md +391 -0
  33. package/vendor/chatgpt2api/source/VERSION +1 -0
  34. package/vendor/chatgpt2api/source/api/__init__.py +2 -0
  35. package/vendor/chatgpt2api/source/api/accounts.py +537 -0
  36. package/vendor/chatgpt2api/source/api/ai.py +217 -0
  37. package/vendor/chatgpt2api/source/api/app.py +62 -0
  38. package/vendor/chatgpt2api/source/api/errors.py +46 -0
  39. package/vendor/chatgpt2api/source/api/image_inputs.py +309 -0
  40. package/vendor/chatgpt2api/source/api/image_tasks.py +120 -0
  41. package/vendor/chatgpt2api/source/api/support.py +134 -0
  42. package/vendor/chatgpt2api/source/api/system.py +365 -0
  43. package/vendor/chatgpt2api/source/assets/atlascloud.svg +1 -0
  44. package/vendor/chatgpt2api/source/assets/ipwo.png +0 -0
  45. package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -0
  46. package/vendor/chatgpt2api/source/config.default.json +108 -0
  47. package/vendor/chatgpt2api/source/main.py +9 -0
  48. package/vendor/chatgpt2api/source/pyproject.toml +27 -0
  49. package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -0
  50. package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -0
  51. package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -0
  52. package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -0
  53. package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -0
  54. package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -0
  55. package/vendor/chatgpt2api/source/services/__init__.py +0 -0
  56. package/vendor/chatgpt2api/source/services/account_service.py +1757 -0
  57. package/vendor/chatgpt2api/source/services/auth_service.py +253 -0
  58. package/vendor/chatgpt2api/source/services/backup_service.py +671 -0
  59. package/vendor/chatgpt2api/source/services/config.py +667 -0
  60. package/vendor/chatgpt2api/source/services/content_filter.py +242 -0
  61. package/vendor/chatgpt2api/source/services/cpa_service.py +315 -0
  62. package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -0
  63. package/vendor/chatgpt2api/source/services/image_service.py +376 -0
  64. package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -0
  65. package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -0
  66. package/vendor/chatgpt2api/source/services/image_task_service.py +550 -0
  67. package/vendor/chatgpt2api/source/services/log_service.py +337 -0
  68. package/vendor/chatgpt2api/source/services/model_service.py +182 -0
  69. package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -0
  70. package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -0
  71. package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -0
  72. package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -0
  73. package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -0
  74. package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -0
  75. package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -0
  76. package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -0
  77. package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -0
  78. package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -0
  79. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -0
  80. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -0
  81. package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -0
  82. package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -0
  83. package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -0
  84. package/vendor/chatgpt2api/source/services/proxy_service.py +648 -0
  85. package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -0
  86. package/vendor/chatgpt2api/source/services/storage/base.py +38 -0
  87. package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -0
  88. package/vendor/chatgpt2api/source/services/storage/factory.py +104 -0
  89. package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -0
  90. package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -0
  91. package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -0
  92. package/vendor/chatgpt2api/source/utils/__init__.py +0 -0
  93. package/vendor/chatgpt2api/source/utils/helper.py +469 -0
  94. package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -0
  95. package/vendor/chatgpt2api/source/utils/log.py +110 -0
  96. package/vendor/chatgpt2api/source/utils/pkce.py +19 -0
  97. package/vendor/chatgpt2api/source/utils/pow.py +207 -0
  98. package/vendor/chatgpt2api/source/utils/sentinel.py +159 -0
  99. package/vendor/chatgpt2api/source/utils/turnstile.py +184 -0
  100. package/vendor/chatgpt2api/source/uv.lock +904 -0
  101. package/vendor/chatgpt2api/source/web/bun.lock +1501 -0
  102. package/vendor/chatgpt2api/source/web/components.json +24 -0
  103. package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -0
  104. package/vendor/chatgpt2api/source/web/next.config.ts +40 -0
  105. package/vendor/chatgpt2api/source/web/package.json +58 -0
  106. package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -0
  107. package/vendor/chatgpt2api/source/web/public/github.svg +1 -0
  108. package/vendor/chatgpt2api/source/web/public/next.svg +1 -0
  109. package/vendor/chatgpt2api/source/web/public/openai.svg +1 -0
  110. package/vendor/chatgpt2api/source/web/public/vercel.svg +1 -0
  111. package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -0
  112. package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -0
  113. package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -0
  114. package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -0
  115. package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -0
  116. package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -0
  117. package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -0
  118. package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -0
  119. package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -0
  120. package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -0
  121. package/vendor/chatgpt2api/source/web/src/app/favicon.ico +0 -0
  122. package/vendor/chatgpt2api/source/web/src/app/globals.css +231 -0
  123. package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -0
  124. package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -0
  125. package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -0
  126. package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -0
  127. package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -0
  128. package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -0
  129. package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -0
  130. package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -0
  131. package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -0
  132. package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -0
  133. package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -0
  134. package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -0
  135. package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -0
  136. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -0
  137. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -0
  138. package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -0
  139. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -0
  140. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -0
  141. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -0
  142. package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -0
  143. package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -0
  144. package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -0
  145. package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -0
  146. package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -0
  147. package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -0
  148. package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -0
  149. package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -0
  150. package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -0
  151. package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -0
  152. package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -0
  153. package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -0
  154. package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -0
  155. package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -0
  156. package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -0
  157. package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -0
  158. package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -0
  159. package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -0
  160. package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -0
  161. package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -0
  162. package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -0
  163. package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -0
  164. package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -0
  165. package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -0
  166. package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -0
  167. package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -0
  168. package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -0
  169. package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -0
  170. package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -0
  171. package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -0
  172. package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -0
  173. package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -0
  174. package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -0
  175. package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -0
  176. package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -0
  177. package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -0
  178. package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -0
  179. package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -0
  180. package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -0
  181. package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -0
  182. package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -0
  183. package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -0
  184. package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -0
  185. package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -0
  186. package/vendor/chatgpt2api/source/web/tsconfig.json +42 -0
@@ -0,0 +1,1307 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useRef, useState } from "react";
4
+ import type { ComponentProps } from "react";
5
+ import {
6
+ Ban,
7
+ CheckCircle2,
8
+ ChevronLeft,
9
+ ChevronRight,
10
+ CircleAlert,
11
+ CircleOff,
12
+ Copy,
13
+ Download,
14
+ Link2,
15
+ LoaderCircle,
16
+ LogIn,
17
+ Pencil,
18
+ RefreshCw,
19
+ Search,
20
+ Trash2,
21
+ UserRound,
22
+ } from "lucide-react";
23
+ import { toast } from "sonner";
24
+
25
+ import { Badge } from "@/components/ui/badge";
26
+ import { Button } from "@/components/ui/button";
27
+ import { Card, CardContent } from "@/components/ui/card";
28
+ import { Checkbox } from "@/components/ui/checkbox";
29
+ import {
30
+ Dialog,
31
+ DialogContent,
32
+ DialogDescription,
33
+ DialogFooter,
34
+ DialogHeader,
35
+ DialogTitle,
36
+ } from "@/components/ui/dialog";
37
+ import { Input } from "@/components/ui/input";
38
+ import {
39
+ Select,
40
+ SelectContent,
41
+ SelectItem,
42
+ SelectTrigger,
43
+ SelectValue,
44
+ } from "@/components/ui/select";
45
+ import {
46
+ deleteAccounts,
47
+ fetchAccounts,
48
+ fetchModels,
49
+ fetchRefreshProgress,
50
+ fetchReLoginProgress,
51
+ reLoginAccounts,
52
+ refreshAccounts,
53
+ testProxy,
54
+ updateAccount,
55
+ type Account,
56
+ type AccountRefreshResponse,
57
+ type AccountStatus,
58
+ type Model,
59
+ type RefreshProgressResponse,
60
+ } from "@/lib/api";
61
+ import { useAuthGuard } from "@/lib/use-auth-guard";
62
+ import { cn } from "@/lib/utils";
63
+
64
+ import { AccountImportDialog } from "./components/account-import-dialog";
65
+
66
+ const accountStatusOptions: { label: string; value: AccountStatus | "all" }[] = [
67
+ { label: "全部状态", value: "all" },
68
+ { label: "正常", value: "正常" },
69
+ { label: "限流", value: "限流" },
70
+ { label: "异常", value: "异常" },
71
+ { label: "禁用", value: "禁用" },
72
+ ];
73
+
74
+ const statusMeta: Record<
75
+ AccountStatus,
76
+ {
77
+ icon: typeof CheckCircle2;
78
+ badge: ComponentProps<typeof Badge>["variant"];
79
+ }
80
+ > = {
81
+ 正常: { icon: CheckCircle2, badge: "success" },
82
+ 限流: { icon: CircleAlert, badge: "warning" },
83
+ 异常: { icon: CircleOff, badge: "danger" },
84
+ 禁用: { icon: Ban, badge: "secondary" },
85
+ };
86
+
87
+ const metricCards = [
88
+ { key: "total", label: "账户总数", color: "text-stone-900", icon: UserRound },
89
+ { key: "active", label: "正常账户", color: "text-emerald-600", icon: CheckCircle2 },
90
+ { key: "limited", label: "限流账户", color: "text-orange-500", icon: CircleAlert },
91
+ { key: "abnormal", label: "异常账户", color: "text-rose-500", icon: CircleOff },
92
+ { key: "disabled", label: "禁用账户", color: "text-stone-500", icon: Ban },
93
+ { key: "quota", label: "剩余额度", color: "text-blue-500", icon: RefreshCw },
94
+ ] as const;
95
+
96
+ function formatCompact(value: number) {
97
+ if (value >= 1000) {
98
+ return `${(value / 1000).toFixed(1)}k`;
99
+ }
100
+ return String(value);
101
+ }
102
+
103
+ function formatQuota(account: Account) {
104
+ return String(Math.max(0, account.quota));
105
+ }
106
+
107
+ function formatRestoreAt(value?: string | null) {
108
+ if (!value) {
109
+ return { absolute: "—", relative: "" };
110
+ }
111
+
112
+ const date = new Date(value);
113
+ if (Number.isNaN(date.getTime())) {
114
+ return { absolute: value, relative: "" };
115
+ }
116
+
117
+ const diffMs = Math.max(0, date.getTime() - Date.now());
118
+ const totalHours = Math.ceil(diffMs / (1000 * 60 * 60));
119
+ const days = Math.floor(totalHours / 24);
120
+ const hours = totalHours % 24;
121
+ const relative = diffMs > 0 ? `剩余 ${days}d ${hours}h` : "已到恢复时间";
122
+
123
+ const pad = (num: number) => String(num).padStart(2, "0");
124
+ const absolute = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
125
+ date.getHours(),
126
+ )}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
127
+
128
+ return { absolute, relative };
129
+ }
130
+
131
+ function formatQuotaSummary(accounts: Account[]) {
132
+ const availableAccounts = accounts.filter((account) => account.status === "正常");
133
+ return formatCompact(availableAccounts.reduce((sum, account) => sum + Math.max(0, account.quota), 0));
134
+ }
135
+
136
+ function maskToken(token?: string) {
137
+ if (!token) return "—";
138
+ if (token.length <= 18) return token;
139
+ return `${token.slice(0, 16)}...${token.slice(-8)}`;
140
+ }
141
+
142
+ function downloadTokens(accounts: Account[]) {
143
+ const content = `${accounts.map((account) => account.access_token).join("\n")}\n`;
144
+ const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
145
+ const url = URL.createObjectURL(blob);
146
+ const link = document.createElement("a");
147
+ link.href = url;
148
+ link.download = `accounts-${Date.now()}.txt`;
149
+ link.click();
150
+ URL.revokeObjectURL(url);
151
+ }
152
+
153
+ function displayAccountType(account: Account) {
154
+ return account.type || "Free";
155
+ }
156
+
157
+ function displayAccountSource(account: Account) {
158
+ const source = String(account.source_type || "").trim().toLowerCase();
159
+ if (!source) {
160
+ return "web";
161
+ }
162
+ if (source === "web") {
163
+ return "web";
164
+ }
165
+ return source;
166
+ }
167
+
168
+ function AccountsPageContent() {
169
+ const didLoadRef = useRef(false);
170
+ const [accounts, setAccounts] = useState<Account[]>([]);
171
+ const [availableModels, setAvailableModels] = useState<Model[]>([]);
172
+ const [selectedIds, setSelectedIds] = useState<string[]>([]);
173
+ const [query, setQuery] = useState("");
174
+ const [typeFilter, setTypeFilter] = useState("all");
175
+ const [statusFilter, setStatusFilter] = useState<AccountStatus | "all">("all");
176
+ const [page, setPage] = useState(1);
177
+ const [pageSize, setPageSize] = useState("10");
178
+ const [editingAccount, setEditingAccount] = useState<Account | null>(null);
179
+ const [editStatus, setEditStatus] = useState<AccountStatus>("正常");
180
+ const [editProxy, setEditProxy] = useState("");
181
+ const [isTestingProxy, setIsTestingProxy] = useState(false);
182
+ const [isLoading, setIsLoading] = useState(true);
183
+ const [isLoadingModels, setIsLoadingModels] = useState(true);
184
+ const [isRefreshing, setIsRefreshing] = useState(false);
185
+ const [refreshingTokens, setRefreshingTokens] = useState<Set<string>>(new Set());
186
+ const [isDeleting, setIsDeleting] = useState(false);
187
+ const [isUpdating, setIsUpdating] = useState(false);
188
+ const [isRelogining, setIsRelogining] = useState(false);
189
+ const [progress, setProgress] = useState<{
190
+ visible: boolean;
191
+ current: number;
192
+ total: number;
193
+ message: string;
194
+ email: string;
195
+ }>({
196
+ visible: false,
197
+ current: 0,
198
+ total: 0,
199
+ message: "",
200
+ email: "",
201
+ });
202
+ const progressRef = useRef<ReturnType<typeof setInterval> | null>(null);
203
+ const [refreshSummary, setRefreshSummary] = useState<Record<string, number | string> | null>(null);
204
+
205
+ const loadAccounts = async (silent = false) => {
206
+ if (!silent) {
207
+ setIsLoading(true);
208
+ }
209
+ try {
210
+ const data = await fetchAccounts();
211
+ setAccounts(data.items);
212
+ setSelectedIds((prev) => prev.filter((id) => data.items.some((item) => item.access_token === id)));
213
+ } catch (error) {
214
+ const message = error instanceof Error ? error.message : "加载账户失败";
215
+ toast.error(message);
216
+ } finally {
217
+ if (!silent) {
218
+ setIsLoading(false);
219
+ }
220
+ }
221
+ };
222
+
223
+ const loadModels = async () => {
224
+ setIsLoadingModels(true);
225
+ try {
226
+ const data = await fetchModels();
227
+ setAvailableModels(Array.isArray(data.data) ? data.data : []);
228
+ } catch (error) {
229
+ const message = error instanceof Error ? error.message : "加载模型列表失败";
230
+ toast.error(message);
231
+ } finally {
232
+ setIsLoadingModels(false);
233
+ }
234
+ };
235
+
236
+ useEffect(() => {
237
+ if (didLoadRef.current) {
238
+ return;
239
+ }
240
+ didLoadRef.current = true;
241
+ void loadAccounts();
242
+ void loadModels();
243
+
244
+ // 清理进度条定时器
245
+ return () => {
246
+ if (progressRef.current) clearInterval(progressRef.current);
247
+ };
248
+ }, []);
249
+
250
+ const filteredAccounts = useMemo(() => {
251
+ const normalizedQuery = query.trim().toLowerCase();
252
+ return accounts.filter((account) => {
253
+ const searchMatched =
254
+ normalizedQuery.length === 0 || (account.email ?? "").toLowerCase().includes(normalizedQuery);
255
+ const typeMatched = typeFilter === "all" || displayAccountType(account) === typeFilter;
256
+ const statusMatched = statusFilter === "all" || account.status === statusFilter;
257
+ return searchMatched && typeMatched && statusMatched;
258
+ });
259
+ }, [accounts, query, statusFilter, typeFilter]);
260
+
261
+ const pageCount = Math.max(1, Math.ceil(filteredAccounts.length / Number(pageSize)));
262
+ const safePage = Math.min(page, pageCount);
263
+ const startIndex = (safePage - 1) * Number(pageSize);
264
+ const currentRows = filteredAccounts.slice(startIndex, startIndex + Number(pageSize));
265
+ const allCurrentSelected =
266
+ currentRows.length > 0 && currentRows.every((row) => selectedIds.includes(row.access_token));
267
+
268
+ const summary = useMemo(() => {
269
+ const total = accounts.length;
270
+ const active = accounts.filter((item) => item.status === "正常").length;
271
+ const limited = accounts.filter((item) => item.status === "限流").length;
272
+ const abnormal = accounts.filter((item) => item.status === "异常").length;
273
+ const disabled = accounts.filter((item) => item.status === "禁用").length;
274
+ const quota = formatQuotaSummary(accounts);
275
+
276
+ return { total, active, limited, abnormal, disabled, quota };
277
+ }, [accounts]);
278
+
279
+ const accountTypeOptions = useMemo(
280
+ () => [
281
+ { label: "全部类型", value: "all" },
282
+ ...Array.from(new Set(accounts.map(displayAccountType))).map((type) => ({ label: type, value: type })),
283
+ ],
284
+ [accounts],
285
+ );
286
+
287
+ const selectedTokens = useMemo(() => {
288
+ const selectedSet = new Set(selectedIds);
289
+ return accounts.filter((item) => selectedSet.has(item.access_token)).map((item) => item.access_token);
290
+ }, [accounts, selectedIds]);
291
+
292
+ const abnormalTokens = useMemo(() => {
293
+ return accounts.filter((item) => item.status === "异常").map((item) => item.access_token);
294
+ }, [accounts]);
295
+
296
+ const paginationItems = useMemo(() => {
297
+ const items: (number | "...")[] = [];
298
+ const start = Math.max(1, safePage - 1);
299
+ const end = Math.min(pageCount, safePage + 1);
300
+
301
+ if (start > 1) items.push(1);
302
+ if (start > 2) items.push("...");
303
+ for (let current = start; current <= end; current += 1) items.push(current);
304
+ if (end < pageCount - 1) items.push("...");
305
+ if (end < pageCount) items.push(pageCount);
306
+
307
+ return items;
308
+ }, [pageCount, safePage]);
309
+
310
+ const handleDeleteTokens = async (tokens: string[]) => {
311
+ if (tokens.length === 0) {
312
+ toast.error("请先选择要删除的账户");
313
+ return;
314
+ }
315
+
316
+ setIsDeleting(true);
317
+ try {
318
+ const data = await deleteAccounts(tokens);
319
+ setAccounts(data.items);
320
+ setSelectedIds((prev) => prev.filter((id) => data.items.some((item) => item.access_token === id)));
321
+ toast.success(`删除 ${data.removed ?? 0} 个账户`);
322
+ } catch (error) {
323
+ const message = error instanceof Error ? error.message : "删除账户失败";
324
+ toast.error(message);
325
+ } finally {
326
+ setIsDeleting(false);
327
+ }
328
+ };
329
+
330
+ const handleRefreshAccounts = async (accessTokens: string[]) => {
331
+ if (accessTokens.length === 0) {
332
+ toast.error("没有需要刷新的账户");
333
+ return;
334
+ }
335
+
336
+ if (accessTokens.length === 1) {
337
+ setRefreshingTokens((prev) => new Set([...prev, accessTokens[0]]));
338
+ try {
339
+ const { progress_id } = await refreshAccounts(accessTokens);
340
+ // 单账号:轮询等待完成
341
+ await pollRefreshProgress(progress_id, (progress) => {
342
+ if (progress.done && progress.result) {
343
+ setAccounts(progress.result.items);
344
+ setSelectedIds((prev) => prev.filter((id) => progress.result!.items.some((item) => item.access_token === id)));
345
+ }
346
+ });
347
+ } catch (error) {
348
+ const message = error instanceof Error ? error.message : "刷新账户失败";
349
+ toast.error(message);
350
+ } finally {
351
+ setRefreshingTokens((prev) => {
352
+ const next = new Set(prev);
353
+ next.delete(accessTokens[0]);
354
+ return next;
355
+ });
356
+ }
357
+ return;
358
+ }
359
+
360
+ setIsRefreshing(true);
361
+
362
+ // 计算非选中账号的基数(统计卡片联动用)
363
+ const selectedTokenSet = new Set(accessTokens);
364
+ const baseAccountsList = accounts.filter((a) => !selectedTokenSet.has(a.access_token));
365
+ const baseActive = baseAccountsList.filter((a) => a.status === "正常").length;
366
+ const baseLimited = baseAccountsList.filter((a) => a.status === "限流").length;
367
+ const baseAbnormal = baseAccountsList.filter((a) => a.status === "异常").length;
368
+ const baseDisabled = baseAccountsList.filter((a) => a.status === "禁用").length;
369
+ const baseNormalAccounts = baseAccountsList.filter((a) => a.status === "正常");
370
+ const baseQuotaNum = baseNormalAccounts.reduce((s, a) => s + Math.max(0, a.quota), 0);
371
+
372
+ // 显示进度条(只显示当前任务,不含分类统计)
373
+ const total = accessTokens.length;
374
+ setProgress({
375
+ visible: true,
376
+ current: 0,
377
+ total,
378
+ message: "正在刷新账号信息...",
379
+ email: "",
380
+ });
381
+
382
+ try {
383
+ const { progress_id } = await refreshAccounts(accessTokens);
384
+
385
+ // 轮询进度到完成
386
+ const data = await new Promise<AccountRefreshResponse>((resolve, reject) => {
387
+ const pollTimer = setInterval(async () => {
388
+ try {
389
+ const p = await fetchRefreshProgress(progress_id);
390
+ if (p.done) {
391
+ clearInterval(pollTimer);
392
+ if (p.error) {
393
+ reject(new Error(p.error));
394
+ return;
395
+ }
396
+ if (!p.result) {
397
+ reject(new Error("刷新结果为空"));
398
+ return;
399
+ }
400
+ // 更新最终进度显示
401
+ setProgress((prev) => ({
402
+ ...prev,
403
+ current: prev.total,
404
+ message: "刷新完成",
405
+ }));
406
+ // 清除联动统计
407
+ setRefreshSummary(null);
408
+ resolve(p.result);
409
+ } else {
410
+ // 实时更新进度
411
+ setProgress((prev) => ({
412
+ ...prev,
413
+ current: p.processed,
414
+ }));
415
+ // 实时更新统计卡片:基数 + 已刷新的累加结果
416
+ const runningActive = baseActive + ((p.status_counts?.["正常"]) ?? 0);
417
+ const runningLimited = baseLimited + ((p.status_counts?.["限流"]) ?? 0);
418
+ const runningAbnormal = baseAbnormal + ((p.status_counts?.["异常"]) ?? 0);
419
+ const runningDisabled = baseDisabled + ((p.status_counts?.["禁用"]) ?? 0);
420
+ setRefreshSummary({
421
+ total: accounts.length,
422
+ active: runningActive,
423
+ limited: runningLimited,
424
+ abnormal: runningAbnormal,
425
+ disabled: runningDisabled,
426
+ quota: formatCompact(baseQuotaNum + (p.total_quota ?? 0)),
427
+ });
428
+ }
429
+ } catch (err) {
430
+ clearInterval(pollTimer);
431
+ reject(err);
432
+ }
433
+ }, 300);
434
+ });
435
+
436
+ // 刷新完成,更新数据
437
+ setAccounts(data.items);
438
+ setSelectedIds((prev) => prev.filter((id) => data.items.some((item) => item.access_token === id)));
439
+
440
+ const relogined = data.relogined ?? 0;
441
+
442
+ // 显示重新登录进度
443
+ if (relogined > 0) {
444
+ setProgress({
445
+ visible: true,
446
+ current: 0,
447
+ total: relogined,
448
+ message: `正在尝试对 ${relogined} 个账号进行移除异常状态`,
449
+ email: "",
450
+ });
451
+ // 模拟重新登录进度
452
+ let reCount = 0;
453
+ await new Promise<void>((resolve) => {
454
+ const timer = setInterval(() => {
455
+ reCount += 1;
456
+ if (reCount >= relogined) {
457
+ clearInterval(timer);
458
+ setProgress({
459
+ visible: true,
460
+ current: relogined,
461
+ total: relogined,
462
+ message: "移除异常状态完成",
463
+ email: "",
464
+ });
465
+ setTimeout(() => setProgress({ visible: false, current: 0, total: 0, message: "", email: "" }), 800);
466
+ resolve();
467
+ } else {
468
+ setProgress((prev) => ({ ...prev, current: reCount }));
469
+ }
470
+ }, 150);
471
+ setTimeout(resolve, 2000);
472
+ });
473
+ } else {
474
+ setProgress({
475
+ visible: true,
476
+ current: total,
477
+ total,
478
+ message: "刷新完成",
479
+ email: "",
480
+ });
481
+ setTimeout(() => setProgress({ visible: false, current: 0, total: 0, message: "", email: "" }), 800);
482
+ }
483
+
484
+ if ((data.errors ?? []).length > 0) {
485
+ const firstError = data.errors?.[0]?.error;
486
+ toast.error(
487
+ `刷新成功 ${data.refreshed} 个,失败 ${(data.errors ?? []).length} 个${firstError ? `,首个错误:${firstError}` : ""}`,
488
+ );
489
+ } else {
490
+ toast.success(`刷新成功 ${data.refreshed} 个账户${relogined > 0 ? `,已触发 ${relogined} 个账号重新登录` : ""}`);
491
+ }
492
+ } catch (error) {
493
+ setProgress({ visible: false, current: 0, total: 0, message: "", email: "" });
494
+ setRefreshSummary(null);
495
+ const message = error instanceof Error ? error.message : "刷新账户失败";
496
+ toast.error(message);
497
+ } finally {
498
+ setIsRefreshing(false);
499
+ }
500
+ };
501
+
502
+ const pollRefreshProgress = async (
503
+ progressId: string,
504
+ onUpdate: (p: RefreshProgressResponse) => void,
505
+ ): Promise<void> => {
506
+ return new Promise<void>((resolve, reject) => {
507
+ const timer = setInterval(async () => {
508
+ try {
509
+ const p = await fetchRefreshProgress(progressId);
510
+ if (p.done) {
511
+ clearInterval(timer);
512
+ if (p.error) {
513
+ reject(new Error(p.error));
514
+ } else {
515
+ onUpdate(p);
516
+ resolve();
517
+ }
518
+ }
519
+ } catch (err) {
520
+ clearInterval(timer);
521
+ reject(err);
522
+ }
523
+ }, 500);
524
+ });
525
+ };
526
+
527
+ const handleReLogin = async (accessTokens: string[]) => {
528
+ if (accessTokens.length === 0) {
529
+ toast.error("请先选择要恢复的账户");
530
+ return;
531
+ }
532
+
533
+ // 只处理异常账号,过滤非异常账号
534
+ const abnormalTokens = accessTokens.filter((token) => {
535
+ const account = accounts.find((a) => a.access_token === token);
536
+ return account?.status === "异常";
537
+ });
538
+
539
+ if (abnormalTokens.length === 0) {
540
+ toast.error("选中账号中没有异常账号");
541
+ return;
542
+ }
543
+
544
+ if (abnormalTokens.length < accessTokens.length) {
545
+ toast.info(`已过滤 ${accessTokens.length - abnormalTokens.length} 个非异常账号`);
546
+ }
547
+
548
+ setIsRelogining(true);
549
+
550
+ // 计算非选中账号的基数(统计卡片联动用)
551
+ const selectedTokenSet = new Set(abnormalTokens);
552
+ const baseAccountsList = accounts.filter((a) => !selectedTokenSet.has(a.access_token));
553
+ const baseActive = baseAccountsList.filter((a) => a.status === "正常").length;
554
+ const baseLimited = baseAccountsList.filter((a) => a.status === "限流").length;
555
+ const baseAbnormal = baseAccountsList.filter((a) => a.status === "异常").length;
556
+ const baseDisabled = baseAccountsList.filter((a) => a.status === "禁用").length;
557
+
558
+ // 显示进度条(真实进度)
559
+ const total = abnormalTokens.length;
560
+ setProgress({ visible: true, current: 0, total, message: "正在尝试恢复异常账号...", email: "" });
561
+
562
+ try {
563
+ const { progress_id } = await reLoginAccounts(abnormalTokens);
564
+
565
+ // 轮询进度到完成
566
+ await new Promise<void>((resolve, reject) => {
567
+ const pollTimer = setInterval(async () => {
568
+ try {
569
+ const p = await fetchReLoginProgress(progress_id);
570
+ if (p.done) {
571
+ clearInterval(pollTimer);
572
+ if (p.error) {
573
+ reject(new Error(p.error));
574
+ return;
575
+ }
576
+ setProgress((prev) => ({ ...prev, current: prev.total, message: "恢复流程已完成" }));
577
+ setRefreshSummary(null);
578
+ resolve();
579
+ } else {
580
+ // 实时更新进度
581
+ const results = p.results ?? [];
582
+ // 找到最新一条有错误的结果
583
+ const lastErrorResult = [...results].reverse().find((r) => r.error);
584
+ const emailHint = lastErrorResult
585
+ ? `失败: ${lastErrorResult.token} ${lastErrorResult.error ?? ""}`
586
+ : `已处理 ${p.processed}/${p.total}`;
587
+ setProgress((prev) => ({
588
+ ...prev,
589
+ current: p.processed,
590
+ email: emailHint,
591
+ message: "正在尝试恢复异常账号...",
592
+ }));
593
+
594
+ // 实时更新统计卡片:基数 + 已处理的恢复结果
595
+ let runningActive = baseActive;
596
+ let runningAbnormal = baseAbnormal;
597
+ let runningDisabled = baseDisabled;
598
+ for (const r of results) {
599
+ if (r.status === "成功") {
600
+ runningActive += 1;
601
+ runningAbnormal -= 1;
602
+ } else if (r.status === "禁用") {
603
+ runningDisabled += 1;
604
+ runningAbnormal -= 1;
605
+ }
606
+ // "异常"或"跳过":保持异常状态不变
607
+ }
608
+ setRefreshSummary({
609
+ total: accounts.length,
610
+ active: runningActive,
611
+ limited: baseLimited,
612
+ abnormal: runningAbnormal,
613
+ disabled: runningDisabled,
614
+ quota: summary.quota,
615
+ });
616
+ }
617
+ } catch (err) {
618
+ clearInterval(pollTimer);
619
+ reject(err);
620
+ }
621
+ }, 300);
622
+ });
623
+
624
+ // 等待后台线程完成,再拉取最新数据
625
+ await new Promise<void>((resolve) => setTimeout(resolve, 500));
626
+ try {
627
+ const freshData = await fetchAccounts();
628
+ setAccounts(freshData.items);
629
+ setSelectedIds((prev) => prev.filter((id) => freshData.items.some((item) => item.access_token === id)));
630
+ } catch { /* 静默失败 */ }
631
+
632
+ setProgress({
633
+ visible: true,
634
+ current: total,
635
+ total,
636
+ message: "恢复完成",
637
+ email: "",
638
+ });
639
+ setTimeout(() => setProgress({ visible: false, current: 0, total: 0, message: "", email: "" }), 800);
640
+
641
+ toast.success(`恢复流程已全部完成`);
642
+ } catch (error) {
643
+ setProgress({ visible: false, current: 0, total: 0, message: "", email: "" });
644
+ setRefreshSummary(null);
645
+ const message = error instanceof Error ? error.message : "重新登录失败";
646
+ toast.error(message);
647
+ } finally {
648
+ setIsRelogining(false);
649
+ }
650
+ };
651
+
652
+ const openEditDialog = (account: Account) => {
653
+ setEditingAccount(account);
654
+ setEditStatus(account.status);
655
+ setEditProxy(account.proxy ?? "");
656
+ };
657
+
658
+ const handleTestAccountProxy = async () => {
659
+ const candidate = editProxy.trim();
660
+ if (!candidate) {
661
+ toast.error("请先填写代理地址");
662
+ return;
663
+ }
664
+ setIsTestingProxy(true);
665
+ try {
666
+ const data = await testProxy(candidate);
667
+ data.result.ok
668
+ ? toast.success(`代理可用(${data.result.latency_ms} ms,HTTP ${data.result.status})`)
669
+ : toast.error(`代理不可用:${data.result.error ?? "未知错误"}`);
670
+ } catch (error) {
671
+ toast.error(error instanceof Error ? error.message : "测试代理失败");
672
+ } finally {
673
+ setIsTestingProxy(false);
674
+ }
675
+ };
676
+
677
+ const handleUpdateAccount = async () => {
678
+ if (!editingAccount) {
679
+ return;
680
+ }
681
+
682
+ setIsUpdating(true);
683
+ try {
684
+ const data = await updateAccount(editingAccount.access_token, {
685
+ status: editStatus,
686
+ proxy: editProxy.trim(),
687
+ });
688
+ setAccounts(data.items);
689
+ setSelectedIds((prev) => prev.filter((id) => data.items.some((item) => item.access_token === id)));
690
+ setEditingAccount(null);
691
+ toast.success("账号信息已更新");
692
+ } catch (error) {
693
+ const message = error instanceof Error ? error.message : "更新账号失败";
694
+ toast.error(message);
695
+ } finally {
696
+ setIsUpdating(false);
697
+ }
698
+ };
699
+
700
+ const toggleSelectAll = (checked: boolean) => {
701
+ if (checked) {
702
+ setSelectedIds((prev) => Array.from(new Set([...prev, ...currentRows.map((item) => item.access_token)])));
703
+ return;
704
+ }
705
+ setSelectedIds((prev) => prev.filter((id) => !currentRows.some((row) => row.access_token === id)));
706
+ };
707
+
708
+ return (
709
+ <>
710
+ <section className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
711
+ <div className="space-y-1">
712
+ <div className="text-xs font-semibold tracking-[0.18em] text-stone-500 uppercase">
713
+ Account Pool
714
+ </div>
715
+ <h1 className="text-2xl font-semibold tracking-tight">号池管理</h1>
716
+ </div>
717
+
718
+ <div className="flex flex-wrap items-center gap-2">
719
+ <Button
720
+ variant="outline"
721
+ className="h-10 rounded-xl border-stone-200 bg-white/80 px-4 text-stone-700 hover:bg-white"
722
+ onClick={() => void loadAccounts()}
723
+ disabled={isLoading || isRefreshing || isDeleting}
724
+ >
725
+ <RefreshCw className={cn("size-4", isLoading ? "animate-spin" : "")} />
726
+ 刷新
727
+ </Button>
728
+ <Button
729
+ variant="outline"
730
+ className="h-10 rounded-xl border-stone-200 bg-white/80 px-4 text-stone-700 hover:bg-white"
731
+ onClick={() => void handleRefreshAccounts(accounts.map((item) => item.access_token))}
732
+ disabled={isLoading || isRefreshing || isDeleting || accounts.length === 0}
733
+ >
734
+ <RefreshCw className={cn("size-4", isRefreshing ? "animate-spin" : "")} />
735
+ 一键刷新所有账号信息和额度
736
+ </Button>
737
+ <AccountImportDialog
738
+ disabled={isLoading || isRefreshing || isDeleting}
739
+ onImported={(items) => {
740
+ setAccounts(items);
741
+ setSelectedIds([]);
742
+ setPage(1);
743
+ }}
744
+ />
745
+ <Button
746
+ variant="outline"
747
+ className="h-10 rounded-xl border-stone-200 bg-white/80 px-4 text-stone-700 hover:bg-white"
748
+ onClick={() => downloadTokens(accounts)}
749
+ disabled={accounts.length === 0}
750
+ >
751
+ <Download className="size-4" />
752
+ 导出全部 Token
753
+ </Button>
754
+ </div>
755
+ </section>
756
+
757
+ {/* 进度条 */}
758
+ {progress.visible && (
759
+ <div className="overflow-hidden rounded-2xl border border-stone-200 bg-white/90 shadow-sm">
760
+ <div className="px-4 py-3">
761
+ <div className="flex items-center justify-between text-sm">
762
+ <span className="text-stone-600">
763
+ {progress.message}
764
+ {progress.email && <span className="ml-1 font-medium text-stone-700">{progress.email}</span>}
765
+ </span>
766
+ <span className="font-medium text-stone-700">
767
+ {progress.current}/{progress.total}
768
+ </span>
769
+ </div>
770
+ <div className="mt-2 h-2 w-full overflow-hidden rounded-full bg-stone-100">
771
+ <div
772
+ className="h-full rounded-full bg-gradient-to-r from-amber-400 to-orange-500 transition-all duration-300 ease-out"
773
+ style={{ width: `${progress.total > 0 ? (progress.current / progress.total) * 100 : 0}%` }}
774
+ />
775
+ </div>
776
+ </div>
777
+ </div>
778
+ )}
779
+
780
+ <Dialog open={Boolean(editingAccount)} onOpenChange={(open) => (!open ? setEditingAccount(null) : null)}>
781
+ <DialogContent showCloseButton={false} className="rounded-2xl p-6">
782
+ <DialogHeader className="gap-2">
783
+ <DialogTitle>编辑账户</DialogTitle>
784
+ <DialogDescription className="text-sm leading-6">
785
+ 手动修改账号状态和专属代理。
786
+ </DialogDescription>
787
+ </DialogHeader>
788
+ <div className="space-y-4">
789
+ <div className="space-y-2">
790
+ <label className="text-sm font-medium text-stone-700">状态</label>
791
+ <Select value={editStatus} onValueChange={(value) => setEditStatus(value as AccountStatus)}>
792
+ <SelectTrigger className="h-11 rounded-xl border-stone-200 bg-white">
793
+ <SelectValue />
794
+ </SelectTrigger>
795
+ <SelectContent>
796
+ {accountStatusOptions
797
+ .filter((option) => option.value !== "all")
798
+ .map((option) => (
799
+ <SelectItem key={option.value} value={option.value}>
800
+ {option.label}
801
+ </SelectItem>
802
+ ))}
803
+ </SelectContent>
804
+ </Select>
805
+ </div>
806
+ <div className="space-y-2">
807
+ <label className="text-sm font-medium text-stone-700">账号代理</label>
808
+ <div className="flex flex-col gap-2 sm:flex-row">
809
+ <Input
810
+ value={editProxy}
811
+ onChange={(event) => setEditProxy(event.target.value)}
812
+ placeholder="留空走全局代理,例如 http://127.0.0.1:7890"
813
+ className="h-11 rounded-xl border-stone-200 bg-white"
814
+ />
815
+ <Button
816
+ variant="outline"
817
+ className="h-11 rounded-xl border-stone-200 bg-white px-4 text-stone-700 sm:w-24"
818
+ onClick={() => void handleTestAccountProxy()}
819
+ disabled={isTestingProxy}
820
+ >
821
+ {isTestingProxy ? <LoaderCircle className="size-4 animate-spin" /> : <Link2 className="size-4" />}
822
+ 测试
823
+ </Button>
824
+ </div>
825
+ </div>
826
+ </div>
827
+ <DialogFooter className="pt-2">
828
+ <Button
829
+ variant="secondary"
830
+ className="h-10 rounded-xl bg-stone-100 px-5 text-stone-700 hover:bg-stone-200"
831
+ onClick={() => setEditingAccount(null)}
832
+ disabled={isUpdating}
833
+ >
834
+ 取消
835
+ </Button>
836
+ <Button
837
+ className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
838
+ onClick={() => void handleUpdateAccount()}
839
+ disabled={isUpdating}
840
+ >
841
+ {isUpdating ? <LoaderCircle className="size-4 animate-spin" /> : null}
842
+ 保存修改
843
+ </Button>
844
+ </DialogFooter>
845
+ </DialogContent>
846
+ </Dialog>
847
+
848
+ <section className="space-y-3">
849
+ <div className="grid gap-3 md:grid-cols-3 xl:grid-cols-6">
850
+ {metricCards.map((item) => {
851
+ const Icon = item.icon;
852
+ const value = (refreshSummary ?? summary)[item.key];
853
+ return (
854
+ <Card key={item.key} className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
855
+ <CardContent className="p-4">
856
+ <div className="mb-4 flex items-start justify-between">
857
+ <span className="text-xs font-medium text-stone-400">{item.label}</span>
858
+ <Icon className="size-4 text-stone-400" />
859
+ </div>
860
+ <div className={cn("text-[1.75rem] font-semibold tracking-tight", item.color)}>
861
+ <span className={typeof value === "number" ? "" : "text-[1.1rem]"}>
862
+ {typeof value === "number" ? formatCompact(value) : value}
863
+ </span>
864
+ </div>
865
+ </CardContent>
866
+ </Card>
867
+ );
868
+ })}
869
+ </div>
870
+ <Card className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
871
+ <CardContent className="p-4">
872
+ <div className="mb-3 text-sm font-medium text-stone-700">
873
+ 系统可用模型
874
+ <span className="ml-1 text-stone-400">({availableModels.length})</span>
875
+ </div>
876
+ <div className="flex flex-wrap gap-2">
877
+ {availableModels.length > 0 ? (
878
+ availableModels.map((model) => (
879
+ <button
880
+ key={model.id}
881
+ type="button"
882
+ className="inline-flex cursor-pointer items-center rounded-full border border-stone-200 bg-white px-2.5 py-1 text-xs font-medium text-stone-700 transition hover:border-stone-300 hover:bg-stone-50"
883
+ onClick={() => {
884
+ void navigator.clipboard.writeText(model.id);
885
+ toast.success("模型名已复制");
886
+ }}
887
+ title={`点击复制 ${model.id}`}
888
+ >
889
+ <img
890
+ src="/openai.svg"
891
+ alt=""
892
+ aria-hidden="true"
893
+ className="mr-1.5 size-3.5 shrink-0"
894
+ />
895
+ {model.id}
896
+ </button>
897
+ ))
898
+ ) : isLoadingModels ? (
899
+ <span className="text-sm text-stone-400">正在加载模型列表...</span>
900
+ ) : (
901
+ <span className="text-sm text-stone-400">当前暂无可用模型</span>
902
+ )}
903
+ </div>
904
+ </CardContent>
905
+ </Card>
906
+ </section>
907
+
908
+ <section className="space-y-4">
909
+ <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
910
+ <div className="flex items-center gap-3">
911
+ <h2 className="text-lg font-semibold tracking-tight">账户列表</h2>
912
+ <Badge variant="secondary" className="rounded-lg bg-stone-200 px-2 py-0.5 text-stone-700">
913
+ {filteredAccounts.length}
914
+ </Badge>
915
+ </div>
916
+
917
+ <div className="flex flex-col gap-2 lg:flex-row lg:items-center">
918
+ <div className="relative min-w-[260px]">
919
+ <Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-stone-400" />
920
+ <Input
921
+ value={query}
922
+ onChange={(event) => {
923
+ setQuery(event.target.value);
924
+ setPage(1);
925
+ }}
926
+ placeholder="搜索邮箱"
927
+ className="h-10 rounded-xl border-stone-200 bg-white/85 pl-10"
928
+ />
929
+ </div>
930
+ <Select
931
+ value={typeFilter}
932
+ onValueChange={(value) => {
933
+ setTypeFilter(value);
934
+ setPage(1);
935
+ }}
936
+ >
937
+ <SelectTrigger className="h-10 w-full rounded-xl border-stone-200 bg-white/85 lg:w-[150px]">
938
+ <SelectValue />
939
+ </SelectTrigger>
940
+ <SelectContent>
941
+ {accountTypeOptions.map((option) => (
942
+ <SelectItem key={option.value} value={option.value}>
943
+ {option.label}
944
+ </SelectItem>
945
+ ))}
946
+ </SelectContent>
947
+ </Select>
948
+ <Select
949
+ value={statusFilter}
950
+ onValueChange={(value) => {
951
+ setStatusFilter(value as AccountStatus | "all");
952
+ setPage(1);
953
+ }}
954
+ >
955
+ <SelectTrigger className="h-10 w-full rounded-xl border-stone-200 bg-white/85 lg:w-[150px]">
956
+ <SelectValue />
957
+ </SelectTrigger>
958
+ <SelectContent>
959
+ {accountStatusOptions.map((option) => (
960
+ <SelectItem key={option.value} value={option.value}>
961
+ {option.label}
962
+ </SelectItem>
963
+ ))}
964
+ </SelectContent>
965
+ </Select>
966
+ </div>
967
+ </div>
968
+
969
+ {isLoading && accounts.length === 0 ? (
970
+ <Card className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
971
+ <CardContent className="flex flex-col items-center justify-center gap-3 px-6 py-14 text-center">
972
+ <div className="rounded-xl bg-stone-100 p-3 text-stone-500">
973
+ <LoaderCircle className="size-5 animate-spin" />
974
+ </div>
975
+ <div className="space-y-1">
976
+ <p className="text-sm font-medium text-stone-700">正在加载账户</p>
977
+ <p className="text-sm text-stone-500">从后端同步账号列表和状态。</p>
978
+ </div>
979
+ </CardContent>
980
+ </Card>
981
+ ) : null}
982
+
983
+ <Card
984
+ className={cn(
985
+ "overflow-hidden rounded-2xl border-white/80 bg-white/90 shadow-sm",
986
+ isLoading && accounts.length === 0 ? "hidden" : "",
987
+ )}
988
+ >
989
+ <CardContent className="space-y-0 p-0">
990
+ <div className="flex flex-col gap-3 border-b border-stone-100 px-4 py-3 lg:flex-row lg:items-center lg:justify-between">
991
+ <div className="flex flex-wrap items-center gap-2 text-sm text-stone-500">
992
+ <Button
993
+ variant="ghost"
994
+ className="h-8 rounded-lg px-3 text-stone-500 hover:bg-stone-100"
995
+ onClick={() => void handleRefreshAccounts(selectedTokens)}
996
+ disabled={selectedTokens.length === 0 || isRefreshing}
997
+ >
998
+ {isRefreshing ? <LoaderCircle className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
999
+ 刷新选中账号信息和额度
1000
+ </Button>
1001
+ <Button
1002
+ variant="ghost"
1003
+ className="h-8 rounded-lg px-3 text-amber-600 hover:bg-amber-50 hover:text-amber-700"
1004
+ onClick={() => void handleReLogin(selectedTokens)}
1005
+ disabled={selectedTokens.length === 0 || isRelogining}
1006
+ title="尝试密码登录恢复账号"
1007
+ >
1008
+ {isRelogining ? <LoaderCircle className="size-4 animate-spin" /> : <LogIn className="size-4" />}
1009
+ 尝试恢复异常账号
1010
+ </Button>
1011
+ <Button
1012
+ variant="ghost"
1013
+ className="h-8 rounded-lg px-3 text-rose-500 hover:bg-rose-50 hover:text-rose-600"
1014
+ onClick={() => void handleDeleteTokens(abnormalTokens)}
1015
+ disabled={abnormalTokens.length === 0 || isDeleting}
1016
+ >
1017
+ {isDeleting ? <LoaderCircle className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
1018
+ 移除异常账号
1019
+ </Button>
1020
+ <Button
1021
+ variant="ghost"
1022
+ className="h-8 rounded-lg px-3 text-rose-500 hover:bg-rose-50 hover:text-rose-600"
1023
+ onClick={() => void handleDeleteTokens(selectedTokens)}
1024
+ disabled={selectedTokens.length === 0 || isDeleting}
1025
+ >
1026
+ {isDeleting ? <LoaderCircle className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
1027
+ 删除所选
1028
+ </Button>
1029
+ {selectedIds.length > 0 ? (
1030
+ <span className="rounded-lg bg-stone-100 px-2.5 py-1 text-xs font-medium text-stone-600">
1031
+ 已选择 {selectedIds.length} 项
1032
+ </span>
1033
+ ) : null}
1034
+ </div>
1035
+ </div>
1036
+
1037
+ <div className="overflow-x-auto">
1038
+ <table className="w-full min-w-[1000px] text-left">
1039
+ <thead className="border-b border-stone-100 text-[11px] text-stone-400 uppercase tracking-[0.18em]">
1040
+ <tr>
1041
+ <th className="w-12 px-4 py-3">
1042
+ <Checkbox
1043
+ checked={allCurrentSelected}
1044
+ onCheckedChange={(checked) => toggleSelectAll(Boolean(checked))}
1045
+ />
1046
+ </th>
1047
+ <th className="w-56 px-4 py-3">token</th>
1048
+ <th className="w-28 px-4 py-3">类型</th>
1049
+ <th className="w-24 px-4 py-3">来源</th>
1050
+ <th className="w-24 px-4 py-3">状态</th>
1051
+ <th className="w-56 px-4 py-3">账号信息</th>
1052
+ <th className="w-32 px-4 py-3">创建时间</th>
1053
+ <th className="w-24 px-4 py-3">额度</th>
1054
+ <th className="w-40 px-4 py-3">恢复时间</th>
1055
+ <th className="w-18 px-4 py-3">在途</th>
1056
+ <th className="w-18 px-4 py-3">成功</th>
1057
+ <th className="w-18 px-4 py-3">失败</th>
1058
+ <th className="w-24 px-4 py-3">操作</th>
1059
+ </tr>
1060
+ </thead>
1061
+ <tbody>
1062
+ {currentRows.map((account) => {
1063
+ const status = statusMeta[account.status];
1064
+ const StatusIcon = status.icon;
1065
+
1066
+ return (
1067
+ <tr
1068
+ key={account.access_token}
1069
+ className="border-b border-stone-100/80 text-sm text-stone-600 transition-colors hover:bg-stone-50/70"
1070
+ >
1071
+ <td className="px-4 py-3">
1072
+ <Checkbox
1073
+ checked={selectedIds.includes(account.access_token)}
1074
+ onCheckedChange={(checked) => {
1075
+ setSelectedIds((prev) =>
1076
+ checked
1077
+ ? Array.from(new Set([...prev, account.access_token]))
1078
+ : prev.filter((item) => item !== account.access_token),
1079
+ );
1080
+ }}
1081
+ />
1082
+ </td>
1083
+ <td className="px-4 py-3">
1084
+ <div className="flex items-center gap-2">
1085
+ <span className="font-medium tracking-tight text-stone-700">
1086
+ {maskToken(account.access_token)}
1087
+ </span>
1088
+ <button
1089
+ type="button"
1090
+ className="rounded-lg p-1 text-stone-400 transition hover:bg-stone-100 hover:text-stone-700"
1091
+ onClick={() => {
1092
+ void navigator.clipboard.writeText(account.access_token);
1093
+ toast.success("token 已复制");
1094
+ }}
1095
+ >
1096
+ <Copy className="size-4" />
1097
+ </button>
1098
+ </div>
1099
+ </td>
1100
+ <td className="px-4 py-3">
1101
+ <Badge variant="secondary" className="rounded-md bg-stone-100 text-stone-700">
1102
+ {displayAccountType(account)}
1103
+ </Badge>
1104
+ </td>
1105
+ <td className="px-4 py-3">
1106
+ <Badge variant="outline" className="rounded-md border-stone-200 text-stone-600">
1107
+ {displayAccountSource(account)}
1108
+ </Badge>
1109
+ </td>
1110
+ <td className="px-4 py-3">
1111
+ <Badge
1112
+ variant={status.badge}
1113
+ className="inline-flex items-center gap-1 rounded-md px-2 py-1"
1114
+ >
1115
+ <StatusIcon className="size-3.5" />
1116
+ {account.status}
1117
+ </Badge>
1118
+ </td>
1119
+ <td className="px-4 py-3">
1120
+ <div className="text-xs leading-5 text-stone-500">{account.email ?? "—"}</div>
1121
+ </td>
1122
+ <td className="px-4 py-3 text-xs leading-5 text-stone-500">
1123
+ {(() => {
1124
+ const raw = (account as any).created_at;
1125
+ if (!raw) return "—";
1126
+ try {
1127
+ const d = new Date(raw + "Z");
1128
+ if (isNaN(d.getTime())) return String(raw).slice(0, 10);
1129
+ return d.toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
1130
+ } catch { return String(raw).slice(0, 10); }
1131
+ })()}
1132
+ </td>
1133
+ <td className="px-4 py-3">
1134
+ <Badge variant="info" className="rounded-md">
1135
+ {formatQuota(account)}
1136
+ </Badge>
1137
+ </td>
1138
+ <td className="px-4 py-3 text-xs leading-5 text-stone-500">
1139
+ {(() => {
1140
+ const restore = formatRestoreAt(account.restore_at);
1141
+ return (
1142
+ <div className="space-y-0.5">
1143
+ {restore.relative ? <div className="font-medium text-stone-700">{restore.relative}</div> : null}
1144
+ <div>{restore.absolute}</div>
1145
+ </div>
1146
+ );
1147
+ })()}
1148
+ </td>
1149
+ <td className="px-4 py-3">
1150
+ {(() => {
1151
+ const inflight = account.image_inflight ?? 0;
1152
+ return (
1153
+ <span
1154
+ className={
1155
+ inflight > 0
1156
+ ? "font-semibold text-amber-600"
1157
+ : "text-stone-400"
1158
+ }
1159
+ title={
1160
+ inflight > 0
1161
+ ? "当前正在生成的图片数。号池空闲时此值持续 > 0,说明并发槽位泄漏、该账号已被静默排除出调度"
1162
+ : "当前无在途生图任务"
1163
+ }
1164
+ >
1165
+ {inflight}
1166
+ </span>
1167
+ );
1168
+ })()}
1169
+ </td>
1170
+ <td className="px-4 py-3 text-stone-500">{account.success}</td>
1171
+ <td className="px-4 py-3 text-stone-500">{account.fail}</td>
1172
+ <td className="px-4 py-3">
1173
+ <div className="flex items-center gap-1 text-stone-400">
1174
+ <button
1175
+ type="button"
1176
+ className="rounded-lg p-2 transition hover:bg-stone-100 hover:text-stone-700"
1177
+ onClick={() => openEditDialog(account)}
1178
+ disabled={isUpdating}
1179
+ >
1180
+ <Pencil className="size-4" />
1181
+ </button>
1182
+ <button
1183
+ type="button"
1184
+ className="rounded-lg p-2 transition hover:bg-stone-100 hover:text-stone-700"
1185
+ onClick={() => void handleRefreshAccounts([account.access_token])}
1186
+ disabled={isRefreshing || refreshingTokens.has(account.access_token)}
1187
+ >
1188
+ <RefreshCw className={cn("size-4", (isRefreshing || refreshingTokens.has(account.access_token)) ? "animate-spin" : "")} />
1189
+ </button>
1190
+ <button
1191
+ type="button"
1192
+ className="rounded-lg p-2 transition hover:bg-rose-50 hover:text-rose-500"
1193
+ onClick={() => void handleDeleteTokens([account.access_token])}
1194
+ disabled={isDeleting}
1195
+ >
1196
+ <Trash2 className="size-4" />
1197
+ </button>
1198
+ </div>
1199
+ </td>
1200
+ </tr>
1201
+ );
1202
+ })}
1203
+ </tbody>
1204
+ </table>
1205
+
1206
+ {!isLoading && currentRows.length === 0 ? (
1207
+ <div className="flex flex-col items-center justify-center gap-3 px-6 py-14 text-center">
1208
+ <div className="rounded-xl bg-stone-100 p-3 text-stone-500">
1209
+ <Search className="size-5" />
1210
+ </div>
1211
+ <div className="space-y-1">
1212
+ <p className="text-sm font-medium text-stone-700">没有匹配的账户</p>
1213
+ <p className="text-sm text-stone-500">调整筛选条件或搜索关键字后重试。</p>
1214
+ </div>
1215
+ </div>
1216
+ ) : null}
1217
+ </div>
1218
+
1219
+ <div className="border-t border-stone-100 px-4 py-4">
1220
+ <div className="flex items-center justify-center gap-3 overflow-x-auto whitespace-nowrap">
1221
+ <div className="shrink-0 text-sm text-stone-500">
1222
+ 显示第 {filteredAccounts.length === 0 ? 0 : startIndex + 1} -{" "}
1223
+ {Math.min(startIndex + Number(pageSize), filteredAccounts.length)} 条,共{" "}
1224
+ {filteredAccounts.length} 条
1225
+ </div>
1226
+
1227
+ <span className="shrink-0 text-sm leading-none text-stone-500">
1228
+ {safePage} / {pageCount} 页
1229
+ </span>
1230
+ <Select
1231
+ value={pageSize}
1232
+ onValueChange={(value) => {
1233
+ setPageSize(value);
1234
+ setPage(1);
1235
+ }}
1236
+ >
1237
+ <SelectTrigger className="h-10 w-[108px] shrink-0 rounded-lg border-stone-200 bg-white text-sm leading-none">
1238
+ <SelectValue />
1239
+ </SelectTrigger>
1240
+ <SelectContent>
1241
+ <SelectItem value="10">10 / 页</SelectItem>
1242
+ <SelectItem value="20">20 / 页</SelectItem>
1243
+ <SelectItem value="50">50 / 页</SelectItem>
1244
+ <SelectItem value="100">100 / 页</SelectItem>
1245
+ </SelectContent>
1246
+ </Select>
1247
+ <Button
1248
+ variant="outline"
1249
+ size="icon"
1250
+ className="size-10 shrink-0 rounded-lg border-stone-200 bg-white"
1251
+ disabled={safePage <= 1}
1252
+ onClick={() => setPage((prev) => Math.max(1, prev - 1))}
1253
+ >
1254
+ <ChevronLeft className="size-4" />
1255
+ </Button>
1256
+ {paginationItems.map((item, index) =>
1257
+ item === "..." ? (
1258
+ <span key={`ellipsis-${index}`} className="px-1 text-sm text-stone-400">
1259
+ ...
1260
+ </span>
1261
+ ) : (
1262
+ <Button
1263
+ key={item}
1264
+ variant={item === safePage ? "default" : "outline"}
1265
+ className={cn(
1266
+ "h-10 min-w-10 shrink-0 rounded-lg px-3",
1267
+ item === safePage
1268
+ ? "bg-stone-950 text-white hover:bg-stone-800"
1269
+ : "border-stone-200 bg-white text-stone-700",
1270
+ )}
1271
+ onClick={() => setPage(item)}
1272
+ >
1273
+ {item}
1274
+ </Button>
1275
+ ),
1276
+ )}
1277
+ <Button
1278
+ variant="outline"
1279
+ size="icon"
1280
+ className="size-10 shrink-0 rounded-lg border-stone-200 bg-white"
1281
+ disabled={safePage >= pageCount}
1282
+ onClick={() => setPage((prev) => Math.min(pageCount, prev + 1))}
1283
+ >
1284
+ <ChevronRight className="size-4" />
1285
+ </Button>
1286
+ </div>
1287
+ </div>
1288
+ </CardContent>
1289
+ </Card>
1290
+ </section>
1291
+ </>
1292
+ );
1293
+ }
1294
+
1295
+ export default function AccountsPage() {
1296
+ const { isCheckingAuth, session } = useAuthGuard(["admin"]);
1297
+
1298
+ if (isCheckingAuth || !session || session.role !== "admin") {
1299
+ return (
1300
+ <div className="flex min-h-[40vh] items-center justify-center">
1301
+ <LoaderCircle className="size-5 animate-spin text-stone-400" />
1302
+ </div>
1303
+ );
1304
+ }
1305
+
1306
+ return <AccountsPageContent />;
1307
+ }