icebear icebear0828 commited on
Commit
7516302
·
unverified ·
1 Parent(s): 909a297

feat: account management page with batch operations (#146)

Browse files

* fix: treat unfetched plan types as possibly compatible in acquire()

When model-fetcher fails to fetch models for a plan type (e.g. the
selected free account had an expired token), _planModelMap only contains
successfully fetched plans. acquire() then excludes all accounts whose
planType is absent from the map — even though their compatibility is
unknown, not negative.

Add isPlanFetched() to model-store and use it in acquire() to
distinguish "plan was fetched and model is absent" (exclude) from
"plan was never fetched" (include as possibly compatible). This prevents
false 503s when a plan's model fetch failed at startup.

* feat: account management page with batch operations

Add #/account-management page for bulk account management:
- POST /auth/accounts/batch-delete and batch-status endpoints
- Batch delete, set active, set disabled with confirmation
- Status summary chips (clickable to filter)
- Reuse AccountTable with optional proxy column
- Import/export accessible from new page
- Fix markStatus() acquire lock leak (was missing acquireLocks.delete)
- Fix misleading test name in plan-routing-acquire

* fix: harden batch operations with error handling and loading state

- Hook batchDelete/batchSetStatus: return error string on failure (consistent with deleteAccount pattern)
- AccountManagement: try-catch + error/success toast, busy state prevents double-submit
- AccountBulkActions: accept loading prop, disable buttons during async
- Remove unnecessary AccountManagementPage wrapper
- Fix hardcoded "Dashboard" string → i18n t("backToDashboard")
- Remove redundant proxies! non-null assertion

---------

Co-authored-by: icebear0828 <icebear0828@users.noreply.github.com>

CHANGELOG.md CHANGED
@@ -15,6 +15,9 @@
15
  - 后台额度刷新周期性重试 banned 账号,成功即自动解封
16
  - 上游 401 token 吊销("token has been invalidated")自动标记过期并切换下一个账号
17
  - 之前 401 直接透传给客户端,不标记也不重试
 
 
 
18
 
19
  ### Fixed
20
 
 
15
  - 后台额度刷新周期性重试 banned 账号,成功即自动解封
16
  - 上游 401 token 吊销("token has been invalidated")自动标记过期并切换下一个账号
17
  - 之前 401 直接透传给客户端,不标记也不重试
18
+ - Account Management 页面(`#/account-management`):批量删除、批量改状态(active/disabled)、导入导出
19
+ - `POST /auth/accounts/batch-delete` 和 `POST /auth/accounts/batch-status` 批量端点
20
+ - 状态摘要条可点击筛选,复用 AccountTable 选择/分页/Shift 多选
21
 
22
  ### Fixed
23
 
shared/hooks/use-accounts.ts CHANGED
@@ -217,6 +217,42 @@ export function useAccounts() {
217
  return result;
218
  }, [loadAccounts]);
219
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  return {
221
  list,
222
  loading,
@@ -232,5 +268,7 @@ export function useAccounts() {
232
  deleteAccount,
233
  exportAccounts,
234
  importAccounts,
 
 
235
  };
236
  }
 
217
  return result;
218
  }, [loadAccounts]);
219
 
220
+ const batchDelete = useCallback(async (ids: string[]): Promise<string | null> => {
221
+ try {
222
+ const resp = await fetch("/auth/accounts/batch-delete", {
223
+ method: "POST",
224
+ headers: { "Content-Type": "application/json" },
225
+ body: JSON.stringify({ ids }),
226
+ });
227
+ if (!resp.ok) {
228
+ const data = await resp.json();
229
+ return data.error || "Batch delete failed";
230
+ }
231
+ await loadAccounts();
232
+ return null;
233
+ } catch (err) {
234
+ return "networkError" + (err instanceof Error ? err.message : "");
235
+ }
236
+ }, [loadAccounts]);
237
+
238
+ const batchSetStatus = useCallback(async (ids: string[], status: "active" | "disabled"): Promise<string | null> => {
239
+ try {
240
+ const resp = await fetch("/auth/accounts/batch-status", {
241
+ method: "POST",
242
+ headers: { "Content-Type": "application/json" },
243
+ body: JSON.stringify({ ids, status }),
244
+ });
245
+ if (!resp.ok) {
246
+ const data = await resp.json();
247
+ return data.error || "Batch status change failed";
248
+ }
249
+ await loadAccounts();
250
+ return null;
251
+ } catch (err) {
252
+ return "networkError" + (err instanceof Error ? err.message : "");
253
+ }
254
+ }, [loadAccounts]);
255
+
256
  return {
257
  list,
258
  loading,
 
268
  deleteAccount,
269
  exportAccounts,
270
  importAccounts,
271
+ batchDelete,
272
+ batchSetStatus,
273
  };
274
  }
shared/i18n/translations.ts CHANGED
@@ -201,6 +201,15 @@ export const translations = {
201
  rotationLeastUsedDesc: "Prefer the account with fewest requests.",
202
  rotationRoundRobinDesc: "Cycle through accounts in order.",
203
  rotationSaved: "Saved",
 
 
 
 
 
 
 
 
 
204
  },
205
  zh: {
206
  serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
@@ -407,6 +416,15 @@ export const translations = {
407
  rotationLeastUsedDesc: "\u4f18\u5148\u4f7f\u7528\u8bf7\u6c42\u6b21\u6570\u6700\u5c11\u7684\u8d26\u53f7\u3002",
408
  rotationRoundRobinDesc: "\u6309\u987a\u5e8f\u8f6e\u6d41\u4f7f\u7528\u5404\u8d26\u53f7\u3002",
409
  rotationSaved: "\u5df2\u4fdd\u5b58",
 
 
 
 
 
 
 
 
 
410
  },
411
  } as const;
412
 
 
201
  rotationLeastUsedDesc: "Prefer the account with fewest requests.",
202
  rotationRoundRobinDesc: "Cycle through accounts in order.",
203
  rotationSaved: "Saved",
204
+ accountManagement: "Account Management",
205
+ manageAccounts: "Manage Accounts",
206
+ batchDelete: "Delete Selected",
207
+ batchDeleteConfirm: "Confirm Delete?",
208
+ setActive: "Set Active",
209
+ setDisabled: "Set Disabled",
210
+ deleteSuccess: "Deleted",
211
+ statusChangeSuccess: "Updated",
212
+ cancel: "Cancel",
213
  },
