icebear icebear0828 Claude Opus 4.6 commited on
Commit
f878eff
·
unverified ·
1 Parent(s): 2ee9797

feat: proxy assignment management page with bulk operations (#34)

Browse files

* feat: proxy assignment management page with bulk operations

Add dedicated page (#/proxy-settings) for managing proxy assignments
across hundreds of accounts with matrix-style dual-column layout:

- Left sidebar: proxy group list with counts, search, click-to-filter
- Right panel: paginated account table (50/page) with search, status
filter, Shift+click range select, per-row proxy dropdown
- Bulk actions: batch assign to proxy, round-robin even distribute,
rule-based assignment modal
- Import/export: download JSON assignments, upload with diff preview
- Hash routing (zero-dependency), Header navigation links
- Backend: 6 new bulk API endpoints + ProxyPool.bulkAssign()
- i18n: 35 new translation keys (en + zh)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review issues in proxy assignment feature

- Make accountPool required param (was optional but always passed)
- Extract isValidProxyId() helper to eliminate duplicated validation
- Add resp.ok checks in use-proxy-assignments hook (throw on failure)
- Fix useCallback dependency arrays: use specific hook methods instead
of entire data object to prevent unnecessary re-renders
- Fix pageAccounts dependency via ref in AccountTable toggleSelect

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: icebear0828 <icebear0828@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

CHANGELOG.md CHANGED
@@ -12,6 +12,14 @@
12
 
13
  ### Added
14
 
 
 
 
 
 
 
 
 
15
  - 代理池功能:支持为不同账号配置不同的上游代理,实现 IP 多样化和风险隔离
16
  - 代理 CRUD:添加、删除、启用、禁用代理(HTTP/HTTPS/SOCKS5)
17
  - 四种分配模式:Global Default(全局代理)、Direct(直连)、Auto(Round-Robin 轮转)、指定代理
 
12
 
13
  ### Added
14
 
15
+ - 代理分配管理页面(`#/proxy-settings`):双栏矩阵式布局,批量管理数百账号的代理分配
16
+ - 左栏代理组列表:按 Global/Direct/Auto/各代理分组显示计数徽章,点击筛选
17
+ - 右栏账号表格:搜索、状态筛选、分页(50条/页)、Shift+点击连续多选、每行独立代理下拉
18
+ - 批量操作栏:批量设为指定代理、均匀分配到所有活跃代理(round-robin)、按规则分配
19
+ - 导入导出:导出 JSON 分配文件、导入后预览 diff 再确认应用
20
+ - Hash 路由零依赖切换,Header 导航链接(Dashboard ↔ 代理分配)
21
+ - 后端新增 6 个批量 API:assignments 列表/批量分配/规则分配/导出/导入预览/应用导入
22
+
23
  - 代理池功能:支持为不同账号配置不同的上游代理,实现 IP 多样化和风险隔离
24
  - 代理 CRUD:添加、删除、启用、禁用代理(HTTP/HTTPS/SOCKS5)
25
  - 四种分配模式:Global Default(全局代理)、Direct(直连)、Auto(Round-Robin 轮转)、指定代理
shared/hooks/use-proxy-assignments.ts ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useCallback } from "preact/hooks";
2
+ import type { ProxyEntry } from "../types";
3
+
4
+ export interface AssignmentAccount {
5
+ id: string;
6
+ email: string;
7
+ status: string;
8
+ proxyId: string;
9
+ proxyName: string;
10
+ }
11
+
12
+ export interface ImportDiff {
13
+ changes: Array<{ email: string; accountId: string; from: string; to: string }>;
14
+ unchanged: number;
15
+ }
16
+
17
+ export interface ProxyAssignmentsState {
18
+ accounts: AssignmentAccount[];
19
+ proxies: ProxyEntry[];
20
+ loading: boolean;
21
+ refresh: () => Promise<void>;
22
+ assignBulk: (assignments: Array<{ accountId: string; proxyId: string }>) => Promise<void>;
23
+ assignRule: (accountIds: string[], rule: string, targetProxyIds: string[]) => Promise<void>;
24
+ exportAssignments: () => Promise<Array<{ email: string; proxyId: string }>>;
25
+ importPreview: (data: Array<{ email: string; proxyId: string }>) => Promise<ImportDiff | null>;
26
+ applyImport: (assignments: Array<{ accountId: string; proxyId: string }>) => Promise<void>;
27
+ }
28
+
29
+ export function useProxyAssignments(): ProxyAssignmentsState {
30
+ const [accounts, setAccounts] = useState<AssignmentAccount[]>([]);
31
+ const [proxies, setProxies] = useState<ProxyEntry[]>([]);
32
+ const [loading, setLoading] = useState(true);
33
+
34
+ const refresh = useCallback(async () => {
35
+ try {
36
+ const resp = await fetch("/api/proxies/assignments");
37
+ const data = await resp.json();
38
+ setAccounts(data.accounts || []);
39
+ setProxies(data.proxies || []);
40
+ } catch {
41
+ // ignore
42
+ } finally {
43
+ setLoading(false);
44
+ }
45
+ }, []);
46
+
47
+ useEffect(() => {
48
+ refresh();
49
+ }, [refresh]);
50
+
51
+ const assignBulk = useCallback(
52
+ async (assignments: Array<{ accountId: string; proxyId: string }>) => {
53
+ const resp = await fetch("/api/proxies/assign-bulk", {
54
+ method: "POST",
55
+ headers: { "Content-Type": "application/json" },
56
+ body: JSON.stringify({ assignments }),
57
+ });
58
+ if (!resp.ok) {
59
+ const err = await resp.json().catch(() => ({ error: "Request failed" }));
60
+ throw new Error((err as { error?: string }).error ?? "Request failed");
61
+ }
62
+ await refresh();
63
+ },
64
+ [refresh],
65
+ );
66
+
67
+ const assignRule = useCallback(
68
+ async (accountIds: string[], rule: string, targetProxyIds: string[]) => {
69
+ const resp = await fetch("/api/proxies/assign-rule", {
70
+ method: "POST",
71
+ headers: { "Content-Type": "application/json" },
72
+ body: JSON.stringify({ accountIds, rule, targetProxyIds }),
73
+ });
74
+ if (!resp.ok) {
75
+ const err = await resp.json().catch(() => ({ error: "Request failed" }));
76
+ throw new Error((err as { error?: string }).error ?? "Request failed");
77
+ }
78
+ await refresh();
79
+ },
80
+ [refresh],
81
+ );
82
+
83
+ const exportAssignments = useCallback(async (): Promise<Array<{ email: string; proxyId: string }>> => {
84
+ const resp = await fetch("/api/proxies/assignments/export");
85
+ if (!resp.ok) return [];
86
+ const data: { assignments?: Array<{ email: string; proxyId: string }> } = await resp.json();
87
+ return data.assignments || [];
88
+ }, []);
89
+
90
+ const importPreview = useCallback(
91
+ async (data: Array<{ email: string; proxyId: string }>): Promise<ImportDiff | null> => {
92
+ const resp = await fetch("/api/proxies/assignments/import", {
93
+ method: "POST",
94
+ headers: { "Content-Type": "application/json" },
95
+ body: JSON.stringify({ assignments: data }),
96
+ });
97
+ if (!resp.ok) return null;
98
+ const result: ImportDiff = await resp.json();
99
+ return result;
100
+ },
101
+ [],
102
+ );
103
+
104
+ const applyImport = useCallback(
105
+ async (assignments: Array<{ accountId: string; proxyId: string }>) => {
106
+ const resp = await fetch("/api/proxies/assignments/apply", {
107
+ method: "POST",
108
+ headers: { "Content-Type": "application/json" },
109
+ body: JSON.stringify({ assignments }),
110
+ });
111
+ if (!resp.ok) {
112
+ const err = await resp.json().catch(() => ({ error: "Request failed" }));
113
+ throw new Error((err as { error?: string }).error ?? "Request failed");
114
+ }
115
+ await refresh();
116
+ },
117
+ [refresh],
118
+ );
119
+
120
+ return {
121
+ accounts,
122
+ proxies,
123
+ loading,
124
+ refresh,
125
+ assignBulk,
126
+ assignRule,
127
+ exportAssignments,
128
+ importPreview,
129
+ applyImport,
130
+ };
131
+ }
shared/i18n/translations.ts CHANGED
@@ -102,6 +102,42 @@ export const translations = {
102
  autoRoundRobin: "Auto (Round-Robin)",
103
  healthInterval: "Health check interval",
104
  minutes: "min",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  },
106
  zh: {
107
  serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
@@ -209,6 +245,42 @@ export const translations = {
209
  autoRoundRobin: "\u81ea\u52a8\u8f6e\u8f6c",
210
  healthInterval: "\u5065\u5eb7\u68c0\u67e5\u95f4\u9694",
211
  minutes: "\u5206\u949f",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  },
213
  } as const;
214
 
 
102
  autoRoundRobin: "Auto (Round-Robin)",
103
  healthInterval: "Health check interval",
104
  minutes: "min",
105
+ proxySettings: "Proxy Assignment",
106
+ proxySettingsDesc: "Bulk manage proxy assignments, import & export.",
107
+ allAccounts: "All",
108
+ unassigned: "Unassigned",
109
+ searchProxy: "Search proxies...",
110
+ searchAccount: "Search accounts...",
111
+ statusFilter: "Status",
112
+ allStatuses: "All Statuses",
113
+ selected: "selected",
114
+ accountsCount: "accounts",
115
+ batchAssignTo: "Batch assign to",
116
+ applyBtn: "Apply",
117
+ evenDistribute: "Distribute evenly",
118
+ ruleAssign: "Rule assign...",
119
+ importBtn: "Import",
120
+ exportBtn: "Export",
121
+ importPreview: "Import Preview",
122
+ changesCount: "changes",
123
+ confirmApply: "Confirm Apply",
124
+ cancelBtn: "Cancel",
125
+ noChanges: "No changes",
126
+ roundRobinRule: "Round-robin",
127
+ assignByStatus: "By status",
128
+ assignByPrefix: "By email prefix",
129
+ ruleTarget: "Target proxies",
130
+ assignRuleTitle: "Rule Assignment",
131
+ backToDashboard: "Dashboard",
132
+ pageLabel: "Page",
133
+ prevPage: "Previous",
134
+ nextPage: "Next",
135
+ totalItems: "total",
136
+ shiftSelectHint: "Shift+click to select range",
137
+ selectAll: "Select all",
138
+ exportSuccess: "Export complete",
139
+ importFile: "Select JSON file",
140
+ downloadJson: "Download JSON",
141
  },
