feat: 节点/服务器批量编辑 Sheet、Server 表单协议合并修复、设备类型解析
Some checks failed
Build and Release / Build (push) Has been cancelled
Some checks failed
Build and Release / Build (push) Has been cancelled
- 新增 NodeBatchSheet 与 ServerBatchSheet,支持批量选中后一键编辑公共字段 - Server 表单:引入 mergeProtocol / buildSanitizedProtocols,过滤空字符串避免 Zod 枚举校验失败;renderGroupCard 改为具名导出供批量 Sheet 复用;优化嵌套错误提示递归取第一条 - 新增 parseDeviceType 工具函数,从 User-Agent 解析设备平台(iPhone/Android/Mac 等) - 用户列表:登录标识旁展示设备平台 Badge - 注册安全设置表单:引入 Textarea 组件并完善字段布局 - 类型定义补充及 i18n 本地化更新 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
de87133061
commit
7400137b3c
76
.claude/settings.local.json
Normal file
76
.claude/settings.local.json
Normal file
@ -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
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -32,6 +32,7 @@
|
||||
"discount_price": "折扣价格",
|
||||
"discountDescription": "根据单价设置折扣",
|
||||
"discountPercent": "折扣百分比",
|
||||
"appleProductId": "苹果商品ID",
|
||||
"Hour": "小时",
|
||||
"inventory": "订阅库存",
|
||||
"language": "语言",
|
||||
|
||||
@ -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 [
|
||||
<NodeBatchSheet
|
||||
key="batch-update"
|
||||
onSuccess={() => {
|
||||
ref.current?.refresh();
|
||||
fetchNodes();
|
||||
fetchTags();
|
||||
}}
|
||||
rows={rows}
|
||||
/>,
|
||||
<ConfirmButton
|
||||
cancelText={t("cancel", "Cancel")}
|
||||
confirmText={t("confirm", "Confirm")}
|
||||
|
||||
558
apps/admin/src/sections/nodes/node-batch-sheet.tsx
Normal file
558
apps/admin/src/sections/nodes/node-batch-sheet.tsx
Normal file
@ -0,0 +1,558 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { Checkbox } from "@workspace/ui/components/checkbox";
|
||||
import { Label } from "@workspace/ui/components/label";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { Combobox } from "@workspace/ui/composed/combobox";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import TagInput from "@workspace/ui/composed/tag-input";
|
||||
import {
|
||||
getGroupConfig,
|
||||
getNodeGroupList,
|
||||
} from "@workspace/ui/services/admin/group";
|
||||
import { updateNode } from "@workspace/ui/services/admin/server";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useNode } from "@/stores/node";
|
||||
import { useServer } from "@/stores/server";
|
||||
|
||||
type FieldKey =
|
||||
| "name"
|
||||
| "enabled"
|
||||
| "tags"
|
||||
| "server_id"
|
||||
| "protocol"
|
||||
| "address"
|
||||
| "port"
|
||||
| "node_group_ids";
|
||||
|
||||
interface BatchPatch {
|
||||
name?: string;
|
||||
enabled?: boolean;
|
||||
tags?: string[];
|
||||
server_id?: number;
|
||||
protocol?: string;
|
||||
address?: string;
|
||||
port?: number;
|
||||
node_group_ids?: number[];
|
||||
}
|
||||
|
||||
export default function NodeBatchSheet({
|
||||
rows,
|
||||
onSuccess,
|
||||
}: {
|
||||
rows: API.Node[];
|
||||
onSuccess: () => 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<Set<FieldKey>>(new Set());
|
||||
|
||||
// Patch values
|
||||
const [patch, setPatch] = useState<BatchPatch>({
|
||||
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<BatchPatch> = {};
|
||||
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 (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<Button onClick={handleOpen} variant="outline">
|
||||
{t("batch_update", "Batch Update")}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
|
||||
<SheetContent className="w-[560px] max-w-full">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{t("batch_update_title", "Batch Update ({{count}} nodes)", {
|
||||
count: rows.length,
|
||||
})}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6 pt-4">
|
||||
<p className="mb-4 text-muted-foreground text-sm">
|
||||
{t(
|
||||
"batch_update_desc",
|
||||
"Check the fields you want to overwrite. Unchecked fields will keep their original values."
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="space-y-5">
|
||||
{/* name */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={isEnabled("name")}
|
||||
id="batch-name"
|
||||
onCheckedChange={() => toggleField("name")}
|
||||
/>
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Label
|
||||
className={isEnabled("name") ? "" : "text-muted-foreground"}
|
||||
htmlFor="batch-name"
|
||||
>
|
||||
{t("name", "Name")}
|
||||
</Label>
|
||||
<div
|
||||
className={
|
||||
isEnabled("name") ? "" : "pointer-events-none opacity-40"
|
||||
}
|
||||
>
|
||||
<EnhancedInput
|
||||
onValueChange={(v) =>
|
||||
setPatch((p) => ({ ...p, name: String(v ?? "") }))
|
||||
}
|
||||
value={patch.name ?? ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* enabled */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={isEnabled("enabled")}
|
||||
id="batch-enabled"
|
||||
onCheckedChange={() => toggleField("enabled")}
|
||||
/>
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Label
|
||||
className={
|
||||
isEnabled("enabled") ? "" : "text-muted-foreground"
|
||||
}
|
||||
htmlFor="batch-enabled"
|
||||
>
|
||||
{t("enabled", "Enabled")}
|
||||
</Label>
|
||||
<div
|
||||
className={
|
||||
isEnabled("enabled") ? "" : "pointer-events-none opacity-40"
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
checked={!!patch.enabled}
|
||||
onCheckedChange={(v) =>
|
||||
setPatch((p) => ({ ...p, enabled: v }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* tags */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={isEnabled("tags")}
|
||||
id="batch-tags"
|
||||
onCheckedChange={() => toggleField("tags")}
|
||||
/>
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Label
|
||||
className={isEnabled("tags") ? "" : "text-muted-foreground"}
|
||||
htmlFor="batch-tags"
|
||||
>
|
||||
{t("tags", "Tags")}
|
||||
</Label>
|
||||
<div
|
||||
className={
|
||||
isEnabled("tags") ? "" : "pointer-events-none opacity-40"
|
||||
}
|
||||
>
|
||||
<TagInput
|
||||
onChange={(v) => setPatch((p) => ({ ...p, tags: v }))}
|
||||
options={existingTags || []}
|
||||
placeholder={t(
|
||||
"tags_placeholder",
|
||||
"Use Enter or comma (,) to add"
|
||||
)}
|
||||
value={patch.tags || []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* server_id */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={isEnabled("server_id")}
|
||||
id="batch-server"
|
||||
onCheckedChange={() => toggleField("server_id")}
|
||||
/>
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Label
|
||||
className={
|
||||
isEnabled("server_id") ? "" : "text-muted-foreground"
|
||||
}
|
||||
htmlFor="batch-server"
|
||||
>
|
||||
{t("server", "Server")}
|
||||
</Label>
|
||||
<div
|
||||
className={
|
||||
isEnabled("server_id")
|
||||
? ""
|
||||
: "pointer-events-none opacity-40"
|
||||
}
|
||||
>
|
||||
<Combobox<number, false>
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* protocol */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={isEnabled("protocol")}
|
||||
id="batch-protocol"
|
||||
onCheckedChange={() => toggleField("protocol")}
|
||||
/>
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Label
|
||||
className={
|
||||
isEnabled("protocol") ? "" : "text-muted-foreground"
|
||||
}
|
||||
htmlFor="batch-protocol"
|
||||
>
|
||||
{t("protocol", "Protocol")}
|
||||
</Label>
|
||||
<div
|
||||
className={
|
||||
isEnabled("protocol")
|
||||
? ""
|
||||
: "pointer-events-none opacity-40"
|
||||
}
|
||||
>
|
||||
<Combobox<string, false>
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* address */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={isEnabled("address")}
|
||||
id="batch-address"
|
||||
onCheckedChange={() => toggleField("address")}
|
||||
/>
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Label
|
||||
className={
|
||||
isEnabled("address") ? "" : "text-muted-foreground"
|
||||
}
|
||||
htmlFor="batch-address"
|
||||
>
|
||||
{t("address", "Address")}
|
||||
</Label>
|
||||
<div
|
||||
className={
|
||||
isEnabled("address") ? "" : "pointer-events-none opacity-40"
|
||||
}
|
||||
>
|
||||
<EnhancedInput
|
||||
onValueChange={(v) =>
|
||||
setPatch((p) => ({ ...p, address: String(v ?? "") }))
|
||||
}
|
||||
value={patch.address ?? ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* port */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={isEnabled("port")}
|
||||
id="batch-port"
|
||||
onCheckedChange={() => toggleField("port")}
|
||||
/>
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Label
|
||||
className={isEnabled("port") ? "" : "text-muted-foreground"}
|
||||
htmlFor="batch-port"
|
||||
>
|
||||
{t("port", "Port")}
|
||||
</Label>
|
||||
<div
|
||||
className={
|
||||
isEnabled("port") ? "" : "pointer-events-none opacity-40"
|
||||
}
|
||||
>
|
||||
<EnhancedInput
|
||||
max={65_535}
|
||||
min={1}
|
||||
onValueChange={(v) =>
|
||||
setPatch((p) => ({
|
||||
...p,
|
||||
port: v ? Number(v) : undefined,
|
||||
}))
|
||||
}
|
||||
placeholder="1-65535"
|
||||
type="number"
|
||||
value={patch.port ?? ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* node_group_ids — only when group feature is enabled */}
|
||||
{isGroupEnabled && (
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={isEnabled("node_group_ids")}
|
||||
id="batch-groups"
|
||||
onCheckedChange={() => toggleField("node_group_ids")}
|
||||
/>
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Label
|
||||
className={
|
||||
isEnabled("node_group_ids") ? "" : "text-muted-foreground"
|
||||
}
|
||||
htmlFor="batch-groups"
|
||||
>
|
||||
{t("nodeGroups", "Node Groups")}
|
||||
</Label>
|
||||
<div
|
||||
className={
|
||||
isEnabled("node_group_ids")
|
||||
? "grid grid-cols-2 gap-2"
|
||||
: "pointer-events-none grid grid-cols-2 gap-2 opacity-40"
|
||||
}
|
||||
>
|
||||
{nodeGroupsData?.map((g) => {
|
||||
const ids = patch.node_group_ids || [];
|
||||
const checked = ids.includes(g.id);
|
||||
return (
|
||||
<div className="flex items-center space-x-2" key={g.id}>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
id={`batch-group-${g.id}`}
|
||||
onCheckedChange={(c) => {
|
||||
setPatch((p) => {
|
||||
const current = p.node_group_ids || [];
|
||||
return {
|
||||
...p,
|
||||
node_group_ids: c
|
||||
? [...current, g.id]
|
||||
: current.filter((id) => id !== g.id),
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={`batch-group-${g.id}`}>
|
||||
{g.name}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} onClick={handleSubmit}>
|
||||
{t("confirm", "Confirm")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@ -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<string, string | undefined>;
|
||||
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",
|
||||
|
||||
@ -115,6 +115,7 @@ export default function SubscribeForm<T extends Record<string, any>>({
|
||||
z.object({
|
||||
quantity: z.number(),
|
||||
discount: z.number(),
|
||||
map_apple: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
@ -813,6 +814,11 @@ export default function SubscribeForm<T extends Record<string, any>>({
|
||||
value
|
||||
).toString(),
|
||||
},
|
||||
{
|
||||
name: "map_apple",
|
||||
type: "text",
|
||||
placeholder: t("form.appleProductId"),
|
||||
},
|
||||
]}
|
||||
onChange={(
|
||||
newValues: (API.SubscribeDiscount & {
|
||||
|
||||
@ -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 [
|
||||
<ServerBatchSheet
|
||||
key="batch-update"
|
||||
onSuccess={() => {
|
||||
ref.current?.refresh();
|
||||
fetchServers();
|
||||
}}
|
||||
rows={rows}
|
||||
/>,
|
||||
<ConfirmButton
|
||||
cancelText={t("cancel", "Cancel")}
|
||||
confirmText={t("confirm", "Confirm")}
|
||||
|
||||
461
apps/admin/src/sections/servers/server-batch-sheet.tsx
Normal file
461
apps/admin/src/sections/servers/server-batch-sheet.tsx
Normal file
@ -0,0 +1,461 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@workspace/ui/components/accordion";
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { Checkbox } from "@workspace/ui/components/checkbox";
|
||||
import { Form } from "@workspace/ui/components/form";
|
||||
import { Label } from "@workspace/ui/components/label";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { cn } from "@workspace/ui/lib/utils";
|
||||
import { updateServer } from "@workspace/ui/services/admin/server";
|
||||
import { useState } from "react";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
formSchema,
|
||||
getProtocolDefaultConfig,
|
||||
protocols as PROTOCOLS,
|
||||
useProtocolFields,
|
||||
} from "./form-schema";
|
||||
import { renderGroupCard } from "./server-form";
|
||||
|
||||
type SimpleFieldKey = "name" | "country" | "city" | "address";
|
||||
type FieldKey = SimpleFieldKey | "protocols";
|
||||
|
||||
interface SimplePatch {
|
||||
name: string;
|
||||
country: string;
|
||||
city: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
export default function ServerBatchSheet({
|
||||
rows,
|
||||
onSuccess,
|
||||
}: {
|
||||
rows: API.Server[];
|
||||
onSuccess: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("servers");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [accordionValue, setAccordionValue] = useState<string>();
|
||||
|
||||
const [enabledFields, setEnabledFields] = useState<Set<FieldKey>>(new Set());
|
||||
const [patch, setPatch] = useState<SimplePatch>({
|
||||
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<string, any> = {};
|
||||
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 (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<Button onClick={handleOpen} variant="outline">
|
||||
{t("batch_update", "Batch Update")}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
|
||||
<SheetContent className="w-[700px] max-w-full gap-0 md:max-w-3xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{t("batch_update_title", "Batch Update ({{count}} servers)", {
|
||||
count: rows.length,
|
||||
})}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6 pt-4">
|
||||
<p className="mb-4 text-muted-foreground text-sm">
|
||||
{t(
|
||||
"batch_update_desc",
|
||||
"Check the fields you want to overwrite. Unchecked fields will keep their original values."
|
||||
)}
|
||||
</p>
|
||||
|
||||
<Form {...protocolForm}>
|
||||
<form className="space-y-5">
|
||||
{/* Simple text fields */}
|
||||
{simpleFields.map(({ key, label, placeholder }) => (
|
||||
<div className="flex items-start gap-3" key={key}>
|
||||
<Checkbox
|
||||
checked={isEnabled(key)}
|
||||
id={`batch-server-${key}`}
|
||||
onCheckedChange={() => toggleField(key)}
|
||||
/>
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Label
|
||||
className={isEnabled(key) ? "" : "text-muted-foreground"}
|
||||
htmlFor={`batch-server-${key}`}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
<div
|
||||
className={
|
||||
isEnabled(key) ? "" : "pointer-events-none opacity-40"
|
||||
}
|
||||
>
|
||||
<EnhancedInput
|
||||
onValueChange={(v) =>
|
||||
setPatch((p) => ({ ...p, [key]: String(v ?? "") }))
|
||||
}
|
||||
placeholder={placeholder}
|
||||
value={patch[key]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Protocol Configurations */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={isEnabled("protocols")}
|
||||
id="batch-server-protocols"
|
||||
onCheckedChange={() => toggleField("protocols")}
|
||||
/>
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Label
|
||||
className={
|
||||
isEnabled("protocols") ? "" : "text-muted-foreground"
|
||||
}
|
||||
htmlFor="batch-server-protocols"
|
||||
>
|
||||
{t("protocol_configurations", "Protocol Configurations")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(
|
||||
"protocol_configurations_desc",
|
||||
"Enable and configure the required protocol types"
|
||||
)}
|
||||
</p>
|
||||
<div
|
||||
className={
|
||||
isEnabled("protocols")
|
||||
? ""
|
||||
: "pointer-events-none opacity-40"
|
||||
}
|
||||
>
|
||||
<Accordion
|
||||
className="w-full space-y-3"
|
||||
collapsible
|
||||
onValueChange={setAccordionValue}
|
||||
type="single"
|
||||
value={accordionValue}
|
||||
>
|
||||
{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 (
|
||||
<AccordionItem
|
||||
className="mb-2 rounded-lg border"
|
||||
key={type}
|
||||
value={type}
|
||||
>
|
||||
<AccordionTrigger className="px-4 py-3 hover:no-underline">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium capitalize">
|
||||
{type}
|
||||
</span>
|
||||
{current.transport && (
|
||||
<Badge
|
||||
className="text-xs"
|
||||
variant="secondary"
|
||||
>
|
||||
{current.transport.toUpperCase()}
|
||||
</Badge>
|
||||
)}
|
||||
{current.security &&
|
||||
current.security !== "none" && (
|
||||
<Badge
|
||||
className="text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
{current.security.toUpperCase()}
|
||||
</Badge>
|
||||
)}
|
||||
{current.port && (
|
||||
<Badge className="text-xs">
|
||||
{current.port}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs",
|
||||
isProtocolEnabled
|
||||
? "text-green-500"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isProtocolEnabled
|
||||
? t("enabled", "Enabled")
|
||||
: t("disabled", "Disabled")}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={!!isProtocolEnabled}
|
||||
className="mr-2"
|
||||
onCheckedChange={(checked) => {
|
||||
protocolForm.setValue(
|
||||
`protocols.${i}.enable` as any,
|
||||
checked
|
||||
);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-4 pt-0 pb-4">
|
||||
<div className="-mx-4 space-y-4 rounded-b-lg border-t px-4 pt-4">
|
||||
{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
|
||||
)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} onClick={handleSubmit}>
|
||||
{t("confirm", "Confirm")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@ -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<string, any> | undefined,
|
||||
defaultConfig: Record<string, any>
|
||||
) {
|
||||
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: {
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={form.handleSubmit(handleSubmit, (errors) => {
|
||||
const key = Object.keys(errors)[0] as keyof typeof errors;
|
||||
if (key) toast.error(String(errors[key]?.message));
|
||||
const getFirstErrorMessage = (
|
||||
errObj: any
|
||||
): string | undefined => {
|
||||
if (!errObj) return;
|
||||
if (errObj.message && typeof errObj.message === "string")
|
||||
return errObj.message;
|
||||
for (const k in errObj) {
|
||||
if (Object.hasOwn(errObj, k)) {
|
||||
const msg = getFirstErrorMessage(errObj[k]);
|
||||
if (msg) return msg;
|
||||
}
|
||||
}
|
||||
return;
|
||||
};
|
||||
const msg = getFirstErrorMessage(errors);
|
||||
if (msg) toast.error(msg);
|
||||
return false;
|
||||
})}
|
||||
>
|
||||
|
||||
@ -27,10 +27,12 @@ type Props = {
|
||||
server: API.Server;
|
||||
};
|
||||
|
||||
const DEFAULT_API_HOST = "https://api.hifast.biz";
|
||||
|
||||
export default function ServerInstall({ server }: Props) {
|
||||
const { t } = useTranslation("servers");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [domain, setDomain] = useState("");
|
||||
const [domain, setDomain] = useState(DEFAULT_API_HOST);
|
||||
|
||||
const { data: cfgResp } = useQuery({
|
||||
queryKey: ["getNodeConfig"],
|
||||
@ -43,8 +45,7 @@ export default function ServerInstall({ server }: Props) {
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const host = localStorage.getItem("API_HOST") ?? window.location.origin;
|
||||
setDomain(host);
|
||||
setDomain(DEFAULT_API_HOST);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
@ -75,7 +76,6 @@ export default function ServerInstall({ server }: Props) {
|
||||
|
||||
const onDomainChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||
setDomain(e.target.value);
|
||||
localStorage.setItem("API_HOST", e.target.value);
|
||||
}, []);
|
||||
return (
|
||||
<Dialog onOpenChange={setOpen} open={open}>
|
||||
|
||||
@ -20,6 +20,7 @@ import {
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { Textarea } from "@workspace/ui/components/textarea";
|
||||
import { Combobox } from "@workspace/ui/composed/combobox";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
@ -40,6 +41,8 @@ const registerSchema = z.object({
|
||||
trial_subscribe: z.number().optional(),
|
||||
trial_time: z.number().optional(),
|
||||
trial_time_unit: z.string().optional(),
|
||||
enable_trial_email_whitelist: z.boolean().optional(),
|
||||
trial_email_domain_whitelist: z.string().optional(),
|
||||
enable_ip_register_limit: z.boolean().optional(),
|
||||
ip_register_limit: z.number().optional(),
|
||||
ip_register_limit_duration: z.number().optional(),
|
||||
@ -72,6 +75,8 @@ export default function RegisterConfig() {
|
||||
trial_subscribe: undefined,
|
||||
trial_time: 0,
|
||||
trial_time_unit: "day",
|
||||
enable_trial_email_whitelist: false,
|
||||
trial_email_domain_whitelist: "",
|
||||
enable_ip_register_limit: false,
|
||||
ip_register_limit: 1,
|
||||
ip_register_limit_duration: 1,
|
||||
@ -331,114 +336,182 @@ export default function RegisterConfig() {
|
||||
/>
|
||||
|
||||
{form.watch("enable_trial") && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="trial_time"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("register.trialConfig", "Trial Configuration")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex gap-2">
|
||||
<EnhancedInput
|
||||
className="flex-1"
|
||||
min={0}
|
||||
onValueBlur={(value) =>
|
||||
field.onChange(Number(value))
|
||||
}
|
||||
placeholder={t(
|
||||
"register.inputPlaceholder",
|
||||
"Please enter"
|
||||
)}
|
||||
prefix={
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="trial_subscribe"
|
||||
render={({ field }) => (
|
||||
<Combobox
|
||||
className="w-32 rounded-r-none bg-secondary"
|
||||
onChange={(value: number) => {
|
||||
if (value) {
|
||||
field.onChange(value);
|
||||
}
|
||||
}}
|
||||
options={subscribes?.map((item) => ({
|
||||
label: item.name!,
|
||||
value: item.id!,
|
||||
}))}
|
||||
placeholder={t(
|
||||
"register.selectPlaceholder",
|
||||
"Please select"
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
suffix={
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="trial_time_unit"
|
||||
render={({ field: unitField }) => (
|
||||
<Combobox
|
||||
className="w-32 rounded-l-none bg-secondary"
|
||||
onChange={(value: string) => {
|
||||
unitField.onChange(value);
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
label: t("register.none", "None"),
|
||||
value: "None",
|
||||
},
|
||||
{
|
||||
label: t("register.year", "Year(s)"),
|
||||
value: "Year",
|
||||
},
|
||||
{
|
||||
label: t("register.month", "Month(s)"),
|
||||
value: "Month",
|
||||
},
|
||||
{
|
||||
label: t("register.day", "Day(s)"),
|
||||
value: "Day",
|
||||
},
|
||||
{
|
||||
label: t("register.hour", "Hour(s)"),
|
||||
value: "Hour",
|
||||
},
|
||||
{
|
||||
label: t(
|
||||
"register.minute",
|
||||
"Minute(s)"
|
||||
),
|
||||
value: "Minute",
|
||||
},
|
||||
]}
|
||||
placeholder={t(
|
||||
"register.selectPlaceholder",
|
||||
"Please select"
|
||||
)}
|
||||
value={unitField.value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
type="number"
|
||||
value={field.value}
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="trial_time"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("register.trialConfig", "Trial Configuration")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex gap-2">
|
||||
<EnhancedInput
|
||||
className="flex-1"
|
||||
min={0}
|
||||
onValueBlur={(value) =>
|
||||
field.onChange(Number(value))
|
||||
}
|
||||
placeholder={t(
|
||||
"register.inputPlaceholder",
|
||||
"Please enter"
|
||||
)}
|
||||
prefix={
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="trial_subscribe"
|
||||
render={({ field }) => (
|
||||
<Combobox
|
||||
className="w-32 rounded-r-none bg-secondary"
|
||||
onChange={(value: number) => {
|
||||
if (value) {
|
||||
field.onChange(value);
|
||||
}
|
||||
}}
|
||||
options={subscribes?.map((item) => ({
|
||||
label: item.name!,
|
||||
value: item.id!,
|
||||
}))}
|
||||
placeholder={t(
|
||||
"register.selectPlaceholder",
|
||||
"Please select"
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
suffix={
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="trial_time_unit"
|
||||
render={({ field: unitField }) => (
|
||||
<Combobox
|
||||
className="w-32 rounded-l-none bg-secondary"
|
||||
onChange={(value: string) => {
|
||||
unitField.onChange(value);
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
label: t("register.none", "None"),
|
||||
value: "None",
|
||||
},
|
||||
{
|
||||
label: t("register.year", "Year(s)"),
|
||||
value: "Year",
|
||||
},
|
||||
{
|
||||
label: t(
|
||||
"register.month",
|
||||
"Month(s)"
|
||||
),
|
||||
value: "Month",
|
||||
},
|
||||
{
|
||||
label: t("register.day", "Day(s)"),
|
||||
value: "Day",
|
||||
},
|
||||
{
|
||||
label: t("register.hour", "Hour(s)"),
|
||||
value: "Hour",
|
||||
},
|
||||
{
|
||||
label: t(
|
||||
"register.minute",
|
||||
"Minute(s)"
|
||||
),
|
||||
value: "Minute",
|
||||
},
|
||||
]}
|
||||
placeholder={t(
|
||||
"register.selectPlaceholder",
|
||||
"Please select"
|
||||
)}
|
||||
value={unitField.value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
type="number"
|
||||
value={field.value}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"register.trialConfigDescription",
|
||||
"Configure trial subscription, duration and time unit for new users upon registration"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enable_trial_email_whitelist"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"register.enableTrialEmailWhitelist",
|
||||
"Email Domain Whitelist"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"register.trialConfigDescription",
|
||||
"Configure trial subscription, duration and time unit for new users upon registration"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"register.enableTrialEmailWhitelistDescription",
|
||||
"When enabled, trial subscription is only granted to emails with whitelisted domains"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.watch("enable_trial_email_whitelist") && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="trial_email_domain_whitelist"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"register.trialEmailDomainWhitelist",
|
||||
"Whitelisted Domains"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t(
|
||||
"register.trialEmailDomainWhitelistPlaceholder",
|
||||
"gmail.com,outlook.com,edu.cn"
|
||||
)}
|
||||
rows={3}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"register.trialEmailDomainWhitelistDescription",
|
||||
"Comma-separated list of allowed email domains for trial subscription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@ -55,6 +55,7 @@ import {
|
||||
getUserList,
|
||||
updateUserBasicInfo,
|
||||
} from "@workspace/ui/services/admin/user";
|
||||
import { parseDeviceType } from "@workspace/ui/utils/device";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
@ -252,16 +253,23 @@ export default function User() {
|
||||
const method = row.original.auth_methods?.[0];
|
||||
const identifier = method?.auth_identifier || "";
|
||||
const isDevice = method?.auth_type === "device";
|
||||
const deviceNo = (row.original.user_devices?.[0] as any)?.device_no;
|
||||
const firstDevice = row.original.user_devices?.[0] as any;
|
||||
const deviceNo = firstDevice?.device_no;
|
||||
const deviceType = parseDeviceType(firstDevice?.user_agent || "");
|
||||
const display = isDevice ? deviceNo || identifier : identifier;
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<Badge
|
||||
className="mr-1 uppercase"
|
||||
title={method?.verified ? t("verified", "Verified") : ""}
|
||||
>
|
||||
{method?.auth_type}
|
||||
</Badge>
|
||||
{deviceType && (
|
||||
<Badge className="mr-1" variant="secondary">
|
||||
{deviceType}
|
||||
</Badge>
|
||||
)}
|
||||
<span title={isDevice ? display : undefined}>{display}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -59,8 +59,8 @@ export default function UserForm<T extends Record<string, any>>({
|
||||
referral_percentage: z.number().optional(),
|
||||
only_first_purchase: z.boolean().optional(),
|
||||
is_admin: z.boolean().optional(),
|
||||
balance: z.number().optional(),
|
||||
gift_amount: z.number().optional(),
|
||||
// balance: z.number().optional(),
|
||||
// gift_amount: z.number().optional(),
|
||||
commission: z.number().optional(),
|
||||
});
|
||||
const form = useForm({
|
||||
@ -282,7 +282,7 @@ export default function UserForm<T extends Record<string, any>>({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
{/* <FormField
|
||||
control={form.control}
|
||||
name="balance"
|
||||
render={({ field }) => (
|
||||
@ -309,8 +309,8 @@ export default function UserForm<T extends Record<string, any>>({
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
/> */}
|
||||
{/* <FormField
|
||||
control={form.control}
|
||||
name="gift_amount"
|
||||
render={({ field }) => (
|
||||
@ -340,7 +340,7 @@ export default function UserForm<T extends Record<string, any>>({
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
/> */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="commission"
|
||||
|
||||
@ -55,6 +55,7 @@ function SheetContent({
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
aria-describedby={undefined}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=open]:animate-in data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
|
||||
3
packages/ui/src/services/admin/typings.d.ts
vendored
3
packages/ui/src/services/admin/typings.d.ts
vendored
@ -1791,6 +1791,8 @@ declare namespace API {
|
||||
trial_subscribe: number;
|
||||
trial_time: number;
|
||||
trial_time_unit: string;
|
||||
enable_trial_email_whitelist: boolean;
|
||||
trial_email_domain_whitelist: string;
|
||||
enable_ip_register_limit: boolean;
|
||||
ip_register_limit: number;
|
||||
ip_register_limit_duration: number;
|
||||
@ -2076,6 +2078,7 @@ declare namespace API {
|
||||
type SubscribeDiscount = {
|
||||
quantity: number;
|
||||
discount: number;
|
||||
map_apple?: string;
|
||||
};
|
||||
|
||||
type SubscribeGroup = {
|
||||
|
||||
1
packages/ui/src/services/common/typings.d.ts
vendored
1
packages/ui/src/services/common/typings.d.ts
vendored
@ -909,6 +909,7 @@ declare namespace API {
|
||||
type SubscribeDiscount = {
|
||||
quantity: number;
|
||||
discount: number;
|
||||
map_apple?: string;
|
||||
};
|
||||
|
||||
type SubscribeGroup = {
|
||||
|
||||
1
packages/ui/src/services/user/typings.d.ts
vendored
1
packages/ui/src/services/user/typings.d.ts
vendored
@ -1003,6 +1003,7 @@ declare namespace API {
|
||||
type SubscribeDiscount = {
|
||||
quantity: number;
|
||||
discount: number;
|
||||
map_apple?: string;
|
||||
};
|
||||
|
||||
type SubscribeGroup = {
|
||||
|
||||
@ -31,3 +31,21 @@ export function shortenDeviceIdentifier(identifier: string): string {
|
||||
.slice(0, 8)
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 User-Agent 字符串解析设备平台类型
|
||||
* 与后端 parseDeviceType 逻辑保持一致
|
||||
* @param userAgent - 设备的 User-Agent 字符串
|
||||
* @returns 设备平台类型字符串,如 "iPhone"、"Android" 等,未识别时返回空字符串
|
||||
*/
|
||||
export function parseDeviceType(userAgent: string): string {
|
||||
if (!userAgent) return "";
|
||||
const ua = userAgent.toLowerCase();
|
||||
if (ua.includes("iphone")) return "iPhone";
|
||||
if (ua.includes("ipad")) return "iPad";
|
||||
if (ua.includes("android")) return "Android";
|
||||
if (ua.includes("windows")) return "Windows";
|
||||
if (ua.includes("macintosh") || ua.includes("mac os")) return "Mac";
|
||||
if (ua.includes("linux")) return "Linux";
|
||||
return "";
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user