feat: 节点/服务器批量编辑 Sheet、Server 表单协议合并修复、设备类型解析
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:
2026-05-01 05:17:01 -07:00
parent de87133061
commit 7400137b3c
19 changed files with 1422 additions and 142 deletions
@@ -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>
);
}