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
+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;
})}
>