diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..2b7bb72 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,76 @@ +{ + "permissions": { + "allow": [ + "mcp__serena__get_symbols_overview", + "mcp__serena__find_symbol", + "Bash(pnpm:*)", + "Bash(bun run:*)", + "mcp__serena__list_dir", + "Bash(npx --filter apps/admin tsr generate --config apps/admin/tsr.config.json 2>&1 | head -20)", + "Bash(ls apps/admin/tsr.config.json 2>/dev/null || echo \"no tsr config\"; ls apps/admin/vite.config.* 2>/dev/null; cat apps/admin/package.json | grep -E '\"\\(dev|build|generate\\)\"' | head -5)", + "Bash(npx tsc:*)", + "Bash(curl -s 'http://127.0.0.1:8080/v1/admin/user/family/list?page=1&size=10' \\\\\n -H 'Accept: application/json' \\\\\n -H 'Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJDdHhMb2dpblR5cGUiOiIiLCJTZXNzaW9uSWQiOiIwMTljZjAyYi05YTcwLTcyMDItYTlmZS1jNzE1NmZkYjIzYzYiLCJVc2VySWQiOjI1OCwiZXhwIjoxODA1MDkxOTE1LCJpYXQiOjE3NzM1NTU5MTUsImlkZW50aWZpZXIiOiIifQ.rnm_y9DOsjvFC2XecRQ8BNUkWZcfiGzXIh5Dgwh99lA' 2>&1)", + "Bash(find apps/admin/src -name \"*.json\" -path \"*locale*\" -o -name \"*.json\" -path \"*i18n*\" -o -name \"*.json\" -path \"*zh*\" -o -name \"*.json\" -path \"*lang*\" 2>/dev/null | head -30)", + "Bash(find apps/admin/src -name \"*.json\" 2>/dev/null | head -20; echo \"---\"; find apps/admin -name \"i18n*\" -o -name \"locale*\" 2>/dev/null | head -20; echo \"---\"; grep -r \"i18n\\\\|i18next\\\\|useTranslation\" apps/admin/src/main.tsx apps/admin/src/App.tsx 2>/dev/null | head -10)", + "Bash(git log:*)", + "Bash(for key:*)", + "Bash(node -e \"JSON.parse\\(require\\('fs'\\).readFileSync\\('apps/admin/public/assets/locales/zh-CN/user.json','utf8'\\)\\); console.log\\('zh-CN OK'\\)\" && node -e \"JSON.parse\\(require\\('fs'\\).readFileSync\\('apps/admin/public/assets/locales/en-US/user.json','utf8'\\)\\); console.log\\('en-US OK'\\)\" && node -e \"JSON.parse\\(require\\('fs'\\).readFileSync\\('apps/admin/public/assets/locales/zh-CN/system.json','utf8'\\)\\); console.log\\('zh-CN system OK'\\)\" && node -e \"JSON.parse\\(require\\('fs'\\).readFileSync\\('apps/admin/public/assets/locales/en-US/system.json','utf8'\\)\\); console.log\\('en-US system OK'\\)\" && node -e \"JSON.parse\\(require\\('fs'\\).readFileSync\\('apps/admin/public/assets/locales/zh-CN/menu.json','utf8'\\)\\); console.log\\('zh-CN menu OK'\\)\" && node -e \"JSON.parse\\(require\\('fs'\\).readFileSync\\('apps/admin/public/assets/locales/en-US/menu.json','utf8'\\)\\); console.log\\('en-US menu OK'\\)\")", + "mcp__sequential-thinking__sequentialthinking", + "Bash(find packages/ui/src/components -name \"alert*\" 2>/dev/null | head -5)", + "Bash(node -e \"JSON.parse\\(require\\('fs'\\).readFileSync\\('apps/admin/public/assets/locales/zh-CN/user.json','utf8'\\)\\); console.log\\('zh-CN OK'\\)\" && node -e \"JSON.parse\\(require\\('fs'\\).readFileSync\\('apps/admin/public/assets/locales/en-US/user.json','utf8'\\)\\); console.log\\('en-US OK'\\)\")", + "Bash(grep -rn \"device_no\\\\|DeviceNo\\\\|device_identifier\\\\|DeviceIdentifier\\\\|sha256\\\\|SHA256\" /Users/Apple/code_vpn/vpn/ppanel-server --include=\"*.go\" 2>/dev/null | head -30)", + "Bash(find /Users/Apple/code_vpn/vpn/ppanel-server -name \"*.sql\" -o -name \"*migration*\" -o -name \"*migrate*\" 2>/dev/null | head -20)", + "Bash(grep -rn \"FindOneDeviceByIdentifier\\\\|DeviceByIdentifier\" /Users/Apple/code_vpn/vpn/ppanel-server --include=\"*.go\" 2>/dev/null | head -20)", + "Bash(grep -rn \"device_no\\\\|DeviceNo\" /Users/Apple/code_vpn/vpn/ppanel-server --include=\"*.go\" 2>/dev/null | head -20)", + "Bash(grep -rn \"DeviceLoginRequest\\\\|device.*identifier\\\\|identifier.*device\" /Users/Apple/code_vpn/vpn/ppanel-server/apis --include=\"*.api\" 2>/dev/null | head -20)", + "Bash(grep -rn \"ShortCode\\\\|short_code\" /Users/Apple/code_vpn/vpn/ppanel-server/internal --include=\"*.go\" 2>/dev/null | head -20)", + "Bash(grep -n -i \"device\" /Users/Apple/code_vpn/vpn/ppanel-server/apis/auth/*.api 2>/dev/null; ls /Users/Apple/code_vpn/vpn/ppanel-server/apis/auth/)", + "Bash(grep -n \"GetCacheKeys\\\\|ClearDeviceCache\" /Users/Apple/code_vpn/vpn/ppanel-server/internal/model/user/*.go | head -10)", + "Bash(grep -rn \"identifier\\\\|Identifier\\\\|device_id\\\\|deviceId\" apps/admin/src/sections/user/ --include=\"*.tsx\" --include=\"*.ts\" 2>/dev/null | grep -v node_modules | grep -v \".d.ts\" | head -20)", + "Bash(grep -rn \"device\\\\|Device\\\\|identifier\\\\|Identifier\" apps/user/src/ --include=\"*.tsx\" --include=\"*.ts\" 2>/dev/null | grep -v node_modules | grep -v \".d.ts\" | grep -v \"//\\\\|languageDetector\\\\|DevicePixel\\\\|device-width\" | head -30)", + "Bash(grep -rn \"user/info\\\\|UserInfo\\\\|getUserInfo\\\\|GetUserInfo\" /Users/Apple/code_vpn/vpn/ppanel-server/apis/ --include=\"*.api\" 2>/dev/null | head -10)", + "Bash(grep -l \"SelectTrigger\\\\|SelectContent\\\\|SelectItem\" packages/ui/src/components/select.tsx 2>/dev/null; echo \"---\"; grep -rn \"from.*@workspace/ui/components/select\" apps/admin/src/ --include=\"*.tsx\" 2>/dev/null | head -3)", + "Bash(grep -ohP 't\\\\\\(\"\\([^\"]+\\)\"' apps/admin/src/sections/user/index.tsx | sed 's/t\\(\"//' | sed 's/\"$//' | sort -u | while read key; do if ! grep -q \"\\\\\"$key\\\\\"\" apps/admin/public/assets/locales/zh-CN/user.json 2>/dev/null; then echo \"MISSING: $key\"; fi; done)", + "Bash(grep -o 't\\(\"[^\"]*\"' apps/admin/src/sections/user/index.tsx | sed 's/t\\(\"//' | sed 's/\"$//' | sort -u | while read key; do if ! grep -q \"\\\\\"$key\\\\\"\" apps/admin/public/assets/locales/zh-CN/user.json 2>/dev/null; then echo \"MISSING: $key\"; fi; done)", + "Bash(grep -roh 't\\(\"[^\"]*\"' apps/admin/src/sections/user/ --include=\"*.tsx\" | sed 's/t\\(\"//' | sed 's/\"$//' | sort -u | while read key; do\n if ! grep -q \"\\\\\"$key\\\\\"\" apps/admin/public/assets/locales/zh-CN/user.json 2>/dev/null; then\n echo \"MISSING zh-CN user: $key\"\n fi\ndone)", + "Bash(grep -o 't\\(\"[^\"]*\"' apps/admin/src/sections/system/user-security/signature-form.tsx apps/admin/src/sections/system/user-security/subscribe-mode-form.tsx | sed 's/.*t\\(\"//' | sed 's/\"$//' | sort -u | while read key; do\n if ! grep -q \"\\\\\"$key\\\\\"\" apps/admin/public/assets/locales/zh-CN/system.json 2>/dev/null; then\n echo \"MISSING system zh-CN: $key\"\n fi\ndone)", + "Bash(grep -rn \"EnhancedInput\\\\|enhanced-input\" packages/ui/src/composed/enhanced-input.tsx 2>/dev/null | head -3; find packages/ui/src -name \"enhanced-input*\" 2>/dev/null)", + "Bash(curl -s 'http://127.0.0.1:8080/v1/admin/system/getSignatureConfig' -H 'Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJDdHhMb2dpblR5cGUiOiIiLCJTZXNzaW9uSWQiOiIwMTljZjAyYi05YTcwLTcyMDItYTlmZS1jNzE1NmZkYjIzYzYiLCJVc2VySWQiOjI1OCwiZXhwIjoxODA1MDkxOTE1LCJpYXQiOjE3NzM1NTU5MTUsImlkZW50aWZpZXIiOiIifQ.rnm_y9DOsjvFC2XecRQ8BNUkWZcfiGzXIh5Dgwh99lA' 2>&1 | head -5)", + "Bash(curl -s 'http://127.0.0.1:8080/v1/admin/system/signature_config' -H 'Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJDdHhMb2dpblR5cGUiOiIiLCJTZXNzaW9uSWQiOiIwMTljZjAyYi05YTcwLTcyMDItYTlmZS1jNzE1NmZkYjIzYzYiLCJVc2VySWQiOjI1OCwiZXhwIjoxODA1MDkxOTE1LCJpYXQiOjE3NzM1NTU5MTUsImlkZW50aWZpZXIiOiIifQ.rnm_y9DOsjvFC2XecRQ8BNUkWZcfiGzXIh5Dgwh99lA' 2>&1)", + "Bash(curl -s 'http://127.0.0.1:8080/v1/admin/system/subscribe_config' -H 'Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJDdHhMb2dpblR5cGUiOiIiLCJTZXNzaW9uSWQiOiIwMTljZjAyYi05YTcwLTcyMDItYTlmZS1jNzE1NmZkYjIzYzYiLCJVc2VySWQiOjI1OCwiZXhwIjoxODA1MDkxOTE1LCJpYXQiOjE3NzM1NTU5MTUsImlkZW50aWZpZXIiOiIifQ.rnm_y9DOsjvFC2XecRQ8BNUkWZcfiGzXIh5Dgwh99lA' 2>&1)", + "Bash(curl -s -X PUT 'http://127.0.0.1:8080/v1/admin/system/signature_config' \\\\\n -H 'Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJDdHhMb2dpblR5cGUiOiIiLCJTZXNzaW9uSWQiOiIwMTljZjAyYi05YTcwLTcyMDItYTlmZS1jNzE1NmZkYjIzYzYiLCJVc2VySWQiOjI1OCwiZXhwIjoxODA1MDkxOTE1LCJpYXQiOjE3NzM1NTU5MTUsImlkZW50aWZpZXIiOiIifQ.rnm_y9DOsjvFC2XecRQ8BNUkWZcfiGzXIh5Dgwh99lA' \\\\\n -H 'Content-Type: application/json' \\\\\n -d '{\"enable_signature\":true}' 2>&1)", + "Bash(AUTH='Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJDdHhMb2dpblR5cGUiOiIiLCJTZXNzaW9uSWQiOiIwMTljZjAyYi05YTcwLTcyMDItYTlmZS1jNzE1NmZkYjIzYzYiLCJVc2VySWQiOjI1OCwiZXhwIjoxODA1MDkxOTE1LCJpYXQiOjE3NzM1NTU5MTUsImlkZW50aWZpZXIiOiIifQ.rnm_y9DOsjvFC2XecRQ8BNUkWZcfiGzXIh5Dgwh99lA'\n\necho \"=== 1. GET before ===\"\ncurl -s 'http://127.0.0.1:8080/v1/admin/system/signature_config' -H \"$AUTH\"\n\necho \"\"\necho \"=== 2. PUT enable=true ===\"\ncurl -s -X PUT 'http://127.0.0.1:8080/v1/admin/system/signature_config' -H \"$AUTH\" -H 'Content-Type: application/json' -d '{\"enable_signature\":true}'\n\necho \"\"\necho \"=== 3. GET after ===\"\ncurl -s 'http://127.0.0.1:8080/v1/admin/system/signature_config' -H \"$AUTH\")", + "Bash(grep -rn \"signature_config\\\\|SignatureConfig\\\\|getSignatureConfig\\\\|updateSignatureConfig\" /Users/Apple/code_vpn/vpn/ppanel-server/apis/ /Users/Apple/code_vpn/vpn/ppanel-server/internal/logic/ --include=\"*.go\" --include=\"*.api\" 2>/dev/null | head -20)", + "Bash(grep -n \"GetSignatureConfig\\\\|func.*Signature\" /Users/Apple/code_vpn/vpn/ppanel-server/internal/model/system/*.go 2>/dev/null | head -10)", + "Bash(AUTH='Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJDdHhMb2dpblR5cGUiOiIiLCJTZXNzaW9uSWQiOiIwMTljZjAyYi05YTcwLTcyMDItYTlmZS1jNzE1NmZkYjIzYzYiLCJVc2VySWQiOjI1OCwiZXhwIjoxODA1MDkxOTE1LCJpYXQiOjE3NzM1NTU5MTUsImlkZW50aWZpZXIiOiIifQ.rnm_y9DOsjvFC2XecRQ8BNUkWZcfiGzXIh5Dgwh99lA'\nAPI='https://tapi.hifast.biz'\n\necho \"=== 用户信息 ===\"\ncurl -s \"$API/v1/public/user/info\" -H \"$AUTH\" | python3 -c \"\nimport sys,json\nd=json.load\\(sys.stdin\\)\nif 'data' in d:\n print\\('user_id:', d['data']['id']\\)\n devs = d['data'].get\\('user_devices', []\\)\n print\\('device count:', len\\(devs\\)\\)\n for dev in devs[:3]:\n print\\(' device:', json.dumps\\(dev\\)\\)\nelse:\n print\\(json.dumps\\(d\\)\\)\n\")", + "Bash(API='https://tapi.hifast.biz'\nDEVICE_ID=\"devicetest$\\(openssl rand -hex 32\\)\"\n\necho \"Device ID: $DEVICE_ID\"\necho \"=== 设备登录 ===\"\ncurl -s -X POST \"$API/v1/auth/login/device\" \\\\\n -H 'Content-Type: application/json' \\\\\n -d \"{\\\\\"identifier\\\\\":\\\\\"$DEVICE_ID\\\\\",\\\\\"user_agent\\\\\":\\\\\"Mozilla/5.0 \\(Macintosh; Intel Mac OS X 10_15_7\\)\\\\\"}\" | python3 -m json.tool 2>/dev/null)", + "Bash(find /Users/Apple/code_vpn/vpn/frontend/packages/ui/src -name \"request*\" -o -name \"token*\" -o -name \"auth*\" -o -name \"store*\" 2>/dev/null | head -30)", + "Bash(grep -r \"SessionIdKey\" /Users/Apple/code_vpn/vpn/ppanel-server --include=\"*.go\" -n 2>/dev/null | head -10)", + "Bash(ls /Users/Apple/code_vpn/vpn/ppanel-server/etc/ 2>/dev/null; ls /Users/Apple/code_vpn/vpn/ppanel-server/*.yaml 2>/dev/null; ls /Users/Apple/code_vpn/vpn/ppanel-server/*.yml 2>/dev/null)", + "Bash(find /Users/Apple/code_vpn/vpn/ppanel-server/internal/svc -type f -name \"*.go\" | xargs ls -la)", + "Bash(git add:*)", + "Bash(git reset:*)", + "Bash(git commit:*)", + "Bash(git push:*)", + "Bash(git fetch:*)", + "Bash(git branch:*)", + "Bash(git merge:*)", + "Bash(node:*)", + "Bash(git stash:*)", + "Bash(npx biome:*)", + "Bash(chmod:*)", + "Bash(bash -n /Users/Apple/code_vpn/vpn/frontend/scripts/sync-upstream.sh && echo \"语法检查通过\")", + "Bash(grep:*)", + "Bash(git checkout:*)", + "Bash(python3:*)", + "Bash(find:*)", + "Bash(cd:*)", + "Read(//Users/Apple/.claude/**)", + "Bash(2)", + "Bash(claude mcp:*)", + "Bash(mysql:*)" + ] + }, + "enabledMcpjsonServers": ["mysql"], + "enableAllProjectMcpServers": true +} diff --git a/apps/admin/public/assets/locales/en-US/product.json b/apps/admin/public/assets/locales/en-US/product.json index 71765be..6c3c105 100644 --- a/apps/admin/public/assets/locales/en-US/product.json +++ b/apps/admin/public/assets/locales/en-US/product.json @@ -32,6 +32,7 @@ "discount_price": "Discount Price", "discountDescription": "Set discount based on unit price", "discountPercent": "Discount Percentage", + "appleProductId": "Apple Product ID", "Hour": "Hour", "inventory": "Subscription Limit", "language": "Language", diff --git a/apps/admin/public/assets/locales/zh-CN/product.json b/apps/admin/public/assets/locales/zh-CN/product.json index 6ff9d46..05aab35 100644 --- a/apps/admin/public/assets/locales/zh-CN/product.json +++ b/apps/admin/public/assets/locales/zh-CN/product.json @@ -32,6 +32,7 @@ "discount_price": "折扣价格", "discountDescription": "根据单价设置折扣", "discountPercent": "折扣百分比", + "appleProductId": "苹果商品ID", "Hour": "小时", "inventory": "订阅库存", "language": "语言", diff --git a/apps/admin/src/sections/nodes/index.tsx b/apps/admin/src/sections/nodes/index.tsx index 98f1c6f..d273de5 100644 --- a/apps/admin/src/sections/nodes/index.tsx +++ b/apps/admin/src/sections/nodes/index.tsx @@ -26,6 +26,7 @@ import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { useNode } from "@/stores/node"; import { useServer } from "@/stores/server"; +import NodeBatchSheet from "./node-batch-sheet"; import NodeForm from "./node-form"; export default function Nodes() { @@ -245,6 +246,15 @@ export default function Nodes() { ], batchRender(rows) { return [ + { + ref.current?.refresh(); + fetchNodes(); + fetchTags(); + }} + rows={rows} + />, void; +}) { + const { t } = useTranslation("nodes"); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + + // Which fields are active (will be applied) + const [enabledFields, setEnabledFields] = useState>(new Set()); + + // Patch values + const [patch, setPatch] = useState({ + name: "", + enabled: true, + tags: [], + server_id: undefined, + protocol: undefined, + address: "", + port: undefined, + node_group_ids: [], + }); + + const { servers, getAvailableProtocols } = useServer(); + const { tags: existingTags } = useNode(); + + const { data: nodeGroupsData } = useQuery({ + queryKey: ["nodeGroups"], + queryFn: async () => { + const { data } = await getNodeGroupList({ page: 1, size: 1000 }); + return data.data?.list || []; + }, + }); + + const { data: groupConfigData } = useQuery({ + queryKey: ["groupConfig"], + queryFn: async () => { + const { data } = await getGroupConfig(); + return data.data; + }, + }); + + const isGroupEnabled = groupConfigData?.enabled; + + const availableProtocols = getAvailableProtocols(patch.server_id); + + function toggleField(key: FieldKey) { + setEnabledFields((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + // When disabling server_id, also disable protocol (it depends on server selection) + if (key === "server_id") next.delete("protocol"); + } else { + next.add(key); + // When enabling server_id, auto-enable protocol as it must be set together + if (key === "server_id") next.add("protocol"); + } + return next; + }); + } + + function isEnabled(key: FieldKey) { + return enabledFields.has(key); + } + + async function handleSubmit() { + if (rows.length === 0) return; + if (enabledFields.size === 0) { + toast.warning( + t( + "batch_no_fields_selected", + "Please select at least one field to update" + ) + ); + return; + } + + // Validate enabled fields before submitting + if (enabledFields.has("name") && !patch.name?.trim()) { + toast.warning(t("batch_name_required", "Name cannot be empty")); + return; + } + if (enabledFields.has("server_id") && !patch.server_id) { + toast.warning(t("batch_server_required", "Please select a server")); + return; + } + if (enabledFields.has("protocol") && !patch.protocol) { + toast.warning(t("batch_protocol_required", "Please select a protocol")); + return; + } + if ( + enabledFields.has("port") && + (!patch.port || patch.port < 1 || patch.port > 65_535) + ) { + toast.warning( + t("batch_port_invalid", "Port must be between 1 and 65535") + ); + return; + } + + const activePatch: Partial = {}; + for (const key of enabledFields) { + (activePatch as any)[key] = (patch as any)[key]; + } + + setLoading(true); + try { + const results = await Promise.allSettled( + rows.map((row) => { + const body: API.UpdateNodeRequest = { + id: row.id, + name: row.name, + tags: row.tags, + port: row.port, + address: row.address, + server_id: row.server_id, + protocol: row.protocol, + enabled: row.enabled, + ...activePatch, + } as any; + return updateNode(body); + }) + ); + + const succeeded = results.filter((r) => r.status === "fulfilled").length; + const failed = results.filter((r) => r.status === "rejected").length; + + if (failed === 0) { + toast.success( + t("batch_updated", "Updated {{count}} nodes", { count: succeeded }) + ); + setOpen(false); + setEnabledFields(new Set()); + onSuccess(); + } else if (succeeded > 0) { + toast.warning( + t("batch_partial", "{{succeeded}} updated, {{failed}} failed", { + succeeded, + failed, + }) + ); + onSuccess(); + } else { + toast.error(t("batch_update_failed", "Batch update failed")); + } + } finally { + setLoading(false); + } + } + + function handleOpen() { + setEnabledFields(new Set()); + setPatch({ + name: "", + enabled: true, + tags: [], + server_id: undefined, + protocol: undefined, + address: "", + port: undefined, + node_group_ids: [], + }); + setOpen(true); + } + + return ( + + + + + + + + + {t("batch_update_title", "Batch Update ({{count}} nodes)", { + count: rows.length, + })} + + + + +

+ {t( + "batch_update_desc", + "Check the fields you want to overwrite. Unchecked fields will keep their original values." + )} +

+ +
+ {/* name */} +
+ toggleField("name")} + /> +
+ +
+ + setPatch((p) => ({ ...p, name: String(v ?? "") })) + } + value={patch.name ?? ""} + /> +
+
+
+ + {/* enabled */} +
+ toggleField("enabled")} + /> +
+ +
+ + setPatch((p) => ({ ...p, enabled: v })) + } + /> +
+
+
+ + {/* tags */} +
+ toggleField("tags")} + /> +
+ +
+ setPatch((p) => ({ ...p, tags: v }))} + options={existingTags || []} + placeholder={t( + "tags_placeholder", + "Use Enter or comma (,) to add" + )} + value={patch.tags || []} + /> +
+
+
+ + {/* server_id */} +
+ toggleField("server_id")} + /> +
+ +
+ + onChange={(v) => { + setPatch((p) => ({ + ...p, + server_id: v ?? undefined, + protocol: undefined, + })); + }} + options={servers.map((s) => ({ + value: s.id, + label: `${s.name} (${(s.address as any) || ""})`, + }))} + placeholder={t("select_server", "Select server…")} + value={patch.server_id} + /> +
+
+
+ + {/* protocol */} +
+ toggleField("protocol")} + /> +
+ +
+ + onChange={(v) => + setPatch((p) => ({ ...p, protocol: v ?? undefined })) + } + options={availableProtocols.map((p) => ({ + value: p.protocol, + label: `${p.protocol}${p.port ? ` (${p.port})` : ""}`, + }))} + placeholder={t("select_protocol", "Select protocol…")} + value={patch.protocol} + /> +
+
+
+ + {/* address */} +
+ toggleField("address")} + /> +
+ +
+ + setPatch((p) => ({ ...p, address: String(v ?? "") })) + } + value={patch.address ?? ""} + /> +
+
+
+ + {/* port */} +
+ toggleField("port")} + /> +
+ +
+ + setPatch((p) => ({ + ...p, + port: v ? Number(v) : undefined, + })) + } + placeholder="1-65535" + type="number" + value={patch.port ?? ""} + /> +
+
+
+ + {/* node_group_ids — only when group feature is enabled */} + {isGroupEnabled && ( +
+ toggleField("node_group_ids")} + /> +
+ +
+ {nodeGroupsData?.map((g) => { + const ids = patch.node_group_ids || []; + const checked = ids.includes(g.id); + return ( +
+ { + setPatch((p) => { + const current = p.node_group_ids || []; + return { + ...p, + node_group_ids: c + ? [...current, g.id] + : current.filter((id) => id !== g.id), + }; + }); + }} + /> + +
+ ); + })} +
+
+
+ )} +
+
+ + + + + +
+
+ ); +} diff --git a/apps/admin/src/sections/order/index.tsx b/apps/admin/src/sections/order/index.tsx index b8fd831..2515c16 100644 --- a/apps/admin/src/sections/order/index.tsx +++ b/apps/admin/src/sections/order/index.tsx @@ -1,3 +1,4 @@ +import { useSearch } from "@tanstack/react-router"; import { Badge } from "@workspace/ui/components/badge"; import { Button } from "@workspace/ui/components/button"; import { @@ -26,6 +27,13 @@ import { UserDetail } from "../user/user-detail"; export default function Order() { const { t } = useTranslation("order"); + const sp = useSearch({ strict: false }) as Record; + const initialFilters = { + user_id: sp.user_id ? Number(sp.user_id) : undefined, + search: sp.search || undefined, + status: sp.status || undefined, + subscribe_id: sp.subscribe_id || undefined, + }; const statusOptions = [ { @@ -252,6 +260,7 @@ export default function Order() { }, }, ]} + initialFilters={initialFilters} params={[ { key: "status", diff --git a/apps/admin/src/sections/product/subscribe-form.tsx b/apps/admin/src/sections/product/subscribe-form.tsx index 317aa38..c2e6986 100644 --- a/apps/admin/src/sections/product/subscribe-form.tsx +++ b/apps/admin/src/sections/product/subscribe-form.tsx @@ -115,6 +115,7 @@ export default function SubscribeForm>({ z.object({ quantity: z.number(), discount: z.number(), + map_apple: z.string().optional(), }) ) .optional(), @@ -813,6 +814,11 @@ export default function SubscribeForm>({ value ).toString(), }, + { + name: "map_apple", + type: "text", + placeholder: t("form.appleProductId"), + }, ]} onChange={( newValues: (API.SubscribeDiscount & { diff --git a/apps/admin/src/sections/servers/index.tsx b/apps/admin/src/sections/servers/index.tsx index 788457a..5a9469c 100644 --- a/apps/admin/src/sections/servers/index.tsx +++ b/apps/admin/src/sections/servers/index.tsx @@ -22,6 +22,7 @@ import { useNode } from "@/stores/node"; import { useServer } from "@/stores/server"; import DynamicMultiplier from "./dynamic-multiplier"; import OnlineUsersCell from "./online-users-cell"; +import ServerBatchSheet from "./server-batch-sheet"; import ServerConfig from "./server-config"; import ServerForm from "./server-form"; import ServerInstall from "./server-install"; @@ -184,6 +185,14 @@ export default function Servers() { isServerReferencedByNodes(row.id) ); return [ + { + ref.current?.refresh(); + fetchServers(); + }} + rows={rows} + />, void; +}) { + const { t } = useTranslation("servers"); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [accordionValue, setAccordionValue] = useState(); + + const [enabledFields, setEnabledFields] = useState>(new Set()); + const [patch, setPatch] = useState({ + name: "", + country: "", + city: "", + address: "", + }); + + const PROTOCOL_FIELDS = useProtocolFields(); + + const protocolForm = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + name: "", + address: "", + country: "", + city: "", + protocols: PROTOCOLS.map((type) => getProtocolDefaultConfig(type)), + }, + }); + + const protocolsValues = useWatch({ + control: protocolForm.control, + name: "protocols", + }); + + function toggleField(key: FieldKey) { + setEnabledFields((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + } + + function isEnabled(key: FieldKey) { + return enabledFields.has(key); + } + + async function handleSubmit() { + if (rows.length === 0) return; + if (enabledFields.size === 0) { + toast.warning( + t( + "batch_no_fields_selected", + "Please select at least one field to update" + ) + ); + return; + } + + // Validate enabled simple fields + if (enabledFields.has("name") && !patch.name.trim()) { + toast.warning(t("batch_name_required", "Name cannot be empty")); + return; + } + if (enabledFields.has("address") && !patch.address.trim()) { + toast.warning(t("batch_address_required", "Address cannot be empty")); + return; + } + + const activePatch: Record = {}; + for (const key of enabledFields) { + if (key === "protocols") { + const formValues = protocolForm.getValues(); + const filteredProtocols = (formValues.protocols || []).filter( + (p: any) => { + const port = Number(p?.port); + return p && Number.isFinite(port) && port > 0 && port <= 65_535; + } + ); + activePatch.protocols = filteredProtocols; + } else { + activePatch[key] = patch[key]; + } + } + + setLoading(true); + try { + const results = await Promise.allSettled( + rows.map((row) => { + const body: API.UpdateServerRequest = { + id: row.id, + name: row.name, + country: row.country as string | undefined, + city: row.city as string | undefined, + address: row.address, + sort: row.sort, + protocols: row.protocols, + ...activePatch, + }; + return updateServer(body); + }) + ); + + const succeeded = results.filter((r) => r.status === "fulfilled").length; + const failed = results.filter((r) => r.status === "rejected").length; + + if (failed === 0) { + toast.success( + t("batch_updated", "Updated {{count}} servers", { count: succeeded }) + ); + setOpen(false); + setEnabledFields(new Set()); + onSuccess(); + } else if (succeeded > 0) { + toast.warning( + t("batch_partial", "{{succeeded}} updated, {{failed}} failed", { + succeeded, + failed, + }) + ); + onSuccess(); + } else { + toast.error(t("batch_update_failed", "Batch update failed")); + } + } finally { + setLoading(false); + } + } + + function handleOpen() { + setEnabledFields(new Set()); + setPatch({ name: "", country: "", city: "", address: "" }); + protocolForm.reset({ + name: "", + address: "", + country: "", + city: "", + protocols: PROTOCOLS.map((type) => getProtocolDefaultConfig(type)), + }); + setAccordionValue(undefined); + setOpen(true); + } + + const simpleFields: Array<{ + key: SimpleFieldKey; + label: string; + placeholder?: string; + }> = [ + { key: "name", label: t("name", "Name") }, + { key: "country", label: t("country", "Country") }, + { key: "city", label: t("city", "City") }, + { + key: "address", + label: t("address", "Address"), + placeholder: t("address_placeholder", "Server address"), + }, + ]; + + return ( + + + + + + + + + {t("batch_update_title", "Batch Update ({{count}} servers)", { + count: rows.length, + })} + + + + +

+ {t( + "batch_update_desc", + "Check the fields you want to overwrite. Unchecked fields will keep their original values." + )} +

+ +
+ + {/* Simple text fields */} + {simpleFields.map(({ key, label, placeholder }) => ( +
+ toggleField(key)} + /> +
+ +
+ + setPatch((p) => ({ ...p, [key]: String(v ?? "") })) + } + placeholder={placeholder} + value={patch[key]} + /> +
+
+
+ ))} + + {/* Protocol Configurations */} +
+ toggleField("protocols")} + /> +
+ +

+ {t( + "protocol_configurations_desc", + "Enable and configure the required protocol types" + )} +

+
+ + {PROTOCOLS.map((type) => { + const i = Math.max(0, PROTOCOLS.indexOf(type)); + const current = (protocolsValues?.[i] || {}) as Record< + string, + any + >; + const isProtocolEnabled = current?.enable; + const fields = PROTOCOL_FIELDS[type] || []; + return ( + + +
+
+
+ + {type} + + {current.transport && ( + + {current.transport.toUpperCase()} + + )} + {current.security && + current.security !== "none" && ( + + {current.security.toUpperCase()} + + )} + {current.port && ( + + {current.port} + + )} +
+ + {isProtocolEnabled + ? t("enabled", "Enabled") + : t("disabled", "Disabled")} + +
+ { + protocolForm.setValue( + `protocols.${i}.enable` as any, + checked + ); + }} + onClick={(e) => e.stopPropagation()} + /> +
+
+ +
+ {renderGroupCard( + t("basic", "Basic Configuration"), + fields, + "basic", + protocolForm.control, + protocolForm, + i, + current + )} + {renderGroupCard( + t("obfs", "Obfuscation"), + fields, + "obfs", + protocolForm.control, + protocolForm, + i, + current + )} + {renderGroupCard( + t("transport", "Transport"), + fields, + "transport", + protocolForm.control, + protocolForm, + i, + current + )} + {renderGroupCard( + t("security", "Security"), + fields, + "security", + protocolForm.control, + protocolForm, + i, + current + )} + {renderGroupCard( + t("reality", "Reality"), + fields, + "reality", + protocolForm.control, + protocolForm, + i, + current + )} + {renderGroupCard( + t("encryption", "Encryption"), + fields, + "encryption", + protocolForm.control, + protocolForm, + i, + current + )} +
+
+
+ ); + })} +
+
+
+
+
+ +
+ + + + + +
+
+ ); +} diff --git a/apps/admin/src/sections/servers/server-form.tsx b/apps/admin/src/sections/servers/server-form.tsx index 46f11f5..5cfc0ec 100644 --- a/apps/admin/src/sections/servers/server-form.tsx +++ b/apps/admin/src/sections/servers/server-form.tsx @@ -184,7 +184,9 @@ function DynamicField({ {...fieldProps} max={field.max} min={field.min} - onValueChange={(v) => fieldProps.onChange(v)} + onValueChange={(v) => + fieldProps.onChange(v === "" ? undefined : Number(v)) + } placeholder={field.placeholder} step={field.step || 1} suffix={field.suffix} @@ -308,7 +310,7 @@ function renderFieldsByGroup( ); } -function renderGroupCard( +export function renderGroupCard( title: string, fields: FieldConfig[], group: string, @@ -362,15 +364,52 @@ export default function ServerForm(props: { const { isProtocolUsedInNodes } = useNode(); const PROTOCOL_FIELDS = useProtocolFields(); + /** + * mergeProtocol - 将后端返回的协议数据与前端默认配置安全合并。 + * 过滤掉空字符串 "",避免覆盖默认的枚举值(如 flow="none"), + * 从而防止 Zod 枚举校验失败。 + * @param existingProtocol - 后端返回的协议对象(可能含空字符串) + * @param defaultConfig - 前端定义的安全默认配置 + * @returns 合并后的协议对象 + */ + function mergeProtocol( + existingProtocol: Record | undefined, + defaultConfig: Record + ) { + if (!existingProtocol) return defaultConfig; + const merged = { ...defaultConfig }; + for (const key in existingProtocol) { + if (Object.hasOwn(existingProtocol, key)) { + const val = existingProtocol[key]; + // 空字符串降级为默认值,避免 Zod 枚举校验错误 + if (val !== "") { + merged[key] = val; + } + } + } + return merged; + } + + /** + * buildSanitizedProtocols - 为所有协议类型构建安全的初始表单数据。 + * @param protocols - 后端返回的已有协议数组 + * @returns 清洗后的完整协议配置数组 + */ + function buildSanitizedProtocols(protocols?: any[]) { + return PROTOCOLS.map((type) => { + const existing = protocols?.find((p) => p.type === type); + return mergeProtocol(existing as any, getProtocolDefaultConfig(type)); + }); + } + const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { - name: "", - address: "", - country: "", - city: "", - protocols: [] as any[], - ...initialValues, + name: initialValues?.name ?? "", + address: initialValues?.address ?? "", + country: initialValues?.country ?? "", + city: initialValues?.city ?? "", + protocols: buildSanitizedProtocols(initialValues?.protocols as any[]), }, }); const { control } = form; @@ -380,20 +419,11 @@ export default function ServerForm(props: { useEffect(() => { if (initialValues) { form.reset({ - name: "", - address: "", - country: "", - city: "", - ...initialValues, - protocols: PROTOCOLS.map((type) => { - const existingProtocol = initialValues.protocols?.find( - (p) => p.type === type - ); - const defaultConfig = getProtocolDefaultConfig(type); - return existingProtocol - ? { ...defaultConfig, ...existingProtocol } - : defaultConfig; - }), + name: initialValues.name ?? "", + address: initialValues.address ?? "", + country: initialValues.country ?? "", + city: initialValues.city ?? "", + protocols: buildSanitizedProtocols(initialValues.protocols as any[]), }); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -686,8 +716,22 @@ export default function ServerForm(props: {