214
  zh: {
215
  serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
 
416
  rotationLeastUsedDesc: "\u4f18\u5148\u4f7f\u7528\u8bf7\u6c42\u6b21\u6570\u6700\u5c11\u7684\u8d26\u53f7\u3002",
417
  rotationRoundRobinDesc: "\u6309\u987a\u5e8f\u8f6e\u6d41\u4f7f\u7528\u5404\u8d26\u53f7\u3002",
418
  rotationSaved: "\u5df2\u4fdd\u5b58",
419
+ accountManagement: "账号管理",
420
+ manageAccounts: "管理账号",
421
+ batchDelete: "批量删除",
422
+ batchDeleteConfirm: "确认删除?",
423
+ setActive: "设为活跃",
424
+ setDisabled: "设为禁用",
425
+ deleteSuccess: "已删除",
426
+ statusChangeSuccess: "已更新",
427
+ cancel: "取消",
428
  },
429
  } as const;
430
 
src/auth/__tests__/account-pool-quota.test.ts CHANGED
@@ -55,6 +55,7 @@ vi.mock("../../utils/jitter.js", () => ({
55
 
56
  vi.mock("../../models/model-store.js", () => ({
57
  getModelPlanTypes: vi.fn(() => []),
 
58
  }));
59
 
60
  import { AccountPool } from "../account-pool.js";
 
55
 
56
  vi.mock("../../models/model-store.js", () => ({
57
  getModelPlanTypes: vi.fn(() => []),
58
+ isPlanFetched: vi.fn(() => true),
59
  }));
60
 
61
  import { AccountPool } from "../account-pool.js";
src/auth/__tests__/account-pool-sticky.test.ts CHANGED
@@ -13,6 +13,7 @@ const mockGetModelPlanTypes = vi.fn<(id: string) => string[]>(() => []);
13
 
14
  vi.mock("../../models/model-store.js", () => ({
15
  getModelPlanTypes: (...args: unknown[]) => mockGetModelPlanTypes(args[0] as string),
 
16
  }));
17
 
18
  vi.mock("../../config.js", () => ({
 
13
 
14
  vi.mock("../../models/model-store.js", () => ({
15
  getModelPlanTypes: (...args: unknown[]) => mockGetModelPlanTypes(args[0] as string),
16
+ isPlanFetched: () => true,
17
  }));
18
 
19
  vi.mock("../../config.js", () => ({
src/auth/__tests__/account-pool-team-dedup.test.ts CHANGED
@@ -10,6 +10,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
10
 
11
  vi.mock("../../models/model-store.js", () => ({
12
  getModelPlanTypes: vi.fn(() => []),
 
13
  }));
14
 
15
  vi.mock("../../config.js", () => ({
 
10
 
11
  vi.mock("../../models/model-store.js", () => ({
12
  getModelPlanTypes: vi.fn(() => []),
13
+ isPlanFetched: vi.fn(() => true),
14
  }));
15
 
16
  vi.mock("../../config.js", () => ({
src/auth/__tests__/plan-routing-acquire.test.ts CHANGED
@@ -8,11 +8,13 @@
8
 
9
  import { describe, it, expect, vi, beforeEach } from "vitest";
10
 
11
- // Mock getModelPlanTypes to control plan routing
12
  const mockGetModelPlanTypes = vi.fn<(id: string) => string[]>(() => []);
 
13
 
14
  vi.mock("../../models/model-store.js", () => ({
15
  getModelPlanTypes: (...args: unknown[]) => mockGetModelPlanTypes(args[0] as string),
 
16
  }));
17
 
18
  vi.mock("../../config.js", () => ({
@@ -139,4 +141,64 @@ describe("account-pool plan-based routing", () => {
139
  // Both are valid candidates, should get one of them
140
  expect(["tok-free", "tok-team"]).toContain(acquired!.token);
141
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  });
 
8
 
9
  import { describe, it, expect, vi, beforeEach } from "vitest";
10
 
11
+ // Mock getModelPlanTypes and isPlanFetched to control plan routing
12
  const mockGetModelPlanTypes = vi.fn<(id: string) => string[]>(() => []);
13
+ const mockIsPlanFetched = vi.fn<(planType: string) => boolean>(() => true);
14
 
15
  vi.mock("../../models/model-store.js", () => ({
16
  getModelPlanTypes: (...args: unknown[]) => mockGetModelPlanTypes(args[0] as string),
17
+ isPlanFetched: (...args: unknown[]) => mockIsPlanFetched(args[0] as string),
18
  }));
19
 
20
  vi.mock("../../config.js", () => ({
 
141
  // Both are valid candidates, should get one of them
142
  expect(["tok-free", "tok-team"]).toContain(acquired!.token);
143
  });
144
+
145
+ it("includes accounts whose plan was never fetched (unfetched = possibly compatible)", () => {
146
+ // Model known to work on team, but free plan was never fetched
147
+ mockGetModelPlanTypes.mockReturnValue(["team"]);
148
+ mockIsPlanFetched.mockImplementation((plan: string) => plan === "team");
149
+
150
+ const pool = createPool(
151
+ { token: "tok-free", planType: "free", email: "free@test.com" },
152
+ { token: "tok-team", planType: "team", email: "team@test.com" },
153
+ );
154
+
155
+ // Both accounts are candidates (team is known, free is unfetched → possibly compatible)
156
+ const first = pool.acquire({ model: "gpt-5.4" });
157
+ expect(first).not.toBeNull();
158
+
159
+ // Second concurrent request should also succeed (two candidates available)
160
+ const second = pool.acquire({ model: "gpt-5.4" });
161
+ expect(second).not.toBeNull();
162
+ expect(second!.token).not.toBe(first!.token);
163
+
164
+ // Both tokens should be from our pool
165
+ const tokens = new Set([first!.token, second!.token]);
166
+ expect(tokens.has("tok-free")).toBe(true);
167
+ expect(tokens.has("tok-team")).toBe(true);
168
+ });
169
+
170
+ it("excludes accounts whose plan was fetched but lacks the model", () => {
171
+ // Model known to work on team; free plan was fetched and model is NOT in it
172
+ mockGetModelPlanTypes.mockReturnValue(["team"]);
173
+ mockIsPlanFetched.mockReturnValue(true); // both plans fetched
174
+
175
+ const pool = createPool(
176
+ { token: "tok-free", planType: "free", email: "free@test.com" },
177
+ { token: "tok-team", planType: "team", email: "team@test.com" },
178
+ );
179
+
180
+ // Lock the team account
181
+ const first = pool.acquire({ model: "gpt-5.4" });
182
+ expect(first).not.toBeNull();
183
+ expect(first!.token).toBe("tok-team");
184
+
185
+ // Second request — free plan was fetched and model is absent → null
186
+ const second = pool.acquire({ model: "gpt-5.4" });
187
+ expect(second).toBeNull();
188
+ });
189
+
190
+ it("unfetched plans are included even when model appears plan-locked", () => {
191
+ // Model supports team only, no plans have been fetched, but only free accounts exist
192
+ mockGetModelPlanTypes.mockReturnValue(["team"]);
193
+ mockIsPlanFetched.mockReturnValue(false); // no plans fetched yet
194
+
195
+ const pool = createPool(
196
+ { token: "tok-free", planType: "free", email: "free@test.com" },
197
+ );
198
+
199
+ // Free plan is unfetched → included as possibly compatible
200
+ const acquired = pool.acquire({ model: "gpt-5.4" });
201
+ expect(acquired).not.toBeNull();
202
+ expect(acquired!.token).toBe("tok-free");
203
+ });
204
  });
src/auth/account-pool.ts CHANGED
@@ -12,7 +12,7 @@ import {
12
  extractUserProfile,
13
  isTokenExpired,
14
  } from "./jwt-utils.js";
15
- import { getModelPlanTypes } from "../models/model-store.js";
16
  import { getRotationStrategy } from "./rotation-strategy.js";
17
  import { createFsPersistence } from "./account-persistence.js";
18
  import type { AccountPersistence } from "./account-persistence.js";
@@ -93,17 +93,25 @@ export class AccountPool {
93
 
94
  if (available.length === 0) return null;
95
 
96
- // Model-aware selection: prefer accounts whose planType matches the model's known plans
 
 
97
  let candidates = available;
98
  if (options?.model) {
99
  const preferredPlans = getModelPlanTypes(options.model);
100
  if (preferredPlans.length > 0) {
101
  const planSet = new Set(preferredPlans);
102
- const matched = available.filter((a) => a.planType && planSet.has(a.planType));
 
 
 
 
 
 
103
  if (matched.length > 0) {
104
  candidates = matched;
105
  } else {
106
- // No account matches the model's plan requirements don't fallback to incompatible accounts
107
  return null;
108
  }
109
  }
@@ -373,6 +381,7 @@ export class AccountPool {
373
  }
374
 
375
  markStatus(entryId: string, status: AccountEntry["status"]): void {
 
376
  const entry = this.accounts.get(entryId);
377
  if (!entry) return;
378
  entry.status = status;
 
12
  extractUserProfile,
13
  isTokenExpired,
14
  } from "./jwt-utils.js";
15
+ import { getModelPlanTypes, isPlanFetched } from "../models/model-store.js";
16
  import { getRotationStrategy } from "./rotation-strategy.js";
17
  import { createFsPersistence } from "./account-persistence.js";
18
  import type { AccountPersistence } from "./account-persistence.js";
 
93
 
94
  if (available.length === 0) return null;
95
 
96
+ // Model-aware selection: prefer accounts whose planType matches the model's known plans.
97
+ // Accounts whose planType has never been fetched are treated as "possibly compatible"
98
+ // to avoid false 503s when a plan's model fetch failed at startup.
99
  let candidates = available;
100
  if (options?.model) {
101
  const preferredPlans = getModelPlanTypes(options.model);
102
  if (preferredPlans.length > 0) {
103
  const planSet = new Set(preferredPlans);
104
+ const matched = available.filter((a) => {
105
+ if (!a.planType) return false;
106
+ // Plan is known to support this model
107
+ if (planSet.has(a.planType)) return true;
108
+ // Plan has never been fetched — include as possibly compatible
109
+ return !isPlanFetched(a.planType);
110
+ });
111
  if (matched.length > 0) {
112
  candidates = matched;
113
  } else {
114
+ // All accounts belong to plans that were fetched but don't include this model
115
  return null;
116
  }
117
  }
 
381
  }
382
 
383
  markStatus(entryId: string, status: AccountEntry["status"]): void {
384
+ this.acquireLocks.delete(entryId);
385
  const entry = this.accounts.get(entryId);
386
  if (!entry) return;
387
  entry.status = status;
src/models/model-store.ts CHANGED
@@ -308,6 +308,14 @@ export function getModelPlanTypes(modelId: string): string[] {
308
  return [...(_modelPlanIndex.get(modelId) ?? [])];
309
  }
310
 
 
 
 
 
 
 
 
 
311
  // ── Model name suffix parsing ───────────────────────────────────────
312
 
313
  export interface ParsedModelName {
 
308
  return [...(_modelPlanIndex.get(modelId) ?? [])];
309
  }
310
 
311
+ /**
312
+ * Check if models have ever been successfully fetched for a given plan type.
313
+ * Returns false when the plan's model list is unknown (fetch failed or never attempted).
314
+ */
315
+ export function isPlanFetched(planType: string): boolean {
316
+ return _planModelMap.has(planType);
317
+ }
318
+
319
  // ── Model name suffix parsing ───────────────────────────────────────
320
 
321
  export interface ParsedModelName {
src/routes/__tests__/accounts-batch.test.ts ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Tests for batch account operations.
3
+ * POST /auth/accounts/batch-delete — delete multiple accounts
4
+ * POST /auth/accounts/batch-status — change status for multiple accounts
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach } 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
+ vi.mock("../../auth/jwt-utils.js", () => ({
35
+ decodeJwtPayload: vi.fn(() => ({ exp: Math.floor(Date.now() / 1000) + 3600 })),
36
+ extractChatGptAccountId: vi.fn((token: string) => `acct-${token.slice(0, 8)}`),
37
+ extractUserProfile: vi.fn((token: string) => ({
38
+ email: `${token.slice(0, 4)}@test.com`,
39
+ chatgpt_plan_type: "free",
40
+ })),
41
+ isTokenExpired: vi.fn(() => false),
42
+ }));
43
+
44
+ vi.mock("../../utils/jitter.js", () => ({
45
+ jitter: vi.fn((val: number) => val),
46
+ }));
47
+
48
+ vi.mock("../../models/model-store.js", () => ({
49
+ getModelPlanTypes: vi.fn(() => []),
50
+ isPlanFetched: vi.fn(() => true),
51
+ }));
52
+
53
+ import { Hono } from "hono";
54
+ import { AccountPool } from "../../auth/account-pool.js";
55
+ import { createAccountRoutes } from "../../routes/accounts.js";
56
+
57
+ const mockScheduler = {
58
+ scheduleOne: vi.fn(),
59
+ clearOne: vi.fn(),
60
+ start: vi.fn(),
61
+ stop: vi.fn(),
62
+ };
63
+
64
+ function addAccounts(pool: AccountPool, count: number): string[] {
65
+ const ids: string[] = [];
66
+ for (let i = 0; i < count; i++) {
67
+ // Each token needs a unique first 8 chars to get a unique accountId from the mock
68
+ const unique = `${String.fromCharCode(65 + i)}${String(i).padStart(7, "0")}`;
69
+ ids.push(pool.addAccount(`${unique}-padding-for-length`));
70
+ }
71
+ return ids;
72
+ }
73
+
74
+ describe("batch account operations", () => {
75
+ let pool: AccountPool;
76
+ let app: Hono;
77
+
78
+ beforeEach(() => {
79
+ pool = new AccountPool();
80
+ const routes = createAccountRoutes(pool, mockScheduler as never);
81
+ app = new Hono();
82
+ app.route("/", routes);
83
+ mockScheduler.clearOne.mockClear();
84
+ });
85
+
86
+ // ── batch-delete ──────────────────────────────────────────
87
+
88
+ describe("POST /auth/accounts/batch-delete", () => {
89
+ it("deletes multiple accounts", async () => {
90
+ const ids = addAccounts(pool, 3);
91
+ expect(pool.getAccounts()).toHaveLength(3);
92
+
93
+ const res = await app.request("/auth/accounts/batch-delete", {
94
+ method: "POST",
95
+ headers: { "Content-Type": "application/json" },
96
+ body: JSON.stringify({ ids }),
97
+ });
98
+
99
+ expect(res.status).toBe(200);
100
+ const body = await res.json();
101
+ expect(body.deleted).toBe(3);
102
+ expect(body.notFound).toHaveLength(0);
103
+ expect(pool.getAccounts()).toHaveLength(0);
104
+ // Scheduler cleared for each
105
+ expect(mockScheduler.clearOne).toHaveBeenCalledTimes(3);
106
+ });
107
+
108
+ it("reports not-found ids without failing", async () => {
109
+ const ids = addAccounts(pool, 2);
110
+
111
+ const res = await app.request("/auth/accounts/batch-delete", {
112
+ method: "POST",
113
+ headers: { "Content-Type": "application/json" },
114
+ body: JSON.stringify({ ids: [...ids, "nonexistent-id"] }),
115
+ });
116
+
117
+ expect(res.status).toBe(200);
118
+ const body = await res.json();
119
+ expect(body.deleted).toBe(2);
120
+ expect(body.notFound).toEqual(["nonexistent-id"]);
121
+ });
122
+
123
+ it("rejects empty ids array", async () => {
124
+ const res = await app.request("/auth/accounts/batch-delete", {
125
+ method: "POST",
126
+ headers: { "Content-Type": "application/json" },
127
+ body: JSON.stringify({ ids: [] }),
128
+ });
129
+ expect(res.status).toBe(400);
130
+ });
131
+
132
+ it("rejects malformed body", async () => {
133
+ const res = await app.request("/auth/accounts/batch-delete", {
134
+ method: "POST",
135
+ headers: { "Content-Type": "application/json" },
136
+ body: JSON.stringify({ wrong: "field" }),
137
+ });
138
+ expect(res.status).toBe(400);
139
+ });
140
+ });
141
+
142
+ // ── batch-status ──────────────────────────────────────────
143
+
144
+ describe("POST /auth/accounts/batch-status", () => {
145
+ it("sets multiple accounts to disabled", async () => {
146
+ const ids = addAccounts(pool, 3);
147
+
148
+ const res = await app.request("/auth/accounts/batch-status", {
149
+ method: "POST",
150
+ headers: { "Content-Type": "application/json" },
151
+ body: JSON.stringify({ ids, status: "disabled" }),
152
+ });
153
+
154
+ expect(res.status).toBe(200);
155
+ const body = await res.json();
156
+ expect(body.updated).toBe(3);
157
+ expect(body.notFound).toHaveLength(0);
158
+
159
+ for (const acct of pool.getAccounts()) {
160
+ expect(acct.status).toBe("disabled");
161
+ }
162
+ });
163
+
164
+ it("sets accounts back to active", async () => {
165
+ const ids = addAccounts(pool, 2);
166
+ // First disable them
167
+ for (const id of ids) pool.markStatus(id, "disabled");
168
+
169
+ const res = await app.request("/auth/accounts/batch-status", {
170
+ method: "POST",
171
+ headers: { "Content-Type": "application/json" },
172
+ body: JSON.stringify({ ids, status: "active" }),
173
+ });
174
+
175
+ expect(res.status).toBe(200);
176
+ const body = await res.json();
177
+ expect(body.updated).toBe(2);
178
+ for (const acct of pool.getAccounts()) {
179
+ expect(acct.status).toBe("active");
180
+ }
181
+ });
182
+
183
+ it("rejects disallowed status values", async () => {
184
+ const ids = addAccounts(pool, 1);
185
+
186
+ for (const badStatus of ["expired", "banned", "rate_limited", "refreshing"]) {
187
+ const res = await app.request("/auth/accounts/batch-status", {
188
+ method: "POST",
189
+ headers: { "Content-Type": "application/json" },
190
+ body: JSON.stringify({ ids, status: badStatus }),
191
+ });
192
+ expect(res.status).toBe(400);
193
+ }
194
+ });
195
+
196
+ it("reports not-found ids", async () => {
197
+ const ids = addAccounts(pool, 1);
198
+
199
+ const res = await app.request("/auth/accounts/batch-status", {
200
+ method: "POST",
201
+ headers: { "Content-Type": "application/json" },
202
+ body: JSON.stringify({ ids: [...ids, "ghost"], status: "disabled" }),
203
+ });
204
+
205
+ expect(res.status).toBe(200);
206
+ const body = await res.json();
207
+ expect(body.updated).toBe(1);
208
+ expect(body.notFound).toEqual(["ghost"]);
209
+ });
210
+
211
+ it("rejects empty ids array", async () => {
212
+ const res = await app.request("/auth/accounts/batch-status", {
213
+ method: "POST",
214
+ headers: { "Content-Type": "application/json" },
215
+ body: JSON.stringify({ ids: [], status: "active" }),
216
+ });
217
+ expect(res.status).toBe(400);
218
+ });
219
+ });
220
+ });
src/routes/__tests__/accounts-delete-warnings.test.ts CHANGED
@@ -47,6 +47,7 @@ vi.mock("../../utils/jitter.js", () => ({
47
 
48
  vi.mock("../../models/model-store.js", () => ({
49
  getModelPlanTypes: vi.fn(() => []),
 
50
  }));
51
 
52
  import { Hono } from "hono";
 
47
 
48
  vi.mock("../../models/model-store.js", () => ({
49
  getModelPlanTypes: vi.fn(() => []),
50
+ isPlanFetched: vi.fn(() => true),
51
  }));
52
 
53
  import { Hono } from "hono";
src/routes/__tests__/accounts-import-export.test.ts CHANGED
@@ -49,6 +49,7 @@ vi.mock("../../utils/jitter.js", () => ({
49
 
50
  vi.mock("../../models/model-store.js", () => ({
51
  getModelPlanTypes: vi.fn(() => []),
 
52
  }));
53
 
54
  import { Hono } from "hono";
 
49
 
50
  vi.mock("../../models/model-store.js", () => ({
51
  getModelPlanTypes: vi.fn(() => []),
52
+ isPlanFetched: vi.fn(() => true),
53
  }));
54
 
55
  import { Hono } from "hono";
src/routes/accounts.ts CHANGED
@@ -28,6 +28,15 @@ import type { ProxyPool } from "../proxy/proxy-pool.js";
28
  import { toQuota } from "../auth/quota-utils.js";
29
  import { clearWarnings, getActiveWarnings, getWarningsLastUpdated } from "../auth/quota-warnings.js";
30
 
 
 
 
 
 
 
 
 
 
31
  const BulkImportSchema = z.object({
32
  accounts: z.array(z.object({
33
  token: z.string().min(1),
@@ -113,6 +122,72 @@ export function createAccountRoutes(
113
  return c.json({ success: true, added, updated, failed, errors });
114
  });
115
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  // List all accounts
117
  // ?quota=true → return cached quota (fast, from background refresh)
118
  // ?quota=fresh → force live fetch from upstream (manual refresh button)
 
28
  import { toQuota } from "../auth/quota-utils.js";
29
  import { clearWarnings, getActiveWarnings, getWarningsLastUpdated } from "../auth/quota-warnings.js";
30
 
31
+ const BatchIdsSchema = z.object({
32
+ ids: z.array(z.string()).min(1),
33
+ });
34
+
35
+ const BatchStatusSchema = z.object({
36
+ ids: z.array(z.string()).min(1),
37
+ status: z.enum(["active", "disabled"]),
38
+ });
39
+
40
  const BulkImportSchema = z.object({
41
  accounts: z.array(z.object({
42
  token: z.string().min(1),
 
122
  return c.json({ success: true, added, updated, failed, errors });
123
  });
124
 
125
+ // Batch delete accounts
126
+ app.post("/auth/accounts/batch-delete", async (c) => {
127
+ let body: unknown;
128
+ try {
129
+ body = await c.req.json();
130
+ } catch {
131
+ c.status(400);
132
+ return c.json({ error: "Malformed JSON request body" });
133
+ }
134
+
135
+ const parsed = BatchIdsSchema.safeParse(body);
136
+ if (!parsed.success) {
137
+ c.status(400);
138
+ return c.json({ error: "Invalid request", details: parsed.error.issues });
139
+ }
140
+
141
+ let deleted = 0;
142
+ const notFound: string[] = [];
143
+
144
+ for (const id of parsed.data.ids) {
145
+ scheduler.clearOne(id);
146
+ const removed = pool.removeAccount(id);
147
+ if (removed) {
148
+ cookieJar?.clear(id);
149
+ clearWarnings(id);
150
+ deleted++;
151
+ } else {
152
+ notFound.push(id);
153
+ }
154
+ }
155
+
156
+ return c.json({ success: true, deleted, notFound });
157
+ });
158
+
159
+ // Batch change account status
160
+ app.post("/auth/accounts/batch-status", async (c) => {
161
+ let body: unknown;
162
+ try {
163
+ body = await c.req.json();
164
+ } catch {
165
+ c.status(400);
166
+ return c.json({ error: "Malformed JSON request body" });
167
+ }
168
+
169
+ const parsed = BatchStatusSchema.safeParse(body);
170
+ if (!parsed.success) {
171
+ c.status(400);
172
+ return c.json({ error: "Invalid request", details: parsed.error.issues });
173
+ }
174
+
175
+ let updated = 0;
176
+ const notFound: string[] = [];
177
+
178
+ for (const id of parsed.data.ids) {
179
+ const entry = pool.getEntry(id);
180
+ if (entry) {
181
+ pool.markStatus(id, parsed.data.status);
182
+ updated++;
183
+ } else {
184
+ notFound.push(id);
185
+ }
186
+ }
187
+
188
+ return c.json({ success: true, updated, notFound });
189
+ });
190
+
191
  // List all accounts
192
  // ?quota=true → return cached quota (fast, from background refresh)
193
  // ?quota=fresh → force live fetch from upstream (manual refresh button)
web/src/App.tsx CHANGED
@@ -15,6 +15,7 @@ import { RotationSettings } from "./components/RotationSettings";
15
  import { TestConnection } from "./components/TestConnection";
16
  import { Footer } from "./components/Footer";
17
  import { ProxySettings } from "./pages/ProxySettings";
 
18
  import { useAccounts } from "../../shared/hooks/use-accounts";
19
  import { useProxies } from "../../shared/hooks/use-proxies";
20
  import { useStatus } from "../../shared/hooks/use-status";
@@ -187,11 +188,12 @@ function useHash(): string {
187
  export function App() {
188
  const hash = useHash();
189
  const isProxySettings = hash === "#/proxy-settings";
 
190
 
191
  return (
192
  <I18nProvider>
193
  <ThemeProvider>
194
- {isProxySettings ? <ProxySettingsPage /> : <Dashboard />}
195
  </ThemeProvider>
196
  </I18nProvider>
197
  );
 
15
  import { TestConnection } from "./components/TestConnection";
16
  import { Footer } from "./components/Footer";
17
  import { ProxySettings } from "./pages/ProxySettings";
18
+ import { AccountManagement } from "./pages/AccountManagement";
19
  import { useAccounts } from "../../shared/hooks/use-accounts";
20
  import { useProxies } from "../../shared/hooks/use-proxies";
21
  import { useStatus } from "../../shared/hooks/use-status";
 
188
  export function App() {
189
  const hash = useHash();
190
  const isProxySettings = hash === "#/proxy-settings";
191
+ const isAccountManagement = hash === "#/account-management";
192
 
193
  return (
194
  <I18nProvider>
195
  <ThemeProvider>
196
+ {isProxySettings ? <ProxySettingsPage /> : isAccountManagement ? <AccountManagement /> : <Dashboard />}
197
  </ThemeProvider>
198
  </I18nProvider>
199
  );
web/src/components/AccountBulkActions.tsx ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useCallback } from "preact/hooks";
2
+ import { useT } from "../../../shared/i18n/context";
3
+
4
+ interface AccountBulkActionsProps {
5
+ selectedCount: number;
6
+ loading: boolean;
7
+ onBatchDelete: () => void;
8
+ onSetActive: () => void;
9
+ onSetDisabled: () => void;
10
+ }
11
+
12
+ export function AccountBulkActions({
13
+ selectedCount,
14
+ loading,
15
+ onBatchDelete,
16
+ onSetActive,
17
+ onSetDisabled,
18
+ }: AccountBulkActionsProps) {
19
+ const t = useT();
20
+ const [confirming, setConfirming] = useState(false);
21
+
22
+ const handleDelete = useCallback(() => {
23
+ if (!confirming) {
24
+ setConfirming(true);
25
+ return;
26
+ }
27
+ setConfirming(false);
28
+ onBatchDelete();
29
+ }, [confirming, onBatchDelete]);
30
+
31
+ const cancelConfirm = useCallback(() => setConfirming(false), []);
32
+
33
+ if (selectedCount === 0) return null;
34
+
35
+ return (
36
+ <div class="sticky bottom-0 z-40 bg-white dark:bg-card-dark border-t border-gray-200 dark:border-border-dark shadow-lg px-4 py-3">
37
+ <div class="flex items-center gap-3 flex-wrap">
38
+ <span class="text-sm font-medium text-slate-700 dark:text-text-main shrink-0">
39
+ {selectedCount} {t("accountsCount")} {t("selected")}
40
+ </span>
41
+
42
+ <div class="h-4 w-px bg-gray-200 dark:bg-border-dark hidden sm:block" />
43
+
44
+ <button
45
+ onClick={onSetActive}
46
+ disabled={loading}
47
+ class="px-3 py-1.5 text-xs font-medium rounded-lg border border-green-200 dark:border-green-800/40 bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
48
+ >
49
+ {t("setActive")}
50
+ </button>
51
+
52
+ <button
53
+ onClick={onSetDisabled}
54
+ disabled={loading}
55
+ class="px-3 py-1.5 text-xs font-medium rounded-lg border border-slate-200 dark:border-slate-700/40 bg-slate-50 dark:bg-slate-800/20 text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
56
+ >
57
+ {t("setDisabled")}
58
+ </button>
59
+
60
+ <div class="h-4 w-px bg-gray-200 dark:bg-border-dark hidden sm:block" />
61
+
62
+ {confirming ? (
63
+ <div class="flex items-center gap-2">
64
+ <button
65
+ onClick={handleDelete}
66
+ disabled={loading}
67
+ class="px-3 py-1.5 text-xs font-medium rounded-lg bg-red-600 text-white hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
68
+ >
69
+ {t("batchDeleteConfirm")}
70
+ </button>
71
+ <button
72
+ onClick={cancelConfirm}
73
+ disabled={loading}
74
+ class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-200 dark:border-border-dark text-slate-600 dark:text-text-dim hover:bg-slate-50 dark:hover:bg-border-dark/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
75
+ >
76
+ {t("cancel")}
77
+ </button>
78
+ </div>
79
+ ) : (
80
+ <button
81
+ onClick={handleDelete}
82
+ disabled={loading}
83
+ class="px-3 py-1.5 text-xs font-medium rounded-lg border border-red-200 dark:border-red-800/40 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
84
+ >
85
+ {t("batchDelete")}
86
+ </button>
87
+ )}
88
+ </div>
89
+ </div>
90
+ );
91
+ }
web/src/components/AccountList.tsx CHANGED
@@ -70,6 +70,12 @@ export function AccountList({ accounts, loading, onDelete, onRefresh, refreshing
70
  {t("updatedAt")} {updatedAtText}
71
  </span>
72
  )}
 
 
 
 
 
 
73
  {onExport && onImport && (
74
  <AccountImportExport onExport={onExport} onImport={onImport} selectedIds={selectedIds} />
75
  )}
 
70
  {t("updatedAt")} {updatedAtText}
71
  </span>
72
  )}
73
+ <a
74
+ href="#/account-management"
75
+ class="text-[0.75rem] text-primary hover:text-primary/80 font-medium transition-colors"
76
+ >
77
+ {t("manageAccounts")} &rarr;
78
+ </a>
79
  {onExport && onImport && (
80
  <AccountImportExport onExport={onExport} onImport={onImport} selectedIds={selectedIds} />
81
  )}
web/src/components/AccountTable.tsx CHANGED
@@ -35,11 +35,11 @@ const statusStyles: Record<string, [string, TranslationKey]> = {
35
 
36
  interface AccountTableProps {
37
  accounts: AssignmentAccount[];
38
- proxies: ProxyEntry[];
39
  selectedIds: Set<string>;
40
  onSelectionChange: (ids: Set<string>) => void;
41
- onSingleProxyChange: (accountId: string, proxyId: string) => void;
42
- filterGroup: string | null;
43
  statusFilter: string;
44
  onStatusFilterChange: (status: string) => void;
45
  }
@@ -50,11 +50,12 @@ export function AccountTable({
50
  selectedIds,
51
  onSelectionChange,
52
  onSingleProxyChange,
53
- filterGroup,
54
  statusFilter,
55
  onStatusFilterChange,
56
  }: AccountTableProps) {
57
  const t = useT();
 
58
  const [search, setSearch] = useState("");
59
  const [page, setPage] = useState(0);
60
  const lastClickedIndex = useRef<number | null>(null);
@@ -182,7 +183,7 @@ export function AccountTable({
182
  </label>
183
  <span class="flex-1 min-w-0">Email</span>
184
  <span class="w-20 text-center hidden sm:block">{t("statusFilter")}</span>
185
- <span class="w-40 text-center hidden md:block">{t("proxyAssignment")}</span>
186
  </div>
187
 
188
  {/* Rows */}
@@ -224,23 +225,25 @@ export function AccountTable({
224
  {t(statusKey)}
225
  </span>
226
  </span>
227
- <span class="w-40 hidden md:block" onClick={(e) => e.stopPropagation()}>
228
- <select
229
- value={acct.proxyId || "global"}
230
- onChange={(e) => onSingleProxyChange(acct.id, (e.target as HTMLSelectElement).value)}
231
- class="w-full text-xs px-2 py-1 rounded-md border border-gray-200 dark:border-border-dark bg-white dark:bg-bg-dark text-slate-700 dark:text-text-main focus:outline-none focus:ring-1 focus:ring-primary cursor-pointer"
232
- >
233
- <option value="global">{t("globalDefault")}</option>
234
- <option value="direct">{t("directNoProxy")}</option>
235
- <option value="auto">{t("autoRoundRobin")}</option>
236
- {proxies.map((p) => (
237
- <option key={p.id} value={p.id}>
238
- {p.name}
239
- {p.health?.exitIp ? ` (${p.health.exitIp})` : ""}
240
- </option>
241
- ))}
242
- </select>
243
- </span>
 
 
244
  </div>
245
  );
246
  })
 
35
 
36
  interface AccountTableProps {
37
  accounts: AssignmentAccount[];
38
+ proxies?: ProxyEntry[];
39
  selectedIds: Set<string>;
40
  onSelectionChange: (ids: Set<string>) => void;
41
+ onSingleProxyChange?: (accountId: string, proxyId: string) => void;
42
+ filterGroup?: string | null;
43
  statusFilter: string;
44
  onStatusFilterChange: (status: string) => void;
45
  }
 
50
  selectedIds,
51
  onSelectionChange,
52
  onSingleProxyChange,
53
+ filterGroup = null,
54
  statusFilter,
55
  onStatusFilterChange,
56
  }: AccountTableProps) {
57
  const t = useT();
58
+ const showProxy = !!proxies;
59
  const [search, setSearch] = useState("");
60
  const [page, setPage] = useState(0);
61
  const lastClickedIndex = useRef<number | null>(null);
 
183
  </label>
184
  <span class="flex-1 min-w-0">Email</span>
185
  <span class="w-20 text-center hidden sm:block">{t("statusFilter")}</span>
186
+ {showProxy && <span class="w-40 text-center hidden md:block">{t("proxyAssignment")}</span>}
187
  </div>
188
 
189
  {/* Rows */}
 
225
  {t(statusKey)}
226
  </span>
227
  </span>
228
+ {showProxy && (
229
+ <span class="w-40 hidden md:block" onClick={(e) => e.stopPropagation()}>
230
+ <select
231
+ value={acct.proxyId || "global"}
232
+ onChange={(e) => onSingleProxyChange?.(acct.id, (e.target as HTMLSelectElement).value)}
233
+ class="w-full text-xs px-2 py-1 rounded-md border border-gray-200 dark:border-border-dark bg-white dark:bg-bg-dark text-slate-700 dark:text-text-main focus:outline-none focus:ring-1 focus:ring-primary cursor-pointer"
234
+ >
235
+ <option value="global">{t("globalDefault")}</option>
236
+ <option value="direct">{t("directNoProxy")}</option>
237
+ <option value="auto">{t("autoRoundRobin")}</option>
238
+ {proxies.map((p) => (
239
+ <option key={p.id} value={p.id}>
240
+ {p.name}
241
+ {p.health?.exitIp ? ` (${p.health.exitIp})` : ""}
242
+ </option>
243
+ ))}
244
+ </select>
245
+ </span>
246
+ )}
247
  </div>
248
  );
249
  })
web/src/pages/AccountManagement.tsx ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useCallback, useMemo } from "preact/hooks";
2
+ import { useT } from "../../../shared/i18n/context";
3
+ import { useAccounts } from "../../../shared/hooks/use-accounts";
4
+ import { AccountTable } from "../components/AccountTable";
5
+ import { AccountBulkActions } from "../components/AccountBulkActions";
6
+ import { AccountImportExport } from "../components/AccountImportExport";
7
+ import type { AssignmentAccount } from "../../../shared/hooks/use-proxy-assignments";
8
+ import type { TranslationKey } from "../../../shared/i18n/translations";
9
+
10
+ const statusOrder: Array<{ key: string; label: TranslationKey }> = [
11
+ { key: "active", label: "active" },
12
+ { key: "expired", label: "expired" },
13
+ { key: "rate_limited", label: "rateLimited" },
14
+ { key: "refreshing", label: "refreshing" },
15
+ { key: "disabled", label: "disabled" },
16
+ { key: "banned", label: "banned" },
17
+ ];
18
+
19
+ export function AccountManagement() {
20
+ const t = useT();
21
+ const { list, loading: listLoading, batchDelete, batchSetStatus, exportAccounts, importAccounts } = useAccounts();
22
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
23
+ const [statusFilter, setStatusFilter] = useState("all");
24
+ const [message, setMessage] = useState<{ text: string; error?: boolean } | null>(null);
25
+ const [busy, setBusy] = useState(false);
26
+
27
+ const tableAccounts: AssignmentAccount[] = useMemo(
28
+ () =>
29
+ list.map((a) => ({
30
+ id: a.id,
31
+ email: a.email || a.id.slice(0, 8),
32
+ status: a.status,
33
+ proxyId: a.proxyId || "global",
34
+ proxyName: "",
35
+ })),
36
+ [list],
37
+ );
38
+
39
+ const statusCounts = useMemo(() => {
40
+ const counts: Record<string, number> = {};
41
+ for (const a of list) {
42
+ counts[a.status] = (counts[a.status] || 0) + 1;
43
+ }
44
+ return counts;
45
+ }, [list]);
46
+
47
+ const showMessage = useCallback((text: string, error = false) => {
48
+ setMessage({ text, error });
49
+ setTimeout(() => setMessage(null), 3000);
50
+ }, []);
51
+
52
+ const handleBatchDelete = useCallback(async () => {
53
+ setBusy(true);
54
+ try {
55
+ const err = await batchDelete([...selectedIds]);
56
+ if (err) {
57
+ showMessage(err, true);
58
+ } else {
59
+ setSelectedIds(new Set());
60
+ showMessage(t("deleteSuccess"));
61
+ }
62
+ } finally {
63
+ setBusy(false);
64
+ }
65
+ }, [selectedIds, batchDelete, t, showMessage]);
66
+
67
+ const handleSetActive = useCallback(async () => {
68
+ setBusy(true);
69
+ try {
70
+ const err = await batchSetStatus([...selectedIds], "active");
71
+ if (err) {
72
+ showMessage(err, true);
73
+ } else {
74
+ setSelectedIds(new Set());
75
+ showMessage(t("statusChangeSuccess"));
76
+ }
77
+ } finally {
78
+ setBusy(false);
79
+ }
80
+ }, [selectedIds, batchSetStatus, t, showMessage]);
81
+
82
+ const handleSetDisabled = useCallback(async () => {
83
+ setBusy(true);
84
+ try {
85
+ const err = await batchSetStatus([...selectedIds], "disabled");
86
+ if (err) {
87
+ showMessage(err, true);
88
+ } else {
89
+ setSelectedIds(new Set());
90
+ showMessage(t("statusChangeSuccess"));
91
+ }
92
+ } finally {
93
+ setBusy(false);
94
+ }
95
+ }, [selectedIds, batchSetStatus, t, showMessage]);
96
+
97
+ const handleStatusChipClick = useCallback((status: string) => {
98
+ setStatusFilter((prev) => (prev === status ? "all" : status));
99
+ }, []);
100
+
101
+ return (
102
+ <div class="min-h-screen bg-slate-50 dark:bg-bg-dark flex flex-col">
103
+ {/* Header */}
104
+ <header class="sticky top-0 z-50 bg-white dark:bg-card-dark border-b border-gray-200 dark:border-border-dark px-4 py-3">
105
+ <div class="max-w-[1100px] mx-auto flex items-center gap-3">
106
+ <a
107
+ href="#/"
108
+ class="text-sm text-slate-500 dark:text-text-dim hover:text-primary transition-colors"
109
+ >
110
+ &larr; {t("backToDashboard")}
111
+ </a>
112
+ <h1 class="text-base font-semibold text-slate-800 dark:text-text-main">
113
+ {t("accountManagement")}
114
+ </h1>
115
+ <div class="flex-1" />
116
+ <AccountImportExport
117
+ onExport={exportAccounts}
118
+ onImport={importAccounts}
119
+ selectedIds={selectedIds}
120
+ />
121
+ </div>
122
+ </header>
123
+
124
+ {/* Main content */}
125
+ <main class="flex-grow px-4 md:px-8 py-6 max-w-[1100px] mx-auto w-full">
126
+ {/* Status summary chips */}
127
+ <div class="flex flex-wrap gap-2 mb-4">
128
+ {statusOrder.map(({ key, label }) => {
129
+ const count = statusCounts[key] || 0;
130
+ if (count === 0) return null;
131
+ const isActive = statusFilter === key;
132
+ return (
133
+ <button
134
+ key={key}
135
+ onClick={() => handleStatusChipClick(key)}
136
+ class={`px-3 py-1 text-xs font-medium rounded-full border transition-colors ${
137
+ isActive
138
+ ? "bg-primary text-white border-primary"
139
+ : "bg-white dark:bg-card-dark border-gray-200 dark:border-border-dark text-slate-600 dark:text-text-dim hover:border-primary/50"
140
+ }`}
141
+ >
142
+ {t(label)} ({count})
143
+ </button>
144
+ );
145
+ })}
146
+ <span class="px-3 py-1 text-xs text-slate-400 dark:text-text-dim">
147
+ {list.length} {t("totalItems")}
148
+ </span>
149
+ </div>
150
+
151
+ {/* Message toast */}
152
+ {message && (
153
+ <div class={`mb-4 px-4 py-2 rounded-lg text-sm font-medium ${
154
+ message.error
155
+ ? "bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400"
156
+ : "bg-primary/10 text-primary"
157
+ }`}>
158
+ {message.text}
159
+ </div>
160
+ )}
161
+
162
+ {/* Table */}
163
+ {listLoading ? (
164
+ <div class="text-center py-12 text-slate-400 dark:text-text-dim">Loading...</div>
165
+ ) : (
166
+ <AccountTable
167
+ accounts={tableAccounts}
168
+ selectedIds={selectedIds}
169
+ onSelectionChange={setSelectedIds}
170
+ statusFilter={statusFilter}
171
+ onStatusFilterChange={setStatusFilter}
172
+ />
173
+ )}
174
+ </main>
175
+
176
+ {/* Bulk actions bar */}
177
+ <AccountBulkActions
178
+ selectedCount={selectedIds.size}
179
+ loading={busy}
180
+ onBatchDelete={handleBatchDelete}
181
+ onSetActive={handleSetActive}
182
+ onSetDisabled={handleSetDisabled}
183
+ />
184
+ </div>
185
+ );
186
+ }