Spaces:
Paused
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 +8 -0
- shared/hooks/use-proxy-assignments.ts +131 -0
- shared/i18n/translations.ts +72 -0
- src/index.ts +1 -1
- src/proxy/proxy-pool.ts +7 -0
- src/routes/proxies.ts +169 -1
- web/src/App.tsx +35 -1
- web/src/components/AccountTable.tsx +279 -0
- web/src/components/BulkActions.tsx +88 -0
- web/src/components/Header.tsx +48 -20
- web/src/components/ImportExport.tsx +168 -0
- web/src/components/ProxyGroupList.tsx +96 -0
- web/src/components/RuleAssign.tsx +113 -0
- web/src/pages/ProxySettings.tsx +154 -0
|
@@ -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 轮转)、指定代理
|
|
@@ -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 |
+
}
|
|
@@ -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 |
|
|
@@ -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);
|
|
@@ -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();
|
|
@@ -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 |
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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 |
-
|
| 36 |
-
<
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|