- 新增 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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user