icebear0828 commited on
Commit
268c5a4
·
1 Parent(s): d07b5c0

feat: bulk account import/export with selective export (#82)

Browse files

- GET /auth/accounts/export — export all accounts (with tokens) for backup
- POST /auth/accounts/import — bulk import from JSON with dedup detection
- Dashboard: import/export buttons next to refresh, multi-file import
- Selective export: checkbox on each account card, select all/deselect all
- 9 unit tests for backend routes

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,58 @@ 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 +220,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 resp = await fetch("/auth/accounts/export");
160
+ const data = await resp.json() as { accounts: Array<{ id: string }> };
161
+ // Filter to selected accounts if specified
162
+ if (selectedIds && selectedIds.length > 0) {
163
+ const idSet = new Set(selectedIds);
164
+ data.accounts = data.accounts.filter((a) => idSet.has(a.id));
165
+ }
166
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
167
+ const url = URL.createObjectURL(blob);
168
+ const a = document.createElement("a");
169
+ a.href = url;
170
+ const date = new Date().toISOString().slice(0, 10);
171
+ a.download = `accounts-export-${date}.json`;
172
+ a.style.display = "none";
173
+ document.body.appendChild(a);
174
+ a.click();
175
+ document.body.removeChild(a);
176
+ URL.revokeObjectURL(url);
177
+ }, []);
178
+
179
+ const importAccounts = useCallback(async (file: File): Promise<{
180
+ success: boolean;
181
+ added: number;
182
+ updated: number;
183
+ failed: number;
184
+ errors: string[];
185
+ }> => {
186
+ const text = await file.text();
187
+ const parsed = JSON.parse(text) as Record<string, unknown>;
188
+ // Support both { accounts: [...] } (export format) and raw array
189
+ const accounts = Array.isArray(parsed)
190
+ ? parsed
191
+ : Array.isArray(parsed.accounts)
192
+ ? parsed.accounts
193
+ : null;
194
+ if (!accounts) {
195
+ return { success: false, added: 0, updated: 0, failed: 0, errors: ["Invalid format: expected { accounts: [...] }"] };
196
+ }
197
+
198
+ const resp = await fetch("/auth/accounts/import", {
199
+ method: "POST",
200
+ headers: { "Content-Type": "application/json" },
201
+ body: JSON.stringify({ accounts }),
202
+ });
203
+ const result = await resp.json();
204
+ if (resp.ok) {
205
+ await loadAccounts();
206
+ }
207
+ return result;
208
+ }, [loadAccounts]);
209
+
210
  return {
211
  list,
212
  loading,
 
220
  startAdd,
221
  submitRelay,
222
  deleteAccount,
223
+ exportAccounts,
224
+ importAccounts,
225
  };
