icebear commited on
Commit
b6cea4f
·
unverified ·
2 Parent(s): d07b5c0a8a2d95

Merge pull request #86 from icebear0828/feat/account-import-export

Browse files
CHANGELOG.md CHANGED
@@ -17,6 +17,7 @@
17
  - 模型列表自动同步:后端动态 fetch 成功后自动回写 `config/models.yaml`,静态配置不再滞后;前端每 60s 轮询模型列表,新模型无需刷新页面即可选择
18
  - Tuple Schema 支持:`prefixItems`(JSON Schema 2020-12 tuple)自动转换为等价 object schema 发给上游,响应侧还原为数组;OpenAI / Gemini / Responses 三端点统一支持
19
  - WebSocket 传输 + `previous_response_id` 多轮支持:`/v1/responses` 端点自动通过 WebSocket 连接上游,服务端持久化 response,客户端可通过 `previous_response_id` 引用前轮对话实现增量多轮;WebSocket 失败自动降级回 HTTP SSE (#83)
 
20
 
21
  ### Fixed
22
 
 
17
  - 模型列表自动同步:后端动态 fetch 成功后自动回写 `config/models.yaml`,静态配置不再滞后;前端每 60s 轮询模型列表,新模型无需刷新页面即可选择
18
  - Tuple Schema 支持:`prefixItems`(JSON Schema 2020-12 tuple)自动转换为等价 object schema 发给上游,响应侧还原为数组;OpenAI / Gemini / Responses 三端点统一支持
19
  - WebSocket 传输 + `previous_response_id` 多轮支持:`/v1/responses` 端点自动通过 WebSocket 连接上游,服务端持久化 response,客户端可通过 `previous_response_id` 引用前轮对话实现增量多轮;WebSocket 失败自动降级回 HTTP SSE (#83)
20
+ - 账号批量导入导出:Dashboard 支持导出全部账号到 JSON 文件(含 token,用于备份/迁移),支持从 JSON 文件批量导入账号,自动去重 (#82)
21
 
22
  ### Fixed
23
 
shared/hooks/use-accounts.ts CHANGED
@@ -155,6 +155,61 @@ export function useAccounts() {
155
  setList((prev) => prev.map((a) => a.id === accountId ? { ...a, ...patch } : a));
156
  }, []);
157
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  return {
159
  list,
160
  loading,
@@ -168,5 +223,7 @@ export function useAccounts() {
168
  startAdd,
169
  submitRelay,
170
  deleteAccount,
 
 
171
  };
172
  }
 
155
  setList((prev) => prev.map((a) => a.id === accountId ? { ...a, ...patch } : a));
156
  }, []);
157
 
158
+ const exportAccounts = useCallback(async (selectedIds?: string[]) => {
159
+ const params = selectedIds && selectedIds.length > 0
160
+ ? `?ids=${selectedIds.join(",")}`
161
+ : "";
162
+ const resp = await fetch(`/auth/accounts/export${params}`);
163
+ const data = await resp.json() as { accounts: Array<{ id: string }> };
164
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
165
+ const url = URL.createObjectURL(blob);
166
+ const a = document.createElement("a");
167
+ a.href = url;
168
+ const date = new Date().toISOString().slice(0, 10);
169
+ a.download = `accounts-export-${date}.json`;
170
+ a.style.display = "none";
171
+ document.body.appendChild(a);
172
+ a.click();
173
+ document.body.removeChild(a);
174
+ URL.revokeObjectURL(url);
175
+ }, []);
176
+
177
+ const importAccounts = useCallback(async (file: File): Promise<{
178
+ success: boolean;
179
+ added: number;
180
+ updated: number;
181
+ failed: number;
182
+ errors: string[];
183
+ }> => {
184
+ const text = await file.text();
185
+ let parsed: unknown;
186
+ try {
187
+ parsed = JSON.parse(text);
188
+ } catch {
189
+ return { success: false, added: 0, updated: 0, failed: 0, errors: ["Invalid JSON file"] };
190
+ }
191
+ // Support both { accounts: [...] } (export format) and raw array
192
+ const accounts = Array.isArray(parsed)
193
+ ? parsed
194
+ : Array.isArray(parsed.accounts)
195
+ ? parsed.accounts
196
+ : null;
197
+ if (!accounts) {
198
+ return { success: false, added: 0, updated: 0, failed: 0, errors: ["Invalid format: expected { accounts: [...] }"] };
199
+ }
200
+
201
+ const resp = await fetch("/auth/accounts/import", {
202
+ method: "POST",
203
+ headers: { "Content-Type": "application/json" },
204
+ body: JSON.stringify({ accounts }),
205
+ });
206
+ const result = await resp.json();
207
+ if (resp.ok) {
208
+ await loadAccounts();
209
+ }
210
+ return result;
211
+ }, [loadAccounts]);
212
+
213
  return {
214
  list,
215
  loading,
 
223
  startAdd,
224
  submitRelay,
225
  deleteAccount,
226
+ exportAccounts,
227
+ importAccounts,
228
  };
