7400137b3c
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>
462 lines
16 KiB
TypeScript
462 lines
16 KiB
TypeScript
"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>
|
|
);
|
|
}
|