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
@@ -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")}
@@ -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>
);
}
+68 -24
View File
@@ -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}>