229
  }
shared/i18n/translations.ts CHANGED
@@ -124,6 +124,11 @@ export const translations = {
124
  confirmApply: "Confirm Apply",
125
  cancelBtn: "Cancel",
126
  noChanges: "No changes",
 
 
 
 
 
127
  roundRobinRule: "Round-robin",
128
  assignByStatus: "By status",
129
  assignByPrefix: "By email prefix",
@@ -303,6 +308,11 @@ export const translations = {
303
  confirmApply: "\u786e\u8ba4\u5e94\u7528",
304
  cancelBtn: "\u53d6\u6d88",
305
  noChanges: "\u65e0\u53d8\u66f4",
 
 
 
 
 
306
  roundRobinRule: "\u8f6e\u8be2\u5206\u914d",
307
  assignByStatus: "\u6309\u72b6\u6001\u5206\u914d",
308
  assignByPrefix: "\u6309\u524d\u7f00\u5206\u914d",
 
124
  confirmApply: "Confirm Apply",
125
  cancelBtn: "Cancel",
126
  noChanges: "No changes",
127
+ accountImportResult: "Imported: {added} added, {updated} updated, {failed} failed",
128
+ accountImportError: "Import failed",
129
+ selectFile: "Select JSON file",
130
+ selectAll: "Select all",
131
+ deselectAll: "Deselect all",
132
  roundRobinRule: "Round-robin",
133
  assignByStatus: "By status",
134
  assignByPrefix: "By email prefix",
 
308
  confirmApply: "\u786e\u8ba4\u5e94\u7528",
309
  cancelBtn: "\u53d6\u6d88",
310
  noChanges: "\u65e0\u53d8\u66f4",
311
+ accountImportResult: "\u5bfc\u5165\u5b8c\u6210\uff1a{added} \u65b0\u589e\uff0c{updated} \u66f4\u65b0\uff0c{failed} \u5931\u8d25",
312
+ accountImportError: "\u5bfc\u5165\u5931\u8d25",
313
+ selectFile: "\u9009\u62e9 JSON \u6587\u4ef6",
314
+ selectAll: "\u5168\u9009",
315
+ deselectAll: "\u53d6\u6d88\u5168\u9009",
316
  roundRobinRule: "\u8f6e\u8be2\u5206\u914d",
317
  assignByStatus: "\u6309\u72b6\u6001\u5206\u914d",
318
  assignByPrefix: "\u6309\u524d\u7f00\u5206\u914d",
src/routes/__tests__/accounts-import-export.test.ts ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Tests for account import/export endpoints.
3
+ * GET /auth/accounts/export — export all accounts with tokens
4
+ * POST /auth/accounts/import — bulk import accounts from tokens
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
8
+
9
+ // Mock fs before importing anything
10
+ vi.mock("fs", () => ({
11
+ readFileSync: vi.fn(() => { throw new Error("ENOENT"); }),
12
+ writeFileSync: vi.fn(),
13
+ renameSync: vi.fn(),
14
+ existsSync: vi.fn(() => false),
15
+ mkdirSync: vi.fn(),
16
+ }));
17
+
18
+ vi.mock("../../paths.js", () => ({
19
+ getDataDir: vi.fn(() => "/tmp/test-data"),
20
+ getConfigDir: vi.fn(() => "/tmp/test-config"),
21
+ }));
22
+
23
+ vi.mock("../../config.js", () => ({
24
+ getConfig: vi.fn(() => ({
25
+ auth: {
26
+ jwt_token: null,
27
+ rotation_strategy: "least_used",
28
+ rate_limit_backoff_seconds: 60,
29
+ },
30
+ server: { proxy_api_key: null },
31
+ })),
32
+ }));
33
+
34
+ // Mock JWT utilities — all tokens are "valid" by default
35
+ const mockIsTokenExpired = vi.hoisted(() => vi.fn(() => false));
36
+ vi.mock("../../auth/jwt-utils.js", () => ({
37
+ decodeJwtPayload: vi.fn(() => ({ exp: Math.floor(Date.now() / 1000) + 3600 })),
38
+ extractChatGptAccountId: vi.fn((token: string) => `acct-${token.slice(0, 8)}`),
39
+ extractUserProfile: vi.fn((token: string) => ({
40
+ email: `${token.slice(0, 4)}@test.com`,
41
+ chatgpt_plan_type: "free",
42
+ })),
43
+ isTokenExpired: mockIsTokenExpired,
44
+ }));
45
+
46
+ vi.mock("../../utils/jitter.js", () => ({
47
+ jitter: vi.fn((val: number) => val),
48
+ }));
49
+
50
+ vi.mock("../../models/model-store.js", () => ({
51
+ getModelPlanTypes: vi.fn(() => []),
52
+ }));
53
+
54
+ import { Hono } from "hono";
55
+ import { AccountPool } from "../../auth/account-pool.js";
56
+ import { createAccountRoutes } from "../../routes/accounts.js";
57
+
58
+ // Minimal RefreshScheduler stub
59
+ const mockScheduler = {
60
+ scheduleOne: vi.fn(),
61
+ clearOne: vi.fn(),
62
+ start: vi.fn(),
63
+ stop: vi.fn(),
64
+ };
65
+
66
+ describe("account import/export", () => {
67
+ let pool: AccountPool;
68
+ let app: Hono;
69
+
70
+ beforeEach(() => {
71
+ mockIsTokenExpired.mockReturnValue(false);
72
+ pool = new AccountPool();
73
+ const routes = createAccountRoutes(
74
+ pool,
75
+ mockScheduler as never,
76
+ );
77
+ app = new Hono();
78
+ app.route("/", routes);
79
+ });
80
+
81
+ afterEach(() => {
82
+ pool.destroy();
83
+ vi.clearAllMocks();
84
+ });
85
+
86
+ // ── Export ──────────────────────────────────────────────
87
+
88
+ it("GET /auth/accounts/export returns empty array when no accounts", async () => {
89
+ const res = await app.request("/auth/accounts/export");
90
+ expect(res.status).toBe(200);
91
+ const data = await res.json() as { accounts: unknown[] };
92
+ expect(data.accounts).toEqual([]);
93
+ });
94
+
95
+ it("GET /auth/accounts/export returns full entries with tokens", async () => {
96
+ pool.addAccount("tokenAAAA1234567890");
97
+ pool.addAccount("tokenBBBB1234567890");
98
+
99
+ const res = await app.request("/auth/accounts/export");
100
+ expect(res.status).toBe(200);
101
+ const data = await res.json() as { accounts: Array<{ token: string; email: string | null; id: string }> };
102
+ expect(data.accounts).toHaveLength(2);
103
+
104
+ // Must include sensitive fields (token, refreshToken)
105
+ for (const acct of data.accounts) {
106
+ expect(acct.token).toBeTruthy();
107
+ expect(acct.id).toBeTruthy();
108
+ }
109
+ });
110
+
111
+ it("GET /auth/accounts/export?ids= filters server-side", async () => {
112
+ const id1 = pool.addAccount("tokenAAAA1234567890");
113
+ pool.addAccount("tokenBBBB1234567890");
114
+
115
+ const res = await app.request(`/auth/accounts/export?ids=${id1}`);
116
+ expect(res.status).toBe(200);
117
+ const data = await res.json() as { accounts: Array<{ id: string }> };
118
+ expect(data.accounts).toHaveLength(1);
119
+ expect(data.accounts[0].id).toBe(id1);
120
+ });
121
+
122
+ // ── Import ─────────────────────────────────────────────
123
+
124
+ it("POST /auth/accounts/import adds new accounts", async () => {
125
+ const res = await app.request("/auth/accounts/import", {
126
+ method: "POST",
127
+ headers: { "Content-Type": "application/json" },
128
+ body: JSON.stringify({
129
+ accounts: [
130
+ { token: "tokenCCCC1234567890" },
131
+ { token: "tokenDDDD1234567890" },
132
+ ],
133
+ }),
134
+ });
135
+
136
+ expect(res.status).toBe(200);
137
+ const data = await res.json() as { added: number; updated: number; failed: number };
138
+ expect(data.added).toBe(2);
139
+ expect(data.updated).toBe(0);
140
+ expect(data.failed).toBe(0);
141
+
142
+ // Verify accounts are in the pool
143
+ expect(pool.getAccounts()).toHaveLength(2);
144
+ // Verify scheduler was called for each
145
+ expect(mockScheduler.scheduleOne).toHaveBeenCalledTimes(2);
146
+ });
147
+
148
+ it("POST /auth/accounts/import detects duplicates as updates", async () => {
149
+ // Pre-add an account
150
+ pool.addAccount("tokenEEEE1234567890");
151
+ expect(pool.getAccounts()).toHaveLength(1);
152
+
153
+ // Import same token again + one new
154
+ const res = await app.request("/auth/accounts/import", {
155
+ method: "POST",
156
+ headers: { "Content-Type": "application/json" },
157
+ body: JSON.stringify({
158
+ accounts: [
159
+ { token: "tokenEEEE1234567890" },
160
+ { token: "tokenFFFF1234567890" },
161
+ ],
162
+ }),
163
+ });
164
+
165
+ expect(res.status).toBe(200);
166
+ const data = await res.json() as { added: number; updated: number; failed: number };
167
+ expect(data.added).toBe(1);
168
+ expect(data.updated).toBe(1);
169
+ expect(data.failed).toBe(0);
170
+
171
+ // Pool should have 2 total (not 3)
172
+ expect(pool.getAccounts()).toHaveLength(2);
173
+ });
174
+
175
+ it("POST /auth/accounts/import handles invalid tokens", async () => {
176
+ // Make isTokenExpired return true for specific tokens
177
+ mockIsTokenExpired.mockImplementation(
178
+ ((...args: unknown[]) => args[0] === "expiredToken12345678") as () => boolean,
179
+ );
180
+
181
+ const res = await app.request("/auth/accounts/import", {
182
+ method: "POST",
183
+ headers: { "Content-Type": "application/json" },
184
+ body: JSON.stringify({
185
+ accounts: [
186
+ { token: "validToken123456789" },
187
+ { token: "expiredToken12345678" },
188
+ ],
189
+ }),
190
+ });
191
+
192
+ expect(res.status).toBe(200);
193
+ const data = await res.json() as { added: number; failed: number; errors: string[] };
194
+ expect(data.added).toBe(1);
195
+ expect(data.failed).toBe(1);
196
+ expect(data.errors).toHaveLength(1);
197
+ expect(data.errors[0]).toContain("expired");
198
+ });
199
+
200
+ it("POST /auth/accounts/import with refreshToken", async () => {
201
+ const res = await app.request("/auth/accounts/import", {
202
+ method: "POST",
203
+ headers: { "Content-Type": "application/json" },
204
+ body: JSON.stringify({
205
+ accounts: [
206
+ { token: "tokenGGGG1234567890", refreshToken: "refresh_abc" },
207
+ ],
208
+ }),
209
+ });
210
+
211
+ expect(res.status).toBe(200);
212
+ const data = await res.json() as { added: number };
213
+ expect(data.added).toBe(1);
214
+
215
+ // Verify refreshToken was passed
216
+ const entries = pool.getAllEntries();
217
+ expect(entries[0].refreshToken).toBe("refresh_abc");
218
+ });
219
+
220
+ it("POST /auth/accounts/import rejects empty accounts array", async () => {
221
+ const res = await app.request("/auth/accounts/import", {
222
+ method: "POST",
223
+ headers: { "Content-Type": "application/json" },
224
+ body: JSON.stringify({ accounts: [] }),
225
+ });
226
+
227
+ expect(res.status).toBe(400);
228
+ });
229
+
230
+ it("POST /auth/accounts/import rejects invalid body", async () => {
231
+ const res = await app.request("/auth/accounts/import", {
232
+ method: "POST",
233
+ headers: { "Content-Type": "application/json" },
234
+ body: JSON.stringify({ foo: "bar" }),
235
+ });
236
+
237
+ expect(res.status).toBe(400);
238
+ });
239
+
240
+ // ── Round-trip ─────────────────────────────────────────
241
+
242
+ it("export → import round-trip preserves accounts", async () => {
243
+ pool.addAccount("tokenHHHH1234567890");
244
+ pool.addAccount("tokenIIII1234567890");
245
+
246
+ // Export
247
+ const exportRes = await app.request("/auth/accounts/export");
248
+ const exported = await exportRes.json() as { accounts: Array<{ token: string; refreshToken?: string | null }> };
249
+ expect(exported.accounts).toHaveLength(2);
250
+
251
+ // Create a fresh pool + app
252
+ const pool2 = new AccountPool();
253
+ const routes2 = createAccountRoutes(pool2, mockScheduler as never);
254
+ const app2 = new Hono();
255
+ app2.route("/", routes2);
256
+
257
+ // Import the exported data (only token + refreshToken needed)
258
+ const importBody = {
259
+ accounts: exported.accounts.map((a) => ({
260
+ token: a.token,
261
+ refreshToken: a.refreshToken,
262
+ })),
263
+ };
264
+
265
+ const importRes = await app2.request("/auth/accounts/import", {
266
+ method: "POST",
267
+ headers: { "Content-Type": "application/json" },
268
+ body: JSON.stringify(importBody),
269
+ });
270
+
271
+ expect(importRes.status).toBe(200);
272
+ const result = await importRes.json() as { added: number };
273
+ expect(result.added).toBe(2);
274
+ expect(pool2.getAccounts()).toHaveLength(2);
275
+
276
+ pool2.destroy();
277
+ });
278
+ });
src/routes/accounts.ts CHANGED
@@ -14,6 +14,7 @@
14
  */
15
 
16
  import { Hono } from "hono";
 
17
  import type { AccountPool } from "../auth/account-pool.js";
18
  import type { RefreshScheduler } from "../auth/refresh-scheduler.js";
19
  import { validateManualToken } from "../auth/chatgpt-oauth.js";
@@ -25,6 +26,13 @@ import type { CodexQuota, AccountInfo } from "../auth/types.js";
25
  import type { CookieJar } from "../proxy/cookie-jar.js";
26
  import type { ProxyPool } from "../proxy/proxy-pool.js";
27
 
 
 
 
 
 
 
 
28
  function toQuota(usage: CodexUsageResponse): CodexQuota {
29
  const sw = usage.rate_limit.secondary_window;
30
  return {
@@ -79,6 +87,62 @@ export function createAccountRoutes(
79
  return c.redirect(authUrl);
80
  });
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  // List all accounts (with optional ?quota=true)
83
  app.get("/auth/accounts", async (c) => {
84
  const accounts = pool.getAccounts();
 
14
  */
15
 
16
  import { Hono } from "hono";
17
+ import { z } from "zod";
18
  import type { AccountPool } from "../auth/account-pool.js";
19
  import type { RefreshScheduler } from "../auth/refresh-scheduler.js";
20
  import { validateManualToken } from "../auth/chatgpt-oauth.js";
 
26
  import type { CookieJar } from "../proxy/cookie-jar.js";
27
  import type { ProxyPool } from "../proxy/proxy-pool.js";
28
 
29
+ const BulkImportSchema = z.object({
30
+ accounts: z.array(z.object({
31
+ token: z.string().min(1),
32
+ refreshToken: z.string().nullable().optional(),
33
+ })).min(1),
34
+ });
35
+
36
  function toQuota(usage: CodexUsageResponse): CodexQuota {
37
  const sw = usage.rate_limit.secondary_window;
38
  return {
 
87
  return c.redirect(authUrl);
88
  });
89
 
90
+ // Export accounts (with tokens) for backup/migration
91
+ // ?ids=id1,id2 for selective export; omit for all
92
+ app.get("/auth/accounts/export", (c) => {
93
+ let entries = pool.getAllEntries();
94
+ const idsParam = c.req.query("ids");
95
+ if (idsParam) {
96
+ const idSet = new Set(idsParam.split(",").filter(Boolean));
97
+ entries = entries.filter((e) => idSet.has(e.id));
98
+ }
99
+ return c.json({ accounts: entries });
100
+ });
101
+
102
+ // Bulk import accounts from tokens
103
+ app.post("/auth/accounts/import", async (c) => {
104
+ let body: unknown;
105
+ try {
106
+ body = await c.req.json();
107
+ } catch {
108
+ c.status(400);
109
+ return c.json({ error: "Malformed JSON request body" });
110
+ }
111
+
112
+ const parsed = BulkImportSchema.safeParse(body);
113
+ if (!parsed.success) {
114
+ c.status(400);
115
+ return c.json({ error: "Invalid request", details: parsed.error.issues });
116
+ }
117
+
118
+ let added = 0;
119
+ let updated = 0;
120
+ let failed = 0;
121
+ const errors: string[] = [];
122
+ const existingIds = new Set(pool.getAccounts().map((a) => a.id));
123
+
124
+ for (const entry of parsed.data.accounts) {
125
+ const validation = validateManualToken(entry.token);
126
+ if (!validation.valid) {
127
+ failed++;
128
+ errors.push(validation.error ?? "Invalid token");
129
+ continue;
130
+ }
131
+
132
+ const entryId = pool.addAccount(entry.token, entry.refreshToken ?? null);
133
+ scheduler.scheduleOne(entryId, entry.token);
134
+
135
+ if (existingIds.has(entryId)) {
136
+ updated++;
137
+ } else {
138
+ added++;
139
+ existingIds.add(entryId);
140
+ }
141
+ }
142
+
143
+ return c.json({ success: true, added, updated, failed, errors });
144
+ });
145
+
146
  // List all accounts (with optional ?quota=true)
147
  app.get("/auth/accounts", async (c) => {
148
  const accounts = pool.getAccounts();
web/src/App.tsx CHANGED
@@ -117,6 +117,8 @@ function Dashboard() {
117
  lastUpdated={accounts.lastUpdated}
118
  proxies={proxies.proxies}
119
  onProxyChange={handleProxyChange}
 
 
120
  />
121
  <ProxyPool proxies={proxies} />
122
  <ApiConfig
 
117
  lastUpdated={accounts.lastUpdated}
118
  proxies={proxies.proxies}
119
  onProxyChange={handleProxyChange}
120
+ onExport={accounts.exportAccounts}
121
+ onImport={accounts.importAccounts}
122
  />
123
  <ProxyPool proxies={proxies} />
124
  <ApiConfig
web/src/components/AccountCard.tsx CHANGED
@@ -41,9 +41,11 @@ interface AccountCardProps {
41
  onDelete: (id: string) => Promise<string | null>;
42
  proxies?: ProxyEntry[];
43
  onProxyChange?: (accountId: string, proxyId: string) => void;
 
 
44
  }
45
 
46
- export function AccountCard({ account, index, onDelete, proxies, onProxyChange }: AccountCardProps) {
47
  const t = useT();
48
  const { lang } = useI18n();
49
  const email = account.email || "Unknown";
@@ -99,11 +101,23 @@ export function AccountCard({ account, index, onDelete, proxies, onProxyChange }
99
  const sWindowSec = srl?.limit_window_seconds;
100
  const sWindowDur = sWindowSec ? formatWindowDuration(sWindowSec, lang === "zh") : null;
101
 
 
 
 
 
102
  return (
103
- <div class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-4 shadow-sm hover:shadow-md transition-all hover:border-primary/30 dark:hover:border-primary/50">
104
  {/* Header */}
105
  <div class="flex justify-between items-start mb-4">
106
  <div class="flex items-center gap-3">
 
 
 
 
 
 
 
 
107
  <div class={`size-10 rounded-full ${bgColor} ${textColor} flex items-center justify-center font-bold text-lg`}>
108
  {initial}
109
  </div>
 
41
  onDelete: (id: string) => Promise<string | null>;
42
  proxies?: ProxyEntry[];
43
  onProxyChange?: (accountId: string, proxyId: string) => void;
44
+ selected?: boolean;
45
+ onToggleSelect?: (id: string) => void;
46
  }
47
 
48
+ export function AccountCard({ account, index, onDelete, proxies, onProxyChange, selected, onToggleSelect }: AccountCardProps) {
49
  const t = useT();
50
  const { lang } = useI18n();
51
  const email = account.email || "Unknown";
 
101
  const sWindowSec = srl?.limit_window_seconds;
102
  const sWindowDur = sWindowSec ? formatWindowDuration(sWindowSec, lang === "zh") : null;
103
 
104
+ const handleToggle = useCallback(() => {
105
+ onToggleSelect?.(account.id);
106
+ }, [account.id, onToggleSelect]);
107
+
108
  return (
109
+ <div class={`bg-white dark:bg-card-dark border rounded-xl p-4 shadow-sm hover:shadow-md transition-all ${selected ? "border-primary ring-1 ring-primary/30" : "border-gray-200 dark:border-border-dark hover:border-primary/30 dark:hover:border-primary/50"}`}>
110
  {/* Header */}
111
  <div class="flex justify-between items-start mb-4">
112
  <div class="flex items-center gap-3">
113
+ {onToggleSelect && (
114
+ <input
115
+ type="checkbox"
116
+ checked={selected}
117
+ onChange={handleToggle}
118
+ class="size-4 rounded border-gray-300 dark:border-border-dark text-primary focus:ring-primary/50 cursor-pointer shrink-0"
119
+ />
120
+ )}
121
  <div class={`size-10 rounded-full ${bgColor} ${textColor} flex items-center justify-center font-bold text-lg`}>
122
  {initial}
123
  </div>
web/src/components/AccountImportExport.tsx ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useCallback, useRef } from "preact/hooks";
2
+ import { useT } from "../../../shared/i18n/context";
3
+
4
+ interface ImportResult {
5
+ success: boolean;
6
+ added: number;
7
+ updated: number;
8
+ failed: number;
9
+ errors: string[];
10
+ }
11
+
12
+ interface AccountImportExportProps {
13
+ onExport: (selectedIds?: string[]) => Promise<void>;
14
+ onImport: (file: File) => Promise<ImportResult>;
15
+ selectedIds: Set<string>;
16
+ }
17
+
18
+ export function AccountImportExport({ onExport, onImport, selectedIds }: AccountImportExportProps) {
19
+ const t = useT();
20
+ const fileRef = useRef<HTMLInputElement>(null);
21
+ const [importing, setImporting] = useState(false);
22
+ const [result, setResult] = useState<string | null>(null);
23
+
24
+ const handleExport = useCallback(async () => {
25
+ try {
26
+ const ids = selectedIds.size > 0 ? [...selectedIds] : undefined;
27
+ await onExport(ids);
28
+ } catch (err) {
29
+ console.error("[AccountExport] failed:", err);
30
+ }
31
+ }, [onExport, selectedIds]);
32
+
33
+ const handleFileChange = useCallback(async () => {
34
+ const files = fileRef.current?.files;
35
+ if (!files || files.length === 0) return;
36
+
37
+ setImporting(true);
38
+ setResult(null);
39
+ try {
40
+ let totalAdded = 0, totalUpdated = 0, totalFailed = 0;
41
+ for (const file of files) {
42
+ const res = await onImport(file);
43
+ totalAdded += res.added;
44
+ totalUpdated += res.updated;
45
+ totalFailed += res.failed;
46
+ }
47
+ const msg = t("accountImportResult")
48
+ .replace("{added}", String(totalAdded))
49
+ .replace("{updated}", String(totalUpdated))
50
+ .replace("{failed}", String(totalFailed));
51
+ setResult(msg);
52
+ } catch {
53
+ setResult(t("accountImportError"));
54
+ } finally {
55
+ setImporting(false);
56
+ if (fileRef.current) fileRef.current.value = "";
57
+ }
58
+ }, [onImport, t]);
59
+
60
+ const triggerFileSelect = useCallback(() => {
61
+ fileRef.current?.click();
62
+ }, []);
63
+
64
+ const exportTitle = selectedIds.size > 0
65
+ ? `${t("exportBtn")} (${selectedIds.size})`
66
+ : t("exportBtn");
67
+
68
+ return (
69
+ <>
70
+ <input
71
+ ref={fileRef}
72
+ type="file"
73
+ accept=".json"
74
+ multiple
75
+ onChange={handleFileChange}
76
+ class="hidden"
77
+ />
78
+ <button
79
+ onClick={triggerFileSelect}
80
+ disabled={importing}
81
+ title={t("importBtn")}
82
+ class="p-1.5 text-slate-400 dark:text-text-dim hover:text-primary transition-colors rounded-md hover:bg-primary/10 disabled:opacity-40"
83
+ >
84
+ <svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
85
+ <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" />
86
+ </svg>
87
+ </button>
88
+ <button
89
+ onClick={handleExport}
90
+ title={exportTitle}
91
+ class="p-1.5 text-slate-400 dark:text-text-dim hover:text-primary transition-colors rounded-md hover:bg-primary/10"
92
+ >
93
+ <svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
94
+ <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12M12 16.5V3" />
95
+ </svg>
96
+ </button>
97
+ {selectedIds.size > 0 && (
98
+ <span class="text-[0.7rem] text-primary font-medium hidden sm:inline">
99
+ {selectedIds.size}
100
+ </span>
101
+ )}
102
+ {result && (
103
+ <span class="text-[0.75rem] text-slate-500 dark:text-text-dim hidden sm:inline">
104
+ {result}
105
+ </span>
106
+ )}
107
+ </>
108
+ );
109
+ }
web/src/components/AccountList.tsx CHANGED
@@ -1,5 +1,7 @@
 
1
  import { useI18n, useT } from "../../../shared/i18n/context";
2
  import { AccountCard } from "./AccountCard";
 
3
  import type { Account, ProxyEntry } from "../../../shared/types";
4
 
5
  interface AccountListProps {
@@ -11,11 +13,30 @@ interface AccountListProps {
11
  lastUpdated: Date | null;
12
  proxies?: ProxyEntry[];
13
  onProxyChange?: (accountId: string, proxyId: string) => void;
 
 
14
  }
15
 
16
- export function AccountList({ accounts, loading, onDelete, onRefresh, refreshing, lastUpdated, proxies, onProxyChange }: AccountListProps) {
17
  const t = useT();
18
  const { lang } = useI18n();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  const updatedAtText = lastUpdated
21
  ? lastUpdated.toLocaleTimeString(lang === "zh" ? "zh-CN" : "en-US", { hour: "2-digit", minute: "2-digit", second: "2-digit" })
@@ -34,6 +55,24 @@ export function AccountList({ accounts, loading, onDelete, onRefresh, refreshing
34
  {t("updatedAt")} {updatedAtText}
35
  </span>
36
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  <button
38
  onClick={onRefresh}
39
  disabled={refreshing}
@@ -63,7 +102,7 @@ export function AccountList({ accounts, loading, onDelete, onRefresh, refreshing
63
  </div>
64
  ) : (
65
  accounts.map((acct, i) => (
66
- <AccountCard key={acct.id} account={acct} index={i} onDelete={onDelete} proxies={proxies} onProxyChange={onProxyChange} />
67
  ))
68
  )}
69
  </div>
 
1
+ import { useState, useCallback } from "preact/hooks";
2
  import { useI18n, useT } from "../../../shared/i18n/context";
3
  import { AccountCard } from "./AccountCard";
4
+ import { AccountImportExport } from "./AccountImportExport";
5
  import type { Account, ProxyEntry } from "../../../shared/types";
6
 
7
  interface AccountListProps {
 
13
  lastUpdated: Date | null;
14
  proxies?: ProxyEntry[];
15
  onProxyChange?: (accountId: string, proxyId: string) => void;
16
+ onExport?: (selectedIds?: string[]) => Promise<void>;
17
+ onImport?: (file: File) => Promise<{ success: boolean; added: number; updated: number; failed: number; errors: string[] }>;
18
  }
19
 
20
+ export function AccountList({ accounts, loading, onDelete, onRefresh, refreshing, lastUpdated, proxies, onProxyChange, onExport, onImport }: AccountListProps) {
21
  const t = useT();
22
  const { lang } = useI18n();
23
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
24
+
25
+ const toggleSelect = useCallback((id: string) => {
26
+ setSelectedIds((prev) => {
27
+ const next = new Set(prev);
28
+ if (next.has(id)) next.delete(id);
29
+ else next.add(id);
30
+ return next;
31
+ });
32
+ }, []);
33
+
34
+ const toggleSelectAll = useCallback(() => {
35
+ setSelectedIds((prev) => {
36
+ if (prev.size === accounts.length) return new Set();
37
+ return new Set(accounts.map((a) => a.id));
38
+ });
39
+ }, [accounts]);
40
 
41
  const updatedAtText = lastUpdated
42
  ? lastUpdated.toLocaleTimeString(lang === "zh" ? "zh-CN" : "en-US", { hour: "2-digit", minute: "2-digit", second: "2-digit" })
 
55
  {t("updatedAt")} {updatedAtText}
56
  </span>
57
  )}
58
+ {onExport && onImport && (
59
+ <AccountImportExport onExport={onExport} onImport={onImport} selectedIds={selectedIds} />
60
+ )}
61
+ {accounts.length > 0 && (
62
+ <button
63
+ onClick={toggleSelectAll}
64
+ title={selectedIds.size === accounts.length ? t("deselectAll") : t("selectAll")}
65
+ class="p-1.5 text-slate-400 dark:text-text-dim hover:text-primary transition-colors rounded-md hover:bg-primary/10"
66
+ >
67
+ <svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
68
+ {selectedIds.size === accounts.length ? (
69
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
70
+ ) : (
71
+ <path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
72
+ )}
73
+ </svg>
74
+ </button>
75
+ )}
76
  <button
77
  onClick={onRefresh}
78
  disabled={refreshing}
 
102
  </div>
103
  ) : (
104
  accounts.map((acct, i) => (
105
+ <AccountCard key={acct.id} account={acct} index={i} onDelete={onDelete} proxies={proxies} onProxyChange={onProxyChange} selected={selectedIds.has(acct.id)} onToggleSelect={toggleSelect} />
106
  ))
107
  )}
108
  </div>