142
  zh: {
143
  serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
 
245
  autoRoundRobin: "\u81ea\u52a8\u8f6e\u8f6c",
246
  healthInterval: "\u5065\u5eb7\u68c0\u67e5\u95f4\u9694",
247
  minutes: "\u5206\u949f",
248
+ proxySettings: "\u4ee3\u7406\u5206\u914d\u7ba1\u7406",
249
+ proxySettingsDesc: "\u6279\u91cf\u7ba1\u7406\u4ee3\u7406\u5206\u914d\u3001\u5bfc\u5165\u5bfc\u51fa\u3002",
250
+ allAccounts: "\u5168\u90e8",
251
+ unassigned: "\u672a\u5206\u914d",
252
+ searchProxy: "\u641c\u7d22\u4ee3\u7406...",
253
+ searchAccount: "\u641c\u7d22\u8d26\u53f7...",
254
+ statusFilter: "\u72b6\u6001",
255
+ allStatuses: "\u5168\u90e8\u72b6\u6001",
256
+ selected: "\u5df2\u9009",
257
+ accountsCount: "\u4e2a\u8d26\u53f7",
258
+ batchAssignTo: "\u6279\u91cf\u8bbe\u4e3a",
259
+ applyBtn: "\u5e94\u7528",
260
+ evenDistribute: "\u5747\u5300\u5206\u914d",
261
+ ruleAssign: "\u6309\u89c4\u5219\u5206\u914d...",
262
+ importBtn: "\u5bfc\u5165",
263
+ exportBtn: "\u5bfc\u51fa",
264
+ importPreview: "\u5bfc\u5165\u9884\u89c8",
265
+ changesCount: "\u6761\u53d8\u66f4",
266
+ confirmApply: "\u786e\u8ba4\u5e94\u7528",
267
+ cancelBtn: "\u53d6\u6d88",
268
+ noChanges: "\u65e0\u53d8\u66f4",
269
+ roundRobinRule: "\u8f6e\u8be2\u5206\u914d",
270
+ assignByStatus: "\u6309\u72b6\u6001\u5206\u914d",
271
+ assignByPrefix: "\u6309\u524d\u7f00\u5206\u914d",
272
+ ruleTarget: "\u76ee\u6807\u4ee3\u7406",
273
+ assignRuleTitle: "\u89c4\u5219\u5206\u914d",
274
+ backToDashboard: "Dashboard",
275
+ pageLabel: "\u7b2c",
276
+ prevPage: "\u4e0a\u4e00\u9875",
277
+ nextPage: "\u4e0b\u4e00\u9875",
278
+ totalItems: "\u5171",
279
+ shiftSelectHint: "Shift+\u70b9\u51fb\u8fde\u7eed\u591a\u9009",
280
+ selectAll: "\u5168\u9009",
281
+ exportSuccess: "\u5bfc\u51fa\u6210\u529f",
282
+ importFile: "\u9009\u62e9 JSON \u6587\u4ef6",
283
+ downloadJson: "\u4e0b\u8f7d JSON",
284
  },
285
  } as const;
286
 
src/index.ts CHANGED
@@ -72,7 +72,7 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
72
  const chatRoutes = createChatRoutes(accountPool, cookieJar, proxyPool);
73
  const messagesRoutes = createMessagesRoutes(accountPool, cookieJar, proxyPool);
74
  const geminiRoutes = createGeminiRoutes(accountPool, cookieJar, proxyPool);
75
- const proxyRoutes = createProxyRoutes(proxyPool);
76
  const webRoutes = createWebRoutes(accountPool);
77
 
78
  app.route("/", authRoutes);
 
72
  const chatRoutes = createChatRoutes(accountPool, cookieJar, proxyPool);
73
  const messagesRoutes = createMessagesRoutes(accountPool, cookieJar, proxyPool);
74
  const geminiRoutes = createGeminiRoutes(accountPool, cookieJar, proxyPool);
75
+ const proxyRoutes = createProxyRoutes(proxyPool, accountPool);
76
  const webRoutes = createWebRoutes(accountPool);
77
 
78
  app.route("/", authRoutes);
src/proxy/proxy-pool.ts CHANGED
@@ -160,6 +160,13 @@ export class ProxyPool {
160
  this.persistNow();
161
  }
162
 
 
 
 
 
 
 
 
163
  unassign(accountId: string): void {
164
  if (this.assignments.delete(accountId)) {
165
  this.persistNow();
 
160
  this.persistNow();
161
  }
162
 
163
+ bulkAssign(assignments: Array<{ accountId: string; proxyId: string }>): void {
164
+ for (const { accountId, proxyId } of assignments) {
165
+ this.assignments.set(accountId, proxyId);
166
+ }
167
+ this.persistNow();
168
+ }
169
+
170
  unassign(accountId: string): void {
171
  if (this.assignments.delete(accountId)) {
172
  this.persistNow();
src/routes/proxies.ts CHANGED
@@ -16,8 +16,9 @@
16
 
17
  import { Hono } from "hono";
18
  import type { ProxyPool } from "../proxy/proxy-pool.js";
 
19
 
20
- export function createProxyRoutes(proxyPool: ProxyPool): Hono {
21
  const app = new Hono();
22
 
23
  // List all proxies + assignments (credentials masked)
@@ -186,6 +187,173 @@ export function createProxyRoutes(proxyPool: ProxyPool): Hono {
186
  });
187
  });
188
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  return app;
190
  }
191
 
 
16
 
17
  import { Hono } from "hono";
18
  import type { ProxyPool } from "../proxy/proxy-pool.js";
19
+ import type { AccountPool } from "../auth/account-pool.js";
20
 
21
+ export function createProxyRoutes(proxyPool: ProxyPool, accountPool: AccountPool): Hono {
22
  const app = new Hono();
23
 
24
  // List all proxies + assignments (credentials masked)
 
187
  });
188
  });
189
 