226
  }
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,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ // ── Import ─────────────────────────────────────────────
112
+
113
+ it("POST /auth/accounts/import adds new accounts", async () => {
114
+ const res = await app.request("/auth/accounts/import", {
115
+ method: "POST",
116
+ headers: { "Content-Type": "application/json" },
117
+ body: JSON.stringify({
118
+ accounts: [
119
+ { token: "tokenCCCC1234567890" },
120
+ { token: "tokenDDDD1234567890" },
121
+ ],
122
+ }),
123
+ });
124
+
125
+ expect(res.status).toBe(200);
126
+ const data = await res.json() as { added: number; updated: number; failed: number };
127
+ expect(data.added).toBe(2);
128
+ expect(data.updated).toBe(0);
129
+ expect(data.failed).toBe(0);
130
+
131
+ // Verify accounts are in the pool
132
+ expect(pool.getAccounts()).toHaveLength(2);
133
+ // Verify scheduler was called for each
134
+ expect(mockScheduler.scheduleOne).toHaveBeenCalledTimes(2);
135
+ });
136
+
137
+ it("POST /auth/accounts/import detects duplicates as updates", async () => {
138
+ // Pre-add an account
139
+ pool.addAccount("tokenEEEE1234567890");
140
+ expect(pool.getAccounts()).toHaveLength(1);
141
+
142
+ // Import same token again + one new
143
+ const res = await app.request("/auth/accounts/import", {
144
+ method: "POST",
145
+ headers: { "Content-Type": "application/json" },
146
+ body: JSON.stringify({
147
+ accounts: [
148
+ { token: "tokenEEEE1234567890" },
149
+ { token: "tokenFFFF1234567890" },
150
+ ],
151
+ }),
152
+ });
153
+
154
+ expect(res.status).toBe(200);
155
+ const data = await res.json() as { added: number; updated: number; failed: number };
156
+ expect(data.added).toBe(1);
157
+ expect(data.updated).toBe(1);
158
+ expect(data.failed).toBe(0);
159
+
160
+ // Pool should have 2 total (not 3)
161
+ expect(pool.getAccounts()).toHaveLength(2);
162
+ });
163
+
164
+ it("POST /auth/accounts/import handles invalid tokens", async () => {
165
+ // Make isTokenExpired return true for specific tokens
166
+ mockIsTokenExpired.mockImplementation(
167
+ ((...args: unknown[]) => args[0] === "expiredToken12345678") as () => boolean,
168
+ );
169
+
170
+ const res = await app.request("/auth/accounts/import", {
171
+ method: "POST",
172
+ headers: { "Content-Type": "application/json" },
173
+ body: JSON.stringify({
174
+ accounts: [
175
+ { token: "validToken123456789" },
176
+ { token: "expiredToken12345678" },
177
+ ],
178
+ }),
179
+ });
180
+
181
+ expect(res.status).toBe(200);
182
+ const data = await res.json() as { added: number; failed: number; errors: string[] };
183
+ expect(data.added).toBe(1);
184
+ expect(data.failed).toBe(1);
185
+ expect(data.errors).toHaveLength(1);
186
+ expect(data.errors[0]).toContain("expired");
187
+ });
188
+
189
+ it("POST /auth/accounts/import with refreshToken", async () => {
190
+ const res = await app.request("/auth/accounts/import", {
191
+ method: "POST",
192
+ headers: { "Content-Type": "application/json" },
193
+ body: JSON.stringify({
194
+ accounts: [
195
+ { token: "tokenGGGG1234567890", refreshToken: "refresh_abc" },
196
+ ],
197
+ }),
198
+ });
199
+
200
+ expect(res.status).toBe(200);
201
+ const data = await res.json() as { added: number };
202
+ expect(data.added).toBe(1);
203
+
204
+ // Verify refreshToken was passed
205
+ const entries = pool.getAllEntries();
206
+ expect(entries[0].refreshToken).toBe("refresh_abc");
207
+ });
208
+
209
+ it("POST /auth/accounts/import rejects empty accounts array", async () => {
210
+ const res = await app.request("/auth/accounts/import", {
211
+ method: "POST",
212
+ headers: { "Content-Type": "application/json" },
213
+ body: JSON.stringify({ accounts: [] }),
214
+ });
215
+
216
+ expect(res.status).toBe(400);
217
+ });
218
+
219
+ it("POST /auth/accounts/import rejects invalid body", async () => {
220
+ const res = await app.request("/auth/accounts/import", {
221
+ method: "POST",
222
+ headers: { "Content-Type": "application/json" },
223
+ body: JSON.stringify({ foo: "bar" }),
224
+ });
225
+
226
+ expect(res.status).toBe(400);
227
+ });
228
+
229
+ // ── Round-trip ─────────────────────────────────────────
230
+
231
+ it("export → import round-trip preserves accounts", async () => {
232
+ pool.addAccount("tokenHHHH1234567890");
233
+ pool.addAccount("tokenIIII1234567890");
234
+
235
+ // Export
236
+ const exportRes = await app.request("/auth/accounts/export");
237
+ const exported = await exportRes.json() as { accounts: Array<{ token: string; refreshToken?: string | null }> };
238
+ expect(exported.accounts).toHaveLength(2);
239
+
240
+ // Create a fresh pool + app
241
+ const pool2 = new AccountPool();
242
+ const routes2 = createAccountRoutes(pool2, mockScheduler as never);
243
+ const app2 = new Hono();
244
+ app2.route("/", routes2);
245
+
246
+ // Import the exported data (only token + refreshToken needed)
247
+ const importBody = {
248
+ accounts: exported.accounts.map((a) => ({
249
+ token: a.token,
250
+ refreshToken: a.refreshToken,
251
+ })),
252
+ };
253
+
254
+ const importRes = await app2.request("/auth/accounts/import", {
255
+ method: "POST",
256
+ headers: { "Content-Type": "application/json" },
257
+ body: JSON.stringify(importBody),
258
+ });
259
+
260
+ expect(importRes.status).toBe(200);
261
+ const result = await importRes.json() as { added: number };
262
+ expect(result.added).toBe(2);
263
+ expect(pool2.getAccounts()).toHaveLength(2);
264
+
265
+ pool2.destroy();
266
+ });
267
+ });
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,56 @@ 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 all accounts (with tokens) for backup/migration
91
+ app.get("/auth/accounts/export", (c) => {
92
+ const entries = pool.getAllEntries();
93
+ return c.json({ accounts: entries });
94
+ });
95
+
96
+ // Bulk import accounts from tokens
97
+ app.post("/auth/accounts/import", async (c) => {
98
+ let body: unknown;
99
+ try {
100
+ body = await c.req.json();
101
+ } catch {
102
+ c.status(400);
103
+ return c.json({ error: "Malformed JSON request body" });
104
+ }
105
+
106
+ const parsed = BulkImportSchema.safeParse(body);
107
+ if (!parsed.success) {
108
+ c.status(400);
109
+ return c.json({ error: "Invalid request", details: parsed.error.issues });
110
+ }
111
+
112
+ let added = 0;
113
+ let updated = 0;
114
+ let failed = 0;
115
+ const errors: string[] = [];
116
+ const existingIds = new Set(pool.getAccounts().map((a) => a.id));
117
+
118
+ for (const entry of parsed.data.accounts) {
119
+ const validation = validateManualToken(entry.token);
120
+ if (!validation.valid) {
121
+ failed++;
122
+ errors.push(validation.error ?? "Invalid token");
123
+ continue;
124
+ }
125
+
126
+ const entryId = pool.addAccount(entry.token, entry.refreshToken ?? null);
127
+ scheduler.scheduleOne(entryId, entry.token);
128
+
129
+ if (existingIds.has(entryId)) {
130
+ updated++;
131
+ } else {
132
+ added++;
133
+ existingIds.add(entryId);
134
+ }
135
+ }
136
+
137
+ return c.json({ success: true, added, updated, failed, errors });
138
+ });
139
+
140
  // List all accounts (with optional ?quota=true)
141
  app.get("/auth/accounts", async (c) => {
142
  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>