190
+ // ── Bulk Assignment Endpoints ────────────────────────────────────
191
+
192
+ /** Check if a proxyId is valid (special keyword or existing proxy). */
193
+ const isValidProxyId = (proxyId: string): boolean =>
194
+ ["global", "direct", "auto"].includes(proxyId) || !!proxyPool.getById(proxyId);
195
+
196
+ // List all accounts with their proxy assignments
197
+ app.get("/api/proxies/assignments", (c) => {
198
+ const accounts = accountPool.getAccounts().map((a) => ({
199
+ id: a.id,
200
+ email: a.email,
201
+ status: a.status,
202
+ proxyId: proxyPool.getAssignment(a.id),
203
+ proxyName: proxyPool.getAssignmentDisplayName(a.id),
204
+ }));
205
+
206
+ return c.json({
207
+ accounts,
208
+ proxies: proxyPool.getAllMasked(),
209
+ });
210
+ });
211
+
212
+ // Bulk assign proxies to accounts
213
+ app.post("/api/proxies/assign-bulk", async (c) => {
214
+ const body = await c.req.json<{
215
+ assignments?: Array<{ accountId: string; proxyId: string }>;
216
+ }>();
217
+
218
+ if (!Array.isArray(body.assignments) || body.assignments.length === 0) {
219
+ c.status(400);
220
+ return c.json({ error: "assignments array is required and must not be empty" });
221
+ }
222
+
223
+ for (const { proxyId } of body.assignments) {
224
+ if (!isValidProxyId(proxyId)) {
225
+ c.status(400);
226
+ return c.json({ error: `Invalid proxyId: "${proxyId}". Use 'global', 'direct', 'auto', or a valid proxy ID.` });
227
+ }
228
+ }
229
+
230
+ proxyPool.bulkAssign(body.assignments);
231
+ return c.json({ success: true, applied: body.assignments.length });
232
+ });
233
+
234
+ // Assign by rule (e.g. round-robin distribution)
235
+ app.post("/api/proxies/assign-rule", async (c) => {
236
+ const body = await c.req.json<{
237
+ accountIds?: string[];
238
+ rule?: string;
239
+ targetProxyIds?: string[];
240
+ }>();
241
+
242
+ if (!Array.isArray(body.accountIds) || body.accountIds.length === 0) {
243
+ c.status(400);
244
+ return c.json({ error: "accountIds array is required" });
245
+ }
246
+ if (!Array.isArray(body.targetProxyIds) || body.targetProxyIds.length === 0) {
247
+ c.status(400);
248
+ return c.json({ error: "targetProxyIds array is required" });
249
+ }
250
+ if (body.rule !== "round-robin") {
251
+ c.status(400);
252
+ return c.json({ error: `Unsupported rule: "${body.rule ?? ""}". Supported: "round-robin".` });
253
+ }
254
+
255
+ for (const pid of body.targetProxyIds) {
256
+ if (!isValidProxyId(pid)) {
257
+ c.status(400);
258
+ return c.json({ error: `Invalid targetProxyId: "${pid}"` });
259
+ }
260
+ }
261
+
262
+ // Distribute accounts evenly across target proxies
263
+ const assignments: Array<{ accountId: string; proxyId: string }> = [];
264
+ for (let i = 0; i < body.accountIds.length; i++) {
265
+ assignments.push({
266
+ accountId: body.accountIds[i],
267
+ proxyId: body.targetProxyIds[i % body.targetProxyIds.length],
268
+ });
269
+ }
270
+
271
+ proxyPool.bulkAssign(assignments);
272
+ return c.json({ success: true, applied: assignments.length, assignments });
273
+ });
274
+
275
+ // Export assignments (by email for portability)
276
+ app.get("/api/proxies/assignments/export", (c) => {
277
+ const allAssignments = proxyPool.getAllAssignments();
278
+ const emailMap = new Map(accountPool.getAccounts().map((a) => [a.id, a.email]));
279
+
280
+ const exported = allAssignments
281
+ .map((a) => ({
282
+ email: emailMap.get(a.accountId) ?? null,
283
+ proxyId: a.proxyId,
284
+ }))
285
+ .filter((a): a is { email: string; proxyId: string } => a.email !== null);
286
+
287
+ return c.json({ assignments: exported });
288
+ });
289
+
290
+ // Import assignments preview (does NOT apply)
291
+ app.post("/api/proxies/assignments/import", async (c) => {
292
+ const body = await c.req.json<{
293
+ assignments?: Array<{ email: string; proxyId: string }>;
294
+ }>();
295
+
296
+ if (!Array.isArray(body.assignments)) {
297
+ c.status(400);
298
+ return c.json({ error: "assignments array is required" });
299
+ }
300
+
301
+ const emailToAccount = new Map(
302
+ accountPool.getAccounts()
303
+ .filter((a) => a.email !== null)
304
+ .map((a) => [a.email, a] as const),
305
+ );
306
+
307
+ const changes: Array<{
308
+ email: string;
309
+ accountId: string;
310
+ from: string;
311
+ to: string;
312
+ }> = [];
313
+ let unchanged = 0;
314
+
315
+ for (const { email, proxyId } of body.assignments) {
316
+ const account = emailToAccount.get(email);
317
+ if (!account) continue; // skip unknown emails
318
+
319
+ const currentProxyId = proxyPool.getAssignment(account.id);
320
+ if (currentProxyId === proxyId) {
321
+ unchanged++;
322
+ } else {
323
+ changes.push({
324
+ email,
325
+ accountId: account.id,
326
+ from: currentProxyId,
327
+ to: proxyId,
328
+ });
329
+ }
330
+ }
331
+
332
+ return c.json({ changes, unchanged });
333
+ });
334
+
335
+ // Apply imported assignments (same as bulk assign)
336
+ app.post("/api/proxies/assignments/apply", async (c) => {
337
+ const body = await c.req.json<{
338
+ assignments?: Array<{ accountId: string; proxyId: string }>;
339
+ }>();
340
+
341
+ if (!Array.isArray(body.assignments) || body.assignments.length === 0) {
342
+ c.status(400);
343
+ return c.json({ error: "assignments array is required and must not be empty" });
344
+ }
345
+
346
+ for (const { proxyId } of body.assignments) {
347
+ if (!isValidProxyId(proxyId)) {
348
+ c.status(400);
349
+ return c.json({ error: `Invalid proxyId: "${proxyId}"` });
350
+ }
351
+ }
352
+
353
+ proxyPool.bulkAssign(body.assignments);
354
+ return c.json({ success: true, applied: body.assignments.length });
355
+ });
356
+
357
  return app;
358
  }
359
 
web/src/App.tsx CHANGED
@@ -1,3 +1,4 @@
 
1
  import { I18nProvider } from "../../shared/i18n/context";
2
  import { ThemeProvider } from "../../shared/theme/context";
3
  import { Header } from "./components/Header";
@@ -8,6 +9,7 @@ import { ApiConfig } from "./components/ApiConfig";
8
  import { AnthropicSetup } from "./components/AnthropicSetup";
9
  import { CodeExamples } from "./components/CodeExamples";
10
  import { Footer } from "./components/Footer";
 
11
  import { useAccounts } from "../../shared/hooks/use-accounts";
12
  import { useProxies } from "../../shared/hooks/use-proxies";
13
  import { useStatus } from "../../shared/hooks/use-status";
@@ -122,12 +124,44 @@ function Dashboard() {
122
  );
123
  }
124
 
 
 
 
 
 
 
 
 
 
 
125
  export function App() {
 
 
 
126
  return (
127
  <I18nProvider>
128
  <ThemeProvider>
129
- <Dashboard />
130
  </ThemeProvider>
131
  </I18nProvider>
132
  );
133
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from "preact/hooks";
2
  import { I18nProvider } from "../../shared/i18n/context";
3
  import { ThemeProvider } from "../../shared/theme/context";
4
  import { Header } from "./components/Header";
 
9
  import { AnthropicSetup } from "./components/AnthropicSetup";
10
  import { CodeExamples } from "./components/CodeExamples";
11
  import { Footer } from "./components/Footer";
12
+ import { ProxySettings } from "./pages/ProxySettings";
13
  import { useAccounts } from "../../shared/hooks/use-accounts";
14
  import { useProxies } from "../../shared/hooks/use-proxies";
15
  import { useStatus } from "../../shared/hooks/use-status";
 
124
  );
125
  }
126
 
127
+ function useHash(): string {
128
+ const [hash, setHash] = useState(location.hash);
129
+ useEffect(() => {
130
+ const handler = () => setHash(location.hash);
131
+ window.addEventListener("hashchange", handler);
132
+ return () => window.removeEventListener("hashchange", handler);
133
+ }, []);
134
+ return hash;
135
+ }
136
+
137
  export function App() {
138
+ const hash = useHash();
139
+ const isProxySettings = hash === "#/proxy-settings";
140
+
141
  return (
142
  <I18nProvider>
143
  <ThemeProvider>
144
+ {isProxySettings ? <ProxySettingsPage /> : <Dashboard />}
145
  </ThemeProvider>
146
  </I18nProvider>
147
  );
148
  }
149
+
150
+ function ProxySettingsPage() {
151
+ const update = useUpdateMessage();
152
+
153
+ return (
154
+ <>
155
+ <Header
156
+ onAddAccount={() => { location.hash = ""; }}
157
+ onCheckUpdate={update.checkForUpdate}
158
+ checking={update.checking}
159
+ updateStatusMsg={update.msg}
160
+ updateStatusColor={update.color}
161
+ version={update.status?.proxy.version ?? null}
162
+ isProxySettings
163
+ />
164
+ <ProxySettings />
165
+ </>
166
+ );
167
+ }
web/src/components/AccountTable.tsx ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useCallback, useRef } from "preact/hooks";
2
+ import { useT } from "../../../shared/i18n/context";
3
+ import type { TranslationKey } from "../../../shared/i18n/translations";
4
+ import type { AssignmentAccount } from "../../../shared/hooks/use-proxy-assignments";
5
+ import type { ProxyEntry } from "../../../shared/types";
6
+
7
+ const PAGE_SIZE = 50;
8
+
9
+ const statusStyles: Record<string, [string, TranslationKey]> = {
10
+ active: [
11
+ "bg-green-100 text-green-700 border-green-200 dark:bg-[#11281d] dark:text-primary dark:border-[#1a442e]",
12
+ "active",
13
+ ],
14
+ expired: [
15
+ "bg-red-100 text-red-600 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800/30",
16
+ "expired",
17
+ ],
18
+ rate_limited: [
19
+ "bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/20 dark:text-amber-400 dark:border-amber-800/30",
20
+ "rateLimited",
21
+ ],
22
+ refreshing: [
23
+ "bg-blue-100 text-blue-600 border-blue-200 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800/30",
24
+ "refreshing",
25
+ ],
26
+ disabled: [
27
+ "bg-slate-100 text-slate-500 border-slate-200 dark:bg-slate-800/30 dark:text-slate-400 dark:border-slate-700/30",
28
+ "disabled",
29
+ ],
30
+ };
31
+
32
+ interface AccountTableProps {
33
+ accounts: AssignmentAccount[];
34
+ proxies: ProxyEntry[];
35
+ selectedIds: Set<string>;
36
+ onSelectionChange: (ids: Set<string>) => void;
37
+ onSingleProxyChange: (accountId: string, proxyId: string) => void;
38
+ filterGroup: string | null;
39
+ statusFilter: string;
40
+ onStatusFilterChange: (status: string) => void;
41
+ }
42
+
43
+ export function AccountTable({
44
+ accounts,
45
+ proxies,
46
+ selectedIds,
47
+ onSelectionChange,
48
+ onSingleProxyChange,
49
+ filterGroup,
50
+ statusFilter,
51
+ onStatusFilterChange,
52
+ }: AccountTableProps) {
53
+ const t = useT();
54
+ const [search, setSearch] = useState("");
55
+ const [page, setPage] = useState(0);
56
+ const lastClickedIndex = useRef<number | null>(null);
57
+
58
+ // Filter accounts
59
+ let filtered = accounts;
60
+
61
+ // By group
62
+ if (filterGroup) {
63
+ filtered = filtered.filter((a) => a.proxyId === filterGroup);
64
+ }
65
+
66
+ // By status
67
+ if (statusFilter && statusFilter !== "all") {
68
+ filtered = filtered.filter((a) => a.status === statusFilter);
69
+ }
70
+
71
+ // By search
72
+ if (search) {
73
+ const lower = search.toLowerCase();
74
+ filtered = filtered.filter((a) => a.email.toLowerCase().includes(lower));
75
+ }
76
+
77
+ const totalCount = filtered.length;
78
+ const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
79
+ const safePage = Math.min(page, totalPages - 1);
80
+ const pageAccounts = filtered.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE);
81
+ const pageAccountsRef = useRef(pageAccounts);
82
+ pageAccountsRef.current = pageAccounts;
83
+
84
+ // Reset page when filters change
85
+ const handleSearch = useCallback((value: string) => {
86
+ setSearch(value);
87
+ setPage(0);
88
+ }, []);
89
+
90
+ const handleStatusFilter = useCallback(
91
+ (value: string) => {
92
+ onStatusFilterChange(value);
93
+ setPage(0);
94
+ },
95
+ [onStatusFilterChange],
96
+ );
97
+
98
+ // Select/deselect
99
+ const toggleSelect = useCallback(
100
+ (id: string, index: number, shiftKey: boolean) => {
101
+ const newSet = new Set(selectedIds);
102
+ const currentPage = pageAccountsRef.current;
103
+
104
+ if (shiftKey && lastClickedIndex.current !== null) {
105
+ // Range select
106
+ const start = Math.min(lastClickedIndex.current, index);
107
+ const end = Math.max(lastClickedIndex.current, index);
108
+ for (let i = start; i <= end; i++) {
109
+ if (currentPage[i]) {
110
+ newSet.add(currentPage[i].id);
111
+ }
112
+ }
113
+ } else {
114
+ if (newSet.has(id)) {
115
+ newSet.delete(id);
116
+ } else {
117
+ newSet.add(id);
118
+ }
119
+ }
120
+
121
+ lastClickedIndex.current = index;
122
+ onSelectionChange(newSet);
123
+ },
124
+ [selectedIds, onSelectionChange],
125
+ );
126
+
127
+ const toggleSelectAll = useCallback(() => {
128
+ const currentPage = pageAccountsRef.current;
129
+ const pageIds = currentPage.map((a) => a.id);
130
+ const allSelected = pageIds.every((id) => selectedIds.has(id));
131
+ const newSet = new Set(selectedIds);
132
+ if (allSelected) {
133
+ for (const id of pageIds) newSet.delete(id);
134
+ } else {
135
+ for (const id of pageIds) newSet.add(id);
136
+ }
137
+ onSelectionChange(newSet);
138
+ }, [selectedIds, onSelectionChange]);
139
+
140
+ const allPageSelected = pageAccounts.length > 0 && pageAccounts.every((a) => selectedIds.has(a.id));
141
+
142
+ return (
143
+ <div class="flex flex-col gap-3">
144
+ {/* Filters */}
145
+ <div class="flex items-center gap-2">
146
+ <input
147
+ type="text"
148
+ placeholder={t("searchAccount")}
149
+ value={search}
150
+ onInput={(e) => handleSearch((e.target as HTMLInputElement).value)}
151
+ class="flex-1 px-3 py-2 text-sm border border-gray-200 dark:border-border-dark rounded-lg bg-white dark:bg-bg-dark text-slate-700 dark:text-text-main focus:outline-none focus:ring-1 focus:ring-primary"
152
+ />
153
+ <select
154
+ value={statusFilter}
155
+ onChange={(e) => handleStatusFilter((e.target as HTMLSelectElement).value)}
156
+ class="px-3 py-2 text-sm border border-gray-200 dark:border-border-dark rounded-lg bg-white dark:bg-bg-dark text-slate-700 dark:text-text-main focus:outline-none focus:ring-1 focus:ring-primary cursor-pointer"
157
+ >
158
+ <option value="all">{t("allStatuses")}</option>
159
+ <option value="active">{t("active")}</option>
160
+ <option value="expired">{t("expired")}</option>
161
+ <option value="rate_limited">{t("rateLimited")}</option>
162
+ <option value="disabled">{t("disabled")}</option>
163
+ </select>
164
+ </div>
165
+
166
+ {/* Table */}
167
+ <div class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl overflow-hidden">
168
+ {/* Table Header */}
169
+ <div class="flex items-center gap-3 px-4 py-2.5 border-b border-gray-100 dark:border-border-dark bg-slate-50 dark:bg-bg-dark text-xs text-slate-500 dark:text-text-dim font-medium">
170
+ <label class="flex items-center cursor-pointer shrink-0" title={t("selectAll")}>
171
+ <input
172
+ type="checkbox"
173
+ checked={allPageSelected}
174
+ onChange={toggleSelectAll}
175
+ class="rounded border-gray-300 dark:border-border-dark text-primary focus:ring-primary cursor-pointer"
176
+ />
177
+ </label>
178
+ <span class="flex-1 min-w-0">Email</span>
179
+ <span class="w-20 text-center hidden sm:block">{t("statusFilter")}</span>
180
+ <span class="w-40 text-center hidden md:block">{t("proxyAssignment")}</span>
181
+ </div>
182
+
183
+ {/* Rows */}
184
+ {pageAccounts.length === 0 ? (
185
+ <div class="px-4 py-8 text-center text-sm text-slate-400 dark:text-text-dim">
186
+ {t("noAccounts")}
187
+ </div>
188
+ ) : (
189
+ pageAccounts.map((acct, idx) => {
190
+ const isSelected = selectedIds.has(acct.id);
191
+ const [statusCls, statusKey] = statusStyles[acct.status] || statusStyles.disabled;
192
+
193
+ return (
194
+ <div
195
+ key={acct.id}
196
+ class={`flex items-center gap-3 px-4 py-2.5 border-b border-gray-50 dark:border-border-dark/50 transition-colors cursor-pointer ${
197
+ isSelected
198
+ ? "bg-primary/5 dark:bg-primary/10"
199
+ : "hover:bg-slate-50 dark:hover:bg-border-dark/30"
200
+ }`}
201
+ onClick={(e) => {
202
+ if ((e.target as HTMLElement).tagName === "SELECT") return;
203
+ toggleSelect(acct.id, idx, e.shiftKey);
204
+ }}
205
+ >
206
+ <label class="flex items-center shrink-0" onClick={(e) => e.stopPropagation()}>
207
+ <input
208
+ type="checkbox"
209
+ checked={isSelected}
210
+ onChange={() => toggleSelect(acct.id, idx, false)}
211
+ class="rounded border-gray-300 dark:border-border-dark text-primary focus:ring-primary cursor-pointer"
212
+ />
213
+ </label>
214
+ <span class="flex-1 min-w-0 text-sm font-medium truncate text-slate-700 dark:text-text-main">
215
+ {acct.email}
216
+ </span>
217
+ <span class="w-20 hidden sm:flex justify-center">
218
+ <span class={`px-2 py-0.5 rounded-full text-[0.65rem] font-medium border ${statusCls}`}>
219
+ {t(statusKey)}
220
+ </span>
221
+ </span>
222
+ <span class="w-40 hidden md:block" onClick={(e) => e.stopPropagation()}>
223
+ <select
224
+ value={acct.proxyId || "global"}
225
+ onChange={(e) => onSingleProxyChange(acct.id, (e.target as HTMLSelectElement).value)}
226
+ 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"
227
+ >
228
+ <option value="global">{t("globalDefault")}</option>
229
+ <option value="direct">{t("directNoProxy")}</option>
230
+ <option value="auto">{t("autoRoundRobin")}</option>
231
+ {proxies.map((p) => (
232
+ <option key={p.id} value={p.id}>
233
+ {p.name}
234
+ {p.health?.exitIp ? ` (${p.health.exitIp})` : ""}
235
+ </option>
236
+ ))}
237
+ </select>
238
+ </span>
239
+ </div>
240
+ );
241
+ })
242
+ )}
243
+ </div>
244
+
245
+ {/* Pagination */}
246
+ {totalPages > 1 && (
247
+ <div class="flex items-center justify-between text-xs text-slate-500 dark:text-text-dim">
248
+ <span>
249
+ {t("totalItems")} {totalCount}
250
+ </span>
251
+ <div class="flex items-center gap-2">
252
+ <button
253
+ onClick={() => setPage(Math.max(0, safePage - 1))}
254
+ disabled={safePage === 0}
255
+ class="px-2.5 py-1 rounded-md border border-gray-200 dark:border-border-dark hover:bg-slate-50 dark:hover:bg-border-dark disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
256
+ >
257
+ {t("prevPage")}
258
+ </button>
259
+ <span class="font-medium">
260
+ {safePage + 1} / {totalPages}
261
+ </span>
262
+ <button
263
+ onClick={() => setPage(Math.min(totalPages - 1, safePage + 1))}
264
+ disabled={safePage >= totalPages - 1}
265
+ class="px-2.5 py-1 rounded-md border border-gray-200 dark:border-border-dark hover:bg-slate-50 dark:hover:bg-border-dark disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
266
+ >
267
+ {t("nextPage")}
268
+ </button>
269
+ </div>
270
+ </div>
271
+ )}
272
+
273
+ {/* Hint */}
274
+ <p class="text-[0.65rem] text-slate-400 dark:text-text-dim text-center">
275
+ {t("shiftSelectHint")}
276
+ </p>
277
+ </div>
278
+ );
279
+ }
web/src/components/BulkActions.tsx ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useCallback } from "preact/hooks";
2
+ import { useT } from "../../../shared/i18n/context";
3
+ import type { ProxyEntry } from "../../../shared/types";
4
+
5
+ interface BulkActionsProps {
6
+ selectedCount: number;
7
+ selectedIds: Set<string>;
8
+ proxies: ProxyEntry[];
9
+ onBulkAssign: (proxyId: string) => void;
10
+ onEvenDistribute: () => void;
11
+ onOpenRuleAssign: () => void;
12
+ }
13
+
14
+ export function BulkActions({
15
+ selectedCount,
16
+ selectedIds,
17
+ proxies,
18
+ onBulkAssign,
19
+ onEvenDistribute,
20
+ onOpenRuleAssign,
21
+ }: BulkActionsProps) {
22
+ const t = useT();
23
+ const [targetProxy, setTargetProxy] = useState("global");
24
+
25
+ const handleApply = useCallback(() => {
26
+ if (selectedIds.size === 0) return;
27
+ onBulkAssign(targetProxy);
28
+ }, [selectedIds, targetProxy, onBulkAssign]);
29
+
30
+ if (selectedCount === 0) return null;
31
+
32
+ return (
33
+ <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">
34
+ <div class="flex items-center gap-3 flex-wrap">
35
+ {/* Selection count */}
36
+ <span class="text-sm font-medium text-slate-700 dark:text-text-main shrink-0">
37
+ {selectedCount} {t("accountsCount")} {t("selected")}
38
+ </span>
39
+
40
+ <div class="h-4 w-px bg-gray-200 dark:bg-border-dark hidden sm:block" />
41
+
42
+ {/* Batch assign */}
43
+ <div class="flex items-center gap-2">
44
+ <span class="text-xs text-slate-500 dark:text-text-dim shrink-0">{t("batchAssignTo")}:</span>
45
+ <select
46
+ value={targetProxy}
47
+ onChange={(e) => setTargetProxy((e.target as HTMLSelectElement).value)}
48
+ class="text-xs px-2 py-1.5 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"
49
+ >
50
+ <option value="global">{t("globalDefault")}</option>
51
+ <option value="direct">{t("directNoProxy")}</option>
52
+ <option value="auto">{t("autoRoundRobin")}</option>
53
+ {proxies.map((p) => (
54
+ <option key={p.id} value={p.id}>
55
+ {p.name}
56
+ </option>
57
+ ))}
58
+ </select>
59
+ <button
60
+ onClick={handleApply}
61
+ class="px-3 py-1.5 text-xs font-medium rounded-lg bg-primary text-white hover:bg-primary-hover transition-colors"
62
+ >
63
+ {t("applyBtn")}
64
+ </button>
65
+ </div>
66
+
67
+ <div class="h-4 w-px bg-gray-200 dark:bg-border-dark hidden sm:block" />
68
+
69
+ {/* Even distribute */}
70
+ <button
71
+ onClick={onEvenDistribute}
72
+ disabled={proxies.length === 0}
73
+ class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-200 dark:border-border-dark hover:bg-slate-50 dark:hover:bg-border-dark transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
74
+ >
75
+ {t("evenDistribute")}
76
+ </button>
77
+
78
+ {/* Rule assign */}
79
+ <button
80
+ onClick={onOpenRuleAssign}
81
+ class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-200 dark:border-border-dark hover:bg-slate-50 dark:hover:bg-border-dark transition-colors"
82
+ >
83
+ {t("ruleAssign")}
84
+ </button>
85
+ </div>
86
+ </div>
87
+ );
88
+ }
web/src/components/Header.tsx CHANGED
@@ -20,9 +20,10 @@ interface HeaderProps {
20
  updateStatusMsg: string | null;
21
  updateStatusColor: string;
22
  version: string | null;
 
23
  }
24
 
25
- export function Header({ onAddAccount, onCheckUpdate, checking, updateStatusMsg, updateStatusColor, version }: HeaderProps) {
26
  const { lang, toggleLang, t } = useI18n();
27
  const { isDark, toggle: toggleTheme } = useTheme();
28
 
@@ -32,12 +33,26 @@ export function Header({ onAddAccount, onCheckUpdate, checking, updateStatusMsg,
32
  <div class="flex w-full max-w-[960px] items-center justify-between">
33
  {/* Logo & Title */}
34
  <div class="flex items-center gap-3">
35
- <div class="flex items-center justify-center size-8 rounded-full bg-primary/10 text-primary border border-primary/20">
36
- <svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
37
- <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
38
- </svg>
39
- </div>
40
- <h1 class="text-[0.9rem] font-bold tracking-tight">Codex Proxy</h1>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  </div>
42
  {/* Actions */}
43
  <div class="flex items-center gap-3">
@@ -104,19 +119,32 @@ export function Header({ onAddAccount, onCheckUpdate, checking, updateStatusMsg,
104
  >
105
  {isDark ? SVG_SUN : SVG_MOON}
106
  </button>
107
- {/* Add Account */}
108
- <button
109
- onClick={onAddAccount}
110
- class="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white text-xs font-semibold rounded-lg transition-colors shadow-sm active:scale-95"
111
- >
112
- <svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
113
- <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
114
- </svg>
115
- <span class="inline-grid">
116
- <span class="invisible col-start-1 row-start-1">Add Account</span>
117
- <span class="col-start-1 row-start-1">{t("addAccount")}</span>
118
- </span>
119
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  </div>
121
  </div>
122
  </div>
 
20
  updateStatusMsg: string | null;
21
  updateStatusColor: string;
22
  version: string | null;
23
+ isProxySettings?: boolean;
24
  }
25
 
26
+ export function Header({ onAddAccount, onCheckUpdate, checking, updateStatusMsg, updateStatusColor, version, isProxySettings }: HeaderProps) {
27
  const { lang, toggleLang, t } = useI18n();
28
  const { isDark, toggle: toggleTheme } = useTheme();
29
 
 
33
  <div class="flex w-full max-w-[960px] items-center justify-between">
34
  {/* Logo & Title */}
35
  <div class="flex items-center gap-3">
36
+ {isProxySettings ? (
37
+ <a
38
+ href="#"
39
+ class="flex items-center gap-1.5 text-sm text-slate-500 dark:text-text-dim hover:text-primary transition-colors"
40
+ >
41
+ <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
42
+ <path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
43
+ </svg>
44
+ <span class="font-medium">{t("backToDashboard")}</span>
45
+ </a>
46
+ ) : (
47
+ <>
48
+ <div class="flex items-center justify-center size-8 rounded-full bg-primary/10 text-primary border border-primary/20">
49
+ <svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
50
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
51
+ </svg>
52
+ </div>
53
+ <h1 class="text-[0.9rem] font-bold tracking-tight">Codex Proxy</h1>
54
+ </>
55
+ )}
56
  </div>
57
  {/* Actions */}
58
  <div class="flex items-center gap-3">
 
119
  >
120
  {isDark ? SVG_SUN : SVG_MOON}
121
  </button>
122
+ {/* Proxy Settings / Add Account */}
123
+ {isProxySettings ? null : (
124
+ <>
125
+ <a
126
+ href="#/proxy-settings"
127
+ class="hidden sm:flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-gray-200 dark:border-border-dark text-slate-600 dark:text-text-dim hover:bg-slate-50 dark:hover:bg-border-dark transition-colors"
128
+ >
129
+ <svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
130
+ <path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 11-3 0m3 0a1.5 1.5 0 10-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-9.75 0h9.75" />
131
+ </svg>
132
+ <span class="text-xs font-semibold">{t("proxySettings")}</span>
133
+ </a>
134
+ <button
135
+ onClick={onAddAccount}
136
+ class="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white text-xs font-semibold rounded-lg transition-colors shadow-sm active:scale-95"
137
+ >
138
+ <svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
139
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
140
+ </svg>
141
+ <span class="inline-grid">
142
+ <span class="invisible col-start-1 row-start-1">Add Account</span>
143
+ <span class="col-start-1 row-start-1">{t("addAccount")}</span>
144
+ </span>
145
+ </button>
146
+ </>
147
+ )}
148
  </div>
149
  </div>
150
  </div>
web/src/components/ImportExport.tsx ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useCallback, useRef } from "preact/hooks";
2
+ import { useT } from "../../../shared/i18n/context";
3
+ import type { ImportDiff } from "../../../shared/hooks/use-proxy-assignments";
4
+
5
+ interface ImportExportProps {
6
+ onExport: () => Promise<Array<{ email: string; proxyId: string }>>;
7
+ onImportPreview: (data: Array<{ email: string; proxyId: string }>) => Promise<ImportDiff | null>;
8
+ onApplyImport: (assignments: Array<{ accountId: string; proxyId: string }>) => Promise<void>;
9
+ }
10
+
11
+ export function ImportExport({ onExport, onImportPreview, onApplyImport }: ImportExportProps) {
12
+ const t = useT();
13
+ const [showImport, setShowImport] = useState(false);
14
+ const [diff, setDiff] = useState<ImportDiff | null>(null);
15
+ const [importing, setImporting] = useState(false);
16
+ const fileRef = useRef<HTMLInputElement>(null);
17
+
18
+ const handleExport = useCallback(async () => {
19
+ const data = await onExport();
20
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
21
+ const url = URL.createObjectURL(blob);
22
+ const a = document.createElement("a");
23
+ a.href = url;
24
+ a.download = "proxy-assignments.json";
25
+ a.click();
26
+ URL.revokeObjectURL(url);
27
+ }, [onExport]);
28
+
29
+ const handleFileSelect = useCallback(async () => {
30
+ const file = fileRef.current?.files?.[0];
31
+ if (!file) return;
32
+
33
+ try {
34
+ const text = await file.text();
35
+ const parsed = JSON.parse(text) as Array<{ email: string; proxyId: string }>;
36
+ if (!Array.isArray(parsed)) {
37
+ alert("Invalid format: expected array of { email, proxyId }");
38
+ return;
39
+ }
40
+ setImporting(true);
41
+ const result = await onImportPreview(parsed);
42
+ setDiff(result);
43
+ setImporting(false);
44
+ } catch {
45
+ alert("Failed to parse JSON file");
46
+ }
47
+ }, [onImportPreview]);
48
+
49
+ const handleApply = useCallback(async () => {
50
+ if (!diff || diff.changes.length === 0) return;
51
+ setImporting(true);
52
+ await onApplyImport(
53
+ diff.changes.map((c) => ({ accountId: c.accountId, proxyId: c.to })),
54
+ );
55
+ setImporting(false);
56
+ setDiff(null);
57
+ setShowImport(false);
58
+ }, [diff, onApplyImport]);
59
+
60
+ const handleClose = useCallback(() => {
61
+ setShowImport(false);
62
+ setDiff(null);
63
+ if (fileRef.current) fileRef.current.value = "";
64
+ }, []);
65
+
66
+ return (
67
+ <>
68
+ {/* Buttons */}
69
+ <div class="flex items-center gap-2">
70
+ <button
71
+ onClick={() => setShowImport(true)}
72
+ class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-200 dark:border-border-dark hover:bg-slate-50 dark:hover:bg-border-dark transition-colors"
73
+ >
74
+ {t("importBtn")}
75
+ </button>
76
+ <button
77
+ onClick={handleExport}
78
+ class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-200 dark:border-border-dark hover:bg-slate-50 dark:hover:bg-border-dark transition-colors"
79
+ >
80
+ {t("exportBtn")}
81
+ </button>
82
+ </div>
83
+
84
+ {/* Import Modal */}
85
+ {showImport && (
86
+ <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={handleClose}>
87
+ <div
88
+ class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-6 w-full max-w-lg mx-4 shadow-xl"
89
+ onClick={(e) => e.stopPropagation()}
90
+ >
91
+ <h3 class="text-lg font-semibold mb-4">{t("importPreview")}</h3>
92
+
93
+ {!diff ? (
94
+ <div class="space-y-4">
95
+ <p class="text-sm text-slate-500 dark:text-text-dim">{t("importFile")}</p>
96
+ <input
97
+ ref={fileRef}
98
+ type="file"
99
+ accept=".json"
100
+ onChange={handleFileSelect}
101
+ class="block w-full text-sm text-slate-500 dark:text-text-dim file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-primary/10 file:text-primary hover:file:bg-primary/20 cursor-pointer"
102
+ />
103
+ {importing && (
104
+ <p class="text-sm text-slate-500 dark:text-text-dim animate-pulse">Loading...</p>
105
+ )}
106
+ </div>
107
+ ) : (
108
+ <div class="space-y-4">
109
+ {diff.changes.length === 0 ? (
110
+ <p class="text-sm text-slate-500 dark:text-text-dim">{t("noChanges")}</p>
111
+ ) : (
112
+ <>
113
+ <p class="text-sm font-medium">
114
+ {diff.changes.length} {t("changesCount")}
115
+ {diff.unchanged > 0 && (
116
+ <span class="text-slate-400 dark:text-text-dim ml-2">
117
+ ({diff.unchanged} unchanged)
118
+ </span>
119
+ )}
120
+ </p>
121
+ <div class="max-h-60 overflow-y-auto border border-gray-100 dark:border-border-dark rounded-lg">
122
+ {diff.changes.map((c) => (
123
+ <div
124
+ key={c.accountId}
125
+ class="flex items-center justify-between px-3 py-2 text-xs border-b border-gray-50 dark:border-border-dark/50"
126
+ >
127
+ <span class="font-medium truncate flex-1 text-slate-700 dark:text-text-main">
128
+ {c.email}
129
+ </span>
130
+ <span class="flex items-center gap-1.5 shrink-0 ml-2">
131
+ <span class="text-slate-400 dark:text-text-dim">{c.from}</span>
132
+ <svg class="size-3 text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
133
+ <path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
134
+ </svg>
135
+ <span class="text-primary font-medium">{c.to}</span>
136
+ </span>
137
+ </div>
138
+ ))}
139
+ </div>
140
+ </>
141
+ )}
142
+ </div>
143
+ )}
144
+
145
+ {/* Actions */}
146
+ <div class="flex items-center justify-end gap-2 mt-4">
147
+ <button
148
+ onClick={handleClose}
149
+ class="px-4 py-2 text-sm rounded-lg border border-gray-200 dark:border-border-dark hover:bg-slate-50 dark:hover:bg-border-dark transition-colors"
150
+ >
151
+ {t("cancelBtn")}
152
+ </button>
153
+ {diff && diff.changes.length > 0 && (
154
+ <button
155
+ onClick={handleApply}
156
+ disabled={importing}
157
+ class="px-4 py-2 text-sm font-medium rounded-lg bg-primary text-white hover:bg-primary-hover transition-colors disabled:opacity-50"
158
+ >
159
+ {importing ? "..." : t("confirmApply")}
160
+ </button>
161
+ )}
162
+ </div>
163
+ </div>
164
+ </div>
165
+ )}
166
+ </>
167
+ );
168
+ }
web/src/components/ProxyGroupList.tsx ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "preact/hooks";
2
+ import { useT } from "../../../shared/i18n/context";
3
+ import type { AssignmentAccount } from "../../../shared/hooks/use-proxy-assignments";
4
+ import type { ProxyEntry } from "../../../shared/types";
5
+
6
+ interface ProxyGroup {
7
+ id: string;
8
+ label: string;
9
+ count: number;
10
+ }
11
+
12
+ interface ProxyGroupListProps {
13
+ accounts: AssignmentAccount[];
14
+ proxies: ProxyEntry[];
15
+ selectedGroup: string | null;
16
+ onSelectGroup: (groupId: string | null) => void;
17
+ }
18
+
19
+ export function ProxyGroupList({ accounts, proxies, selectedGroup, onSelectGroup }: ProxyGroupListProps) {
20
+ const t = useT();
21
+ const [search, setSearch] = useState("");
22
+
23
+ // Build groups with counts
24
+ const groups: ProxyGroup[] = [];
25
+
26
+ // "All"
27
+ groups.push({ id: "__all__", label: t("allAccounts"), count: accounts.length });
28
+
29
+ // Count by assignment
30
+ const countMap = new Map<string, number>();
31
+ for (const acct of accounts) {
32
+ const key = acct.proxyId || "global";
33
+ countMap.set(key, (countMap.get(key) || 0) + 1);
34
+ }
35
+
36
+ // Special groups
37
+ groups.push({ id: "global", label: t("globalDefault"), count: countMap.get("global") || 0 });
38
+
39
+ // Proxy entries
40
+ for (const p of proxies) {
41
+ groups.push({ id: p.id, label: p.name, count: countMap.get(p.id) || 0 });
42
+ }
43
+
44
+ groups.push({ id: "direct", label: t("directNoProxy"), count: countMap.get("direct") || 0 });
45
+ groups.push({ id: "auto", label: t("autoRoundRobin"), count: countMap.get("auto") || 0 });
46
+
47
+ // "Unassigned" — accounts not in any explicit assignment (they default to "global")
48
+ // Since getAssignment defaults to "global", all accounts have a proxyId.
49
+ // "Unassigned" is not really a separate bucket in this model — skip or show 0.
50
+
51
+ const lowerSearch = search.toLowerCase();
52
+ const filtered = search
53
+ ? groups.filter((g) => g.label.toLowerCase().includes(lowerSearch))
54
+ : groups;
55
+
56
+ const active = selectedGroup ?? "__all__";
57
+
58
+ return (
59
+ <div class="flex flex-col gap-1">
60
+ {/* Search */}
61
+ <input
62
+ type="text"
63
+ placeholder={t("searchProxy")}
64
+ value={search}
65
+ onInput={(e) => setSearch((e.target as HTMLInputElement).value)}
66
+ class="px-3 py-2 text-sm border border-gray-200 dark:border-border-dark rounded-lg bg-white dark:bg-bg-dark text-slate-700 dark:text-text-main focus:outline-none focus:ring-1 focus:ring-primary mb-2"
67
+ />
68
+
69
+ {filtered.map((g) => {
70
+ const isActive = active === g.id;
71
+ return (
72
+ <button
73
+ key={g.id}
74
+ onClick={() => onSelectGroup(g.id === "__all__" ? null : g.id)}
75
+ class={`flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-colors text-left ${
76
+ isActive
77
+ ? "bg-primary/10 text-primary font-medium border border-primary/20"
78
+ : "hover:bg-slate-50 dark:hover:bg-border-dark text-slate-700 dark:text-text-main"
79
+ }`}
80
+ >
81
+ <span class="truncate">{g.label}</span>
82
+ <span
83
+ class={`ml-2 px-2 py-0.5 rounded-full text-xs font-medium shrink-0 ${
84
+ isActive
85
+ ? "bg-primary/20 text-primary"
86
+ : "bg-slate-100 dark:bg-border-dark text-slate-500 dark:text-text-dim"
87
+ }`}
88
+ >
89
+ {g.count}
90
+ </span>
91
+ </button>
92
+ );
93
+ })}
94
+ </div>
95
+ );
96
+ }
web/src/components/RuleAssign.tsx ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useCallback } from "preact/hooks";
2
+ import { useT } from "../../../shared/i18n/context";
3
+ import type { ProxyEntry } from "../../../shared/types";
4
+
5
+ interface RuleAssignProps {
6
+ proxies: ProxyEntry[];
7
+ selectedCount: number;
8
+ onAssign: (rule: string, targetProxyIds: string[]) => void;
9
+ onClose: () => void;
10
+ }
11
+
12
+ export function RuleAssign({ proxies, selectedCount, onAssign, onClose }: RuleAssignProps) {
13
+ const t = useT();
14
+ const [rule, setRule] = useState("round-robin");
15
+ const [targetIds, setTargetIds] = useState<Set<string>>(
16
+ () => new Set(proxies.filter((p) => p.status === "active").map((p) => p.id)),
17
+ );
18
+
19
+ const toggleTarget = useCallback((id: string) => {
20
+ setTargetIds((prev) => {
21
+ const next = new Set(prev);
22
+ if (next.has(id)) {
23
+ next.delete(id);
24
+ } else {
25
+ next.add(id);
26
+ }
27
+ return next;
28
+ });
29
+ }, []);
30
+
31
+ const handleApply = useCallback(() => {
32
+ if (targetIds.size === 0) return;
33
+ onAssign(rule, Array.from(targetIds));
34
+ }, [rule, targetIds, onAssign]);
35
+
36
+ // Also include special values
37
+ const allTargets = [
38
+ { id: "global", label: t("globalDefault"), status: "active" as const },
39
+ { id: "direct", label: t("directNoProxy"), status: "active" as const },
40
+ ...proxies.map((p) => ({ id: p.id, label: p.name, status: p.status })),
41
+ ];
42
+
43
+ return (
44
+ <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
45
+ <div
46
+ class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-6 w-full max-w-md mx-4 shadow-xl"
47
+ onClick={(e) => e.stopPropagation()}
48
+ >
49
+ <h3 class="text-lg font-semibold mb-1">{t("assignRuleTitle")}</h3>
50
+ <p class="text-xs text-slate-500 dark:text-text-dim mb-4">
51
+ {selectedCount} {t("accountsCount")} {t("selected")}
52
+ </p>
53
+
54
+ {/* Rule selection */}
55
+ <div class="mb-4">
56
+ <label class="text-xs font-medium text-slate-600 dark:text-text-dim block mb-1.5">
57
+ {t("assignRuleTitle")}
58
+ </label>
59
+ <select
60
+ value={rule}
61
+ onChange={(e) => setRule((e.target as HTMLSelectElement).value)}
62
+ class="w-full px-3 py-2 text-sm border border-gray-200 dark:border-border-dark rounded-lg bg-white dark:bg-bg-dark text-slate-700 dark:text-text-main focus:outline-none focus:ring-1 focus:ring-primary cursor-pointer"
63
+ >
64
+ <option value="round-robin">{t("roundRobinRule")}</option>
65
+ </select>
66
+ </div>
67
+
68
+ {/* Target proxies */}
69
+ <div class="mb-4">
70
+ <label class="text-xs font-medium text-slate-600 dark:text-text-dim block mb-1.5">
71
+ {t("ruleTarget")}
72
+ </label>
73
+ <div class="max-h-48 overflow-y-auto border border-gray-100 dark:border-border-dark rounded-lg">
74
+ {allTargets.map((target) => (
75
+ <label
76
+ key={target.id}
77
+ class="flex items-center gap-2 px-3 py-2 border-b border-gray-50 dark:border-border-dark/50 hover:bg-slate-50 dark:hover:bg-border-dark/30 cursor-pointer"
78
+ >
79
+ <input
80
+ type="checkbox"
81
+ checked={targetIds.has(target.id)}
82
+ onChange={() => toggleTarget(target.id)}
83
+ class="rounded border-gray-300 dark:border-border-dark text-primary focus:ring-primary"
84
+ />
85
+ <span class="text-sm text-slate-700 dark:text-text-main">{target.label}</span>
86
+ {target.status !== "active" && (
87
+ <span class="text-[0.6rem] text-slate-400 dark:text-text-dim">({target.status})</span>
88
+ )}
89
+ </label>
90
+ ))}
91
+ </div>
92
+ </div>
93
+
94
+ {/* Actions */}
95
+ <div class="flex items-center justify-end gap-2">
96
+ <button
97
+ onClick={onClose}
98
+ class="px-4 py-2 text-sm rounded-lg border border-gray-200 dark:border-border-dark hover:bg-slate-50 dark:hover:bg-border-dark transition-colors"
99
+ >
100
+ {t("cancelBtn")}
101
+ </button>
102
+ <button
103
+ onClick={handleApply}
104
+ disabled={targetIds.size === 0}
105
+ class="px-4 py-2 text-sm font-medium rounded-lg bg-primary text-white hover:bg-primary-hover transition-colors disabled:opacity-50"
106
+ >
107
+ {t("applyBtn")}
108
+ </button>
109
+ </div>
110
+ </div>
111
+ </div>
112
+ );
113
+ }
web/src/pages/ProxySettings.tsx ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useCallback } from "preact/hooks";
2
+ import { useT } from "../../../shared/i18n/context";
3
+ import { useProxyAssignments } from "../../../shared/hooks/use-proxy-assignments";
4
+ import { ProxyGroupList } from "../components/ProxyGroupList";
5
+ import { AccountTable } from "../components/AccountTable";
6
+ import { BulkActions } from "../components/BulkActions";
7
+ import { ImportExport } from "../components/ImportExport";
8
+ import { RuleAssign } from "../components/RuleAssign";
9
+
10
+ export function ProxySettings() {
11
+ const t = useT();
12
+ const data = useProxyAssignments();
13
+ const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
14
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
15
+ const [statusFilter, setStatusFilter] = useState("all");
16
+ const [showRuleAssign, setShowRuleAssign] = useState(false);
17
+
18
+ // Single account proxy change (optimistic)
19
+ const handleSingleProxyChange = useCallback(
20
+ async (accountId: string, proxyId: string) => {
21
+ await data.assignBulk([{ accountId, proxyId }]);
22
+ },
23
+ [data.assignBulk],
24
+ );
25
+
26
+ // Bulk assign all selected to a single proxy
27
+ const handleBulkAssign = useCallback(
28
+ async (proxyId: string) => {
29
+ const assignments = Array.from(selectedIds).map((accountId) => ({ accountId, proxyId }));
30
+ await data.assignBulk(assignments);
31
+ setSelectedIds(new Set());
32
+ },
33
+ [selectedIds, data.assignBulk],
34
+ );
35
+
36
+ // Even distribute selected across all active proxies
37
+ const handleEvenDistribute = useCallback(async () => {
38
+ const activeProxies = data.proxies.filter((p) => p.status === "active");
39
+ if (activeProxies.length === 0) return;
40
+
41
+ const ids = Array.from(selectedIds);
42
+ await data.assignRule(ids, "round-robin", activeProxies.map((p) => p.id));
43
+ setSelectedIds(new Set());
44
+ }, [selectedIds, data.proxies, data.assignRule]);
45
+
46
+ // Rule-based assignment
47
+ const handleRuleAssign = useCallback(
48
+ async (rule: string, targetProxyIds: string[]) => {
49
+ const ids = Array.from(selectedIds);
50
+ await data.assignRule(ids, rule, targetProxyIds);
51
+ setSelectedIds(new Set());
52
+ setShowRuleAssign(false);
53
+ },
54
+ [selectedIds, data.assignRule],
55
+ );
56
+
57
+ if (data.loading) {
58
+ return (
59
+ <div class="flex-grow flex items-center justify-center">
60
+ <div class="text-sm text-slate-400 dark:text-text-dim animate-pulse">Loading...</div>
61
+ </div>
62
+ );
63
+ }
64
+
65
+ return (
66
+ <div class="flex flex-col flex-grow">
67
+ {/* Top bar */}
68
+ <div class="px-4 md:px-8 lg:px-40 py-4 border-b border-gray-200 dark:border-border-dark bg-white dark:bg-card-dark">
69
+ <div class="flex items-center justify-between max-w-[1200px] mx-auto">
70
+ <div>
71
+ <h2 class="text-lg font-bold">{t("proxySettings")}</h2>
72
+ <p class="text-xs text-slate-500 dark:text-text-dim mt-0.5">
73
+ {t("proxySettingsDesc")}
74
+ </p>
75
+ </div>
76
+ <ImportExport
77
+ onExport={data.exportAssignments}
78
+ onImportPreview={data.importPreview}
79
+ onApplyImport={data.applyImport}
80
+ />
81
+ </div>
82
+ </div>
83
+
84
+ {/* Main content */}
85
+ <div class="flex-grow px-4 md:px-8 lg:px-40 py-6">
86
+ <div class="flex gap-6 max-w-[1200px] mx-auto">
87
+ {/* Left sidebar — proxy groups */}
88
+ <div class="w-56 shrink-0 hidden lg:block">
89
+ <ProxyGroupList
90
+ accounts={data.accounts}
91
+ proxies={data.proxies}
92
+ selectedGroup={selectedGroup}
93
+ onSelectGroup={setSelectedGroup}
94
+ />
95
+ </div>
96
+
97
+ {/* Right panel — account table */}
98
+ <div class="flex-1 min-w-0">
99
+ {/* Mobile group filter */}
100
+ <div class="lg:hidden mb-3">
101
+ <select
102
+ value={selectedGroup ?? "__all__"}
103
+ onChange={(e) => {
104
+ const v = (e.target as HTMLSelectElement).value;
105
+ setSelectedGroup(v === "__all__" ? null : v);
106
+ }}
107
+ class="w-full px-3 py-2 text-sm border border-gray-200 dark:border-border-dark rounded-lg bg-white dark:bg-bg-dark text-slate-700 dark:text-text-main focus:outline-none focus:ring-1 focus:ring-primary cursor-pointer"
108
+ >
109
+ <option value="__all__">{t("allAccounts")} ({data.accounts.length})</option>
110
+ <option value="global">{t("globalDefault")}</option>
111
+ {data.proxies.map((p) => (
112
+ <option key={p.id} value={p.id}>{p.name}</option>
113
+ ))}
114
+ <option value="direct">{t("directNoProxy")}</option>
115
+ <option value="auto">{t("autoRoundRobin")}</option>
116
+ </select>
117
+ </div>
118
+
119
+ <AccountTable
120
+ accounts={data.accounts}
121
+ proxies={data.proxies}
122
+ selectedIds={selectedIds}
123
+ onSelectionChange={setSelectedIds}
124
+ onSingleProxyChange={handleSingleProxyChange}
125
+ filterGroup={selectedGroup}
126
+ statusFilter={statusFilter}
127
+ onStatusFilterChange={setStatusFilter}
128
+ />
129
+ </div>
130
+ </div>
131
+ </div>
132
+
133
+ {/* Bulk actions bar */}
134
+ <BulkActions
135
+ selectedCount={selectedIds.size}
136
+ selectedIds={selectedIds}
137
+ proxies={data.proxies}
138
+ onBulkAssign={handleBulkAssign}
139
+ onEvenDistribute={handleEvenDistribute}
140
+ onOpenRuleAssign={() => setShowRuleAssign(true)}
141
+ />
142
+
143
+ {/* Rule assign modal */}
144
+ {showRuleAssign && (
145
+ <RuleAssign
146
+ proxies={data.proxies}
147
+ selectedCount={selectedIds.size}
148
+ onAssign={handleRuleAssign}
149
+ onClose={() => setShowRuleAssign(false)}
150
+ />
151
+ )}
152
+ </div>
153
+ );
154
+ }