diff --git a/bun.lockb b/bun.lockb deleted file mode 100755 index ef833dd..0000000 Binary files a/bun.lockb and /dev/null differ diff --git a/components.json b/components.json index 4879b60..3151684 100644 --- a/components.json +++ b/components.json @@ -10,11 +10,13 @@ "cssVariables": true, "prefix": "" }, + "iconLibrary": "lucide", "aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" - } + }, + "registries": {} } diff --git a/package.json b/package.json index 4271765..0a57257 100644 --- a/package.json +++ b/package.json @@ -13,68 +13,72 @@ "preview": "vite preview" }, "dependencies": { - "@hookform/resolvers": "^3.10.0", - "@radix-ui/react-alert-dialog": "^1.1.5", - "@radix-ui/react-avatar": "^1.1.2", - "@radix-ui/react-checkbox": "^1.1.3", - "@radix-ui/react-dialog": "^1.1.5", - "@radix-ui/react-dropdown-menu": "^2.1.5", - "@radix-ui/react-label": "^2.1.1", - "@radix-ui/react-navigation-menu": "^1.2.4", - "@radix-ui/react-popover": "^1.1.5", - "@radix-ui/react-scroll-area": "^1.2.2", - "@radix-ui/react-select": "^2.1.5", - "@radix-ui/react-separator": "^1.1.1", - "@radix-ui/react-slot": "^1.1.1", - "@radix-ui/react-tabs": "^1.1.2", - "@tanstack/react-table": "^8.20.6", + "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-navigation-menu": "^1.2.14", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@tailwindcss/postcss": "^4.1.13", + "@tanstack/react-table": "^8.21.3", "@trivago/prettier-plugin-sort-imports": "^5.2.2", - "@types/luxon": "^3.4.2", + "@types/luxon": "^3.7.1", "@xterm/addon-attach": "^0.11.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "cmdk": "^1.0.4", + "cmdk": "^1.1.1", "copy-to-clipboard": "^3.3.3", - "framer-motion": "^11.18.2", - "i18next": "^24.2.2", - "i18next-browser-languagedetector": "^8.0.2", + "date-fns": "^4.1.0", + "framer-motion": "^12.23.22", + "i18next": "^25.5.2", + "i18next-browser-languagedetector": "^8.2.0", "jotai-zustand": "^0.6.0", - "lucide-react": "^0.454.0", - "luxon": "^3.5.0", + "lucide-react": "^0.544.0", + "luxon": "^3.7.2", "next-themes": "^0.4.6", - "prettier-plugin-tailwindcss": "^0.6.11", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "react-hook-form": "^7.54.2", - "react-i18next": "^15.4.0", - "react-router-dom": "^7.1.5", - "react-virtuoso": "^4.12.3", - "sonner": "^1.7.4", - "swr": "^2.3.0", - "tailwind-merge": "^2.6.0", + "prettier-plugin-tailwindcss": "^0.6.14", + "react": "^19.1.1", + "react-day-picker": "^9.11.0", + "react-dom": "^19.1.1", + "react-hook-form": "^7.63.0", + "react-i18next": "^16.0.0", + "react-router-dom": "^7.9.3", + "react-virtuoso": "^4.14.1", + "sonner": "^2.0.7", + "swr": "^2.3.6", + "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", "vaul": "^1.1.2", - "zod": "^3.24.1", - "zustand": "^5.0.3" + "zod": "^4.1.11", + "zustand": "^5.0.8" }, "devDependencies": { - "@eslint/js": "^9.19.0", - "@types/node": "^22.13.0", - "@types/react": "^18.3.18", - "@types/react-dom": "^18.3.5", - "@vitejs/plugin-react": "^4.3.4", - "autoprefixer": "^10.4.20", - "eslint": "^9.19.0", - "eslint-plugin-react-hooks": "^5.1.0", - "eslint-plugin-react-refresh": "^0.4.18", - "globals": "^15.14.0", - "postcss": "^8.5.1", - "swagger-typescript-api": "^13.0.23", - "tailwindcss": "^3.4.17", - "typescript": "~5.6.3", - "typescript-eslint": "^8.22.0", - "vite": "^6.0.11" + "@eslint/js": "^9.36.0", + "@types/node": "^24.5.2", + "@types/react": "^19.1.15", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.4", + "autoprefixer": "^10.4.21", + "eslint": "^9.36.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.22", + "globals": "^16.4.0", + "postcss": "^8.5.6", + "swagger-typescript-api": "^13.2.13", + "tailwindcss": "^4.1.13", + "typescript": "~5.9.2", + "typescript-eslint": "^8.44.1", + "vite": "^7.1.7" } } diff --git a/postcss.config.js b/postcss.config.js deleted file mode 100644 index d41ad63..0000000 --- a/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..521f73c --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + }, +} diff --git a/src/components/action-button-group.tsx b/src/components/action-button-group.tsx index b1a5e98..09d794d 100644 --- a/src/components/action-button-group.tsx +++ b/src/components/action-button-group.tsx @@ -49,7 +49,7 @@ export function ActionButtonGroup({ {children} - + @@ -61,7 +61,10 @@ export function ActionButtonGroup({ {t("Close")} {t("Confirm")} @@ -95,7 +98,7 @@ export function BlockButtonGroup({ {children} - + @@ -107,7 +110,10 @@ export function BlockButtonGroup({ {t("Close")} {t("Confirm")} diff --git a/src/components/alert-rule.tsx b/src/components/alert-rule.tsx index e86e53d..468800d 100644 --- a/src/components/alert-rule.tsx +++ b/src/components/alert-rule.tsx @@ -32,11 +32,10 @@ import { import { IconButton } from "@/components/xui/icon-button" import { useNotification } from "@/hooks/useNotfication" import { conv } from "@/lib/utils" -import { asOptionalField } from "@/lib/utils" import { ModelAlertRule } from "@/types" import { triggerModes } from "@/types" import { zodResolver } from "@hookform/resolvers/zod" -import { useState } from "react" +import { useEffect, useState } from "react" import { useForm } from "react-hook-form" import { useTranslation } from "react-i18next" import { toast } from "sonner" @@ -53,16 +52,16 @@ interface AlertRuleCardProps { const ruleSchema = z.object({ type: z.string(), - min: asOptionalField(z.number()), - max: asOptionalField(z.number()), - cycle_start: asOptionalField(z.string()), - cycle_interval: asOptionalField(z.number()), - cycle_unit: asOptionalField(z.enum(["hour", "day", "week", "month", "year"])), - duration: asOptionalField(z.number()), + min: z.number().optional(), + max: z.number().optional(), + cycle_start: z.string().optional(), + cycle_interval: z.number().optional(), + cycle_unit: z.enum(["hour", "day", "week", "month", "year"]).optional(), + duration: z.number().optional(), cover: z.number().int().min(0), - ignore: asOptionalField(z.record(z.boolean())), - next_transfer_at: asOptionalField(z.record(z.string())), - last_cycle_status: asOptionalField(z.boolean()), + ignore: z.record(z.string(), z.boolean()).optional(), + next_transfer_at: z.record(z.string(), z.string()).optional(), + last_cycle_status: z.boolean().optional(), }) const alertRuleFormSchema = z.object({ @@ -87,13 +86,16 @@ const alertRuleFormSchema = z.object({ recover_trigger_tasks_raw: z.string(), notification_group_id: z.coerce.number().int(), trigger_mode: z.coerce.number().int().min(0), - enable: asOptionalField(z.boolean()), + enable: z.boolean().optional(), }) export const AlertRuleCard: React.FC = ({ data, mutate }) => { const { t } = useTranslation() - const form = useForm>({ - resolver: zodResolver(alertRuleFormSchema), + + type AlertRuleFormData = z.infer + + const form = useForm({ + resolver: zodResolver(alertRuleFormSchema) as any, defaultValues: data ? { ...data, @@ -119,7 +121,28 @@ export const AlertRuleCard: React.FC = ({ data, mutate }) => const [open, setOpen] = useState(false) - const onSubmit = async (values: z.infer) => { + // 结构化规则编辑状态:从已有数据或 rules_raw 初始化 + const initialRules = (() => { + try { + if (data?.rules) return data.rules as any[] + const raw = form.getValues("rules_raw") + return raw ? JSON.parse(raw) : [] + } catch { + return [] + } + })() + const [rulesUI, setRulesUI] = useState(initialRules) + + // 同步到 rules_raw(提交仍走 JSON 字符串) + useEffect(() => { + try { + form.setValue("rules_raw", JSON.stringify(rulesUI), { shouldDirty: true }) + } catch { + // ignore + } + }, [rulesUI]) + + const onSubmit = async (values: AlertRuleFormData) => { values.rules = JSON.parse(values.rules_raw) values.fail_trigger_tasks = conv.strToArr(values.fail_trigger_tasks_raw).map(Number) values.recover_trigger_tasks = conv.strToArr(values.recover_trigger_tasks_raw).map(Number) @@ -161,7 +184,10 @@ export const AlertRuleCard: React.FC = ({ data, mutate }) =>
- + = ({ data, mutate }) => )} /> - ( - - {t("Rules")} - - + = ({ server {t("Cancel")} - diff --git a/src/components/cron.tsx b/src/components/cron.tsx index df811f0..aa8680d 100644 --- a/src/components/cron.tsx +++ b/src/components/cron.tsx @@ -63,18 +63,31 @@ const cronFormSchema = z.object({ notification_group_id: z.coerce.number().int(), }) +type CronFormData = z.infer + export const CronCard: React.FC = ({ data, mutate }) => { const { t } = useTranslation() - const form = useForm>({ - resolver: zodResolver(cronFormSchema), + const form = useForm({ + resolver: zodResolver(cronFormSchema as any), defaultValues: data - ? data + ? { + task_type: data.task_type ?? 0, + name: data.name ?? "", + scheduler: data.scheduler ?? "", + command: (data as any).command ?? "", + servers: data.servers ?? [], + cover: data.cover ?? 0, + push_successful: (data as any).push_successful ?? false, + notification_group_id: data.notification_group_id ?? 0, + } : { - name: "", task_type: 0, + name: "", scheduler: "", + command: "", servers: [], cover: 0, + push_successful: false, notification_group_id: 0, }, resetOptions: { @@ -84,7 +97,7 @@ export const CronCard: React.FC = ({ data, mutate }) => { const [open, setOpen] = useState(false) - const onSubmit = async (values: z.infer) => { + const onSubmit = async (values: CronFormData) => { try { data?.id ? await updateCron(data.id, values) : await createCron(values) } catch (e) { diff --git a/src/components/ddns.tsx b/src/components/ddns.tsx index 3d56180..3bea2bd 100644 --- a/src/components/ddns.tsx +++ b/src/components/ddns.tsx @@ -67,21 +67,44 @@ const ddnsFormSchema = z.object({ webhook_headers: asOptionalField(z.string()), }) +type DDNSFormData = z.infer + export const DDNSCard: React.FC = ({ data, providers, mutate }) => { const { t } = useTranslation() - const form = useForm>({ - resolver: zodResolver(ddnsFormSchema), + const form = useForm({ + resolver: zodResolver(ddnsFormSchema as any), defaultValues: data ? { - ...data, - domains_raw: conv.arrToStr(data.domains), + max_retries: data.max_retries ?? 3, + enable_ipv4: (data as any).enable_ipv4 ?? false, + enable_ipv6: (data as any).enable_ipv6 ?? false, + name: data.name ?? "", + provider: data.provider ?? "dummy", + domains: data.domains ?? [], + domains_raw: conv.arrToStr(data.domains ?? []), + access_id: (data as any).access_id ?? "", + access_secret: (data as any).access_secret ?? "", + webhook_url: (data as any).webhook_url ?? "", + webhook_method: (data as any).webhook_method, + webhook_request_type: (data as any).webhook_request_type, + webhook_request_body: (data as any).webhook_request_body ?? "", + webhook_headers: (data as any).webhook_headers ?? "", } : { max_retries: 3, + enable_ipv4: false, + enable_ipv6: false, name: "", provider: "dummy", domains: [], domains_raw: "", + access_id: "", + access_secret: "", + webhook_url: "", + webhook_method: undefined, + webhook_request_type: undefined, + webhook_request_body: "", + webhook_headers: "", }, resetOptions: { keepDefaultValues: false, @@ -90,7 +113,7 @@ export const DDNSCard: React.FC = ({ data, providers, mutate }) = const [open, setOpen] = useState(false) - const onSubmit = async (values: z.infer) => { + const onSubmit = async (values: DDNSFormData) => { try { values.domains = conv.strToArr(values.domains_raw) data?.id ? await updateDDNSProfile(data.id, values) : await createDDNSProfile(values) diff --git a/src/components/fm.tsx b/src/components/fm.tsx index c9dc2d1..c1555b3 100644 --- a/src/components/fm.tsx +++ b/src/components/fm.tsx @@ -37,7 +37,7 @@ import { import { ColumnDef } from "@tanstack/react-table" import { Row, flexRender } from "@tanstack/react-table" import { File, Folder } from "lucide-react" -import { HTMLAttributes, useEffect, useRef, useState } from "react" +import { HTMLAttributes, JSX, useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" diff --git a/src/components/header-button-group.tsx b/src/components/header-button-group.tsx index b60bd93..ca51997 100644 --- a/src/components/header-button-group.tsx +++ b/src/components/header-button-group.tsx @@ -51,6 +51,7 @@ export function HeaderButtonGroup({ { toast(t("Error"), { description: t("Results.NoRowsAreSelected"), @@ -63,7 +64,7 @@ export function HeaderButtonGroup({ <> - + @@ -75,7 +76,10 @@ export function HeaderButtonGroup({ {t("Close")} {t("Confirm")} @@ -114,6 +118,7 @@ export function HeaderBlockButtonGroup({ { toast(t("Error"), { description: t("Results.NoRowsAreSelected"), @@ -126,7 +131,7 @@ export function HeaderBlockButtonGroup({ <> - + @@ -138,7 +143,10 @@ export function HeaderBlockButtonGroup({ {t("Close")} {t("Confirm")} diff --git a/src/components/header.tsx b/src/components/header.tsx index 774ef5e..bbd6a68 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -403,7 +403,7 @@ function Overview() { {!profile &&

{t("LoginFirst")}

}

{t("CurrentTime")}

-

{timeString}

+

{timeString}

) diff --git a/src/components/install-commands.tsx b/src/components/install-commands.tsx index 4b9a432..3df734b 100644 --- a/src/components/install-commands.tsx +++ b/src/components/install-commands.tsx @@ -10,7 +10,7 @@ import useSettings from "@/hooks/useSetting" import { copyToClipboard } from "@/lib/utils" import { ModelProfile, ModelSetting } from "@/types" import i18next from "i18next" -import { Check, Clipboard } from "lucide-react" +import { Check, Copy, Download } from "lucide-react" import { forwardRef, useState } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" @@ -21,82 +21,137 @@ enum OSTypes { Windows, } -export const InstallCommandsMenu = forwardRef((props, ref) => { - const [copy, setCopy] = useState(false) - const { data: settings } = useSettings() - const { profile } = useAuth() +type InstallCommandsMenuProps = ButtonProps & { + uuid?: string + iconOnly?: boolean + menuItem?: boolean +} - const { t } = useTranslation() +export const InstallCommandsMenu = forwardRef( + ({ uuid, iconOnly = false, menuItem = false, ...props }, ref) => { + const [copy, setCopy] = useState(false) + const { data: settings } = useSettings() + const { profile } = useAuth() - const switchState = async (type: number) => { - if (!copy) { - try { - setCopy(true) - if (!profile) throw new Error("Profile is not found.") - if (!settings?.config) throw new Error("Settings is not found.") - await copyToClipboard(generateCommand(type, settings!.config, profile) || "") - } catch (e: Error | any) { - console.error(e) - toast(t("Error"), { - description: e.message, - }) - } finally { - setTimeout(() => { - setCopy(false) - }, 2 * 1000) + const { t } = useTranslation() + + const switchState = async (type: number) => { + if (!copy) { + try { + setCopy(true) + if (!profile) throw new Error("Profile is not found.") + if (!settings?.config) throw new Error("Settings is not found.") + await copyToClipboard( + generateCommand(type, settings!.config, profile, uuid) || "", + ) + } catch (e: Error | any) { + console.error(e) + toast(t("Error"), { + description: e.message, + }) + } finally { + setTimeout(() => { + setCopy(false) + }, 2 * 1000) + } } } - } - return ( - - - - - - { - switchState(OSTypes.Linux) - }} + return ( + + + {menuItem ? ( + + ) : iconOnly ? ( + + ) : ( + + )} + + - Linux - - { - switchState(OSTypes.macOS) - }} - > - macOS - - { - switchState(OSTypes.Windows) - }} - > - Windows - - - - ) -}) + { + switchState(OSTypes.Linux) + }} + > + Linux + + { + switchState(OSTypes.macOS) + }} + > + macOS + + { + switchState(OSTypes.Windows) + }} + > + Windows + + + + ) + }, +) const generateCommand = ( type: number, { install_host, tls }: ModelSetting, { agent_secret }: ModelProfile, + uuid?: string, ) => { if (!install_host) throw new Error(i18next.t("Results.InstallHostRequired")) if (!agent_secret) throw new Error(i18next.t("Results.AgentSecretRequired")) - const env = `NZ_SERVER=${install_host} NZ_TLS=${tls || false} NZ_CLIENT_SECRET=${agent_secret}` - const env_win = `$env:NZ_SERVER=\"${install_host}\";$env:NZ_TLS=\"${tls || false}\";$env:NZ_CLIENT_SECRET=\"${agent_secret}\";` + const envParts = [ + `NZ_SERVER=${install_host}`, + `NZ_TLS=${tls || false}`, + `NZ_CLIENT_SECRET=${agent_secret}`, + ] + if (uuid) envParts.push(`NZ_UUID=${uuid}`) + const env = envParts.join(" ") + + const envWinParts = [ + `$env:NZ_SERVER=\"${install_host}\";`, + `$env:NZ_TLS=\"${tls || false}\";`, + `$env:NZ_CLIENT_SECRET=\"${agent_secret}\";`, + ] + if (uuid) envWinParts.push(`$env:NZ_UUID=\"${uuid}\";`) + const env_win = envWinParts.join("") switch (type) { case OSTypes.Linux: diff --git a/src/components/nat.tsx b/src/components/nat.tsx index f82677f..6bbab76 100644 --- a/src/components/nat.tsx +++ b/src/components/nat.tsx @@ -46,12 +46,20 @@ const natFormSchema = z.object({ domain: z.string(), }) +type NatFormData = z.infer + export const NATCard: React.FC = ({ data, mutate }) => { const { t } = useTranslation() - const form = useForm>({ - resolver: zodResolver(natFormSchema), + const form = useForm({ + resolver: zodResolver(natFormSchema as any), defaultValues: data - ? data + ? { + name: data.name ?? "", + enabled: (data as any).enabled ?? false, + server_id: data.server_id ?? 0, + host: data.host ?? "", + domain: data.domain ?? "", + } : { name: "", enabled: false, @@ -66,7 +74,7 @@ export const NATCard: React.FC = ({ data, mutate }) => { const [open, setOpen] = useState(false) - const onSubmit = async (values: z.infer) => { + const onSubmit = async (values: NatFormData) => { try { data?.id ? await updateNAT(data.id, values) : await createNAT(values) } catch (e) { diff --git a/src/components/notifier.tsx b/src/components/notifier.tsx index 2b60583..af3b93d 100644 --- a/src/components/notifier.tsx +++ b/src/components/notifier.tsx @@ -61,10 +61,21 @@ const notificationFormSchema = z.object({ export const NotifierCard: React.FC = ({ data, mutate }) => { const { t } = useTranslation() - const form = useForm>({ - resolver: zodResolver(notificationFormSchema), + type notificationFormData = z.infer + + const form = useForm({ + resolver: zodResolver(notificationFormSchema) as any, defaultValues: data - ? data + ? { + name: data.name ?? "", + url: data.url ?? "", + request_method: data.request_method ?? 1, + request_type: data.request_type ?? 1, + request_header: data.request_header ?? "", + request_body: data.request_body ?? "", + verify_tls: (data as any).verify_tls ?? false, + skip_check: (data as any).skip_check ?? false, + } : { name: "", url: "", @@ -72,6 +83,8 @@ export const NotifierCard: React.FC = ({ data, mutate }) => { request_type: 1, request_header: "", request_body: "", + verify_tls: false, + skip_check: false, }, resetOptions: { keepDefaultValues: false, @@ -80,7 +93,7 @@ export const NotifierCard: React.FC = ({ data, mutate }) => { const [open, setOpen] = useState(false) - const onSubmit = async (values: z.infer) => { + const onSubmit = async (values: notificationFormData) => { try { data?.id ? await updateNotification(data.id, values) : await createNotification(values) } catch (e) { @@ -110,7 +123,10 @@ export const NotifierCard: React.FC = ({ data, mutate }) => { - + { const { t } = useTranslation() const { profile, setProfile } = useMainStore() - const form = useForm>({ - resolver: zodResolver(profileFormSchema), + const form = useForm({ + resolver: zodResolver(profileFormSchema) as any, defaultValues: { original_password: "", new_password: "", @@ -57,7 +57,7 @@ export const ProfileCard = ({ className }: { className: string }) => { const [open, setOpen] = useState(false) - const onSubmit = async (values: z.infer) => { + const onSubmit = async (values: any) => { try { await updateProfile(values) } catch (e) { diff --git a/src/components/server-config.tsx b/src/components/server-config.tsx index 7ef9dc6..d0fd738 100644 --- a/src/components/server-config.tsx +++ b/src/components/server-config.tsx @@ -27,6 +27,7 @@ import { IconButton } from "@/components/xui/icon-button" import { asOptionalField } from "@/lib/utils" import { ModelServerTaskResponse } from "@/types" import { zodResolver } from "@hookform/resolvers/zod" +import { CogIcon } from "lucide-react" import { useEffect, useState } from "react" import { useForm } from "react-hook-form" import { useTranslation } from "react-i18next" @@ -58,7 +59,7 @@ const agentConfigSchema = z.object({ ), ), ip_report_period: asOptionalField(z.coerce.number().int().min(30)), - nic_allowlist: asOptionalField(z.record(z.boolean())), + nic_allowlist: asOptionalField(z.record(z.string(), z.boolean())), nic_allowlist_raw: asOptionalField( z.string().refine( (val) => { @@ -102,9 +103,10 @@ for (let i = 0; i < boolFields.length; i += 2) { interface ServerConfigCardProps extends ButtonProps { sid: number + menuItem?: boolean } -export const ServerConfigCard = ({ sid, ...props }: ServerConfigCardProps) => { +export const ServerConfigCard = ({ sid, menuItem = false, ...props }: ServerConfigCardProps) => { const { t } = useTranslation() const [data, setData] = useState(undefined) const [loading, setLoading] = useState(true) @@ -129,8 +131,8 @@ export const ServerConfigCard = ({ sid, ...props }: ServerConfigCardProps) => { if (open) fetchData() }, [open]) - const form = useForm({ - resolver: zodResolver(agentConfigSchema), + const form = useForm({ + resolver: zodResolver(agentConfigSchema) as any, defaultValues: { ...data, hard_drive_partition_allowlist_raw: JSON.stringify( @@ -155,7 +157,7 @@ export const ServerConfigCard = ({ sid, ...props }: ServerConfigCardProps) => { } }, [data, form]) - const onSubmit = async (values: AgentConfig) => { + const onSubmit = async (values: any) => { let resp: ModelServerTaskResponse = {} try { values.nic_allowlist = values.nic_allowlist_raw @@ -186,7 +188,18 @@ export const ServerConfigCard = ({ sid, ...props }: ServerConfigCardProps) => { return ( - + {menuItem ? ( + + ) : ( + + )} {loading ? ( @@ -283,7 +296,7 @@ export const ServerConfigCard = ({ sid, ...props }: ServerConfigCardProps) => {
{ @@ -69,8 +79,8 @@ const serverFormSchema = z.object({ export const ServerCard: React.FC = ({ data, mutate }) => { const { t } = useTranslation() - const form = useForm>({ - resolver: zodResolver(serverFormSchema), + const form = useForm({ + resolver: zodResolver(serverFormSchema) as any, defaultValues: { ...data, ddns_profiles_raw: data.ddns_profiles ? conv.arrToStr(data.ddns_profiles) : undefined, @@ -85,7 +95,131 @@ export const ServerCard: React.FC = ({ data, mutate }) => { const [open, setOpen] = useState(false) - const onSubmit = async (values: z.infer) => { + type PublicNote = { + billingDataMod: { + startDate: string + endDate: string + autoRenewal: string + cycle: string + amount: string + } + planDataMod: { + bandwidth: string + trafficVol: string + trafficType: string + IPv4: string + IPv6: string + networkRoute: string + extra: string + } + } + + const defaultPublicNote: PublicNote = { + billingDataMod: { + startDate: "", + endDate: "", + autoRenewal: "", + cycle: "", + amount: "", + }, + planDataMod: { + bandwidth: "", + trafficVol: "", + trafficType: "", + IPv4: "0", + IPv6: "0", + networkRoute: "", + extra: "", + }, + } + + const parsePublicNote = (s?: string): PublicNote => { + if (!s) return defaultPublicNote + try { + const obj = JSON.parse(s) + return { + billingDataMod: { + startDate: obj?.billingDataMod?.startDate ?? "", + endDate: obj?.billingDataMod?.endDate ?? "", + autoRenewal: obj?.billingDataMod?.autoRenewal ?? "", + cycle: obj?.billingDataMod?.cycle ?? "", + amount: obj?.billingDataMod?.amount ?? "", + }, + planDataMod: { + bandwidth: obj?.planDataMod?.bandwidth ?? "", + trafficVol: obj?.planDataMod?.trafficVol ?? "", + trafficType: obj?.planDataMod?.trafficType ?? "", + IPv4: obj?.planDataMod?.IPv4 === "1" ? "1" : "0", + IPv6: obj?.planDataMod?.IPv6 === "1" ? "1" : "0", + networkRoute: obj?.planDataMod?.networkRoute ?? "", + extra: obj?.planDataMod?.extra ?? "", + }, + } + } catch { + return defaultPublicNote + } + } + + const [publicNoteObj, setPublicNoteObj] = useState( + parsePublicNote(data?.public_note), + ) + const [publicNoteErrors, setPublicNoteErrors] = useState< + Partial< + Record< + | "billing.startDate" + | "billing.endDate" + | "billing.autoRenewal" + | "billing.cycle" + | "billing.amount" + | "plan.bandwidth" + | "plan.trafficVol" + | "plan.trafficType" + | "plan.IPv4" + | "plan.IPv6" + | "plan.extra", + string + > + > + >({}) + + const isValidISOLike = (v: string) => { + if (!v) return true + // special marker for "no expiry" + if (v === "0000-00-00T23:59:59+08:00") return true + const d = new Date(v) + return !isNaN(d.getTime()) + } + + const validatePublicNote = (pn: PublicNote) => { + const errs: Partial> = {} + + if (pn.billingDataMod.startDate && !isValidISOLike(pn.billingDataMod.startDate)) { + errs["billing.startDate"] = t("Validation.InvalidDate") + } + if (pn.billingDataMod.endDate && !isValidISOLike(pn.billingDataMod.endDate)) { + errs["billing.endDate"] = t("Validation.InvalidDate") + } + if (pn.billingDataMod.autoRenewal && !/^(0|1)$/.test(pn.billingDataMod.autoRenewal)) { + errs["billing.autoRenewal"] = t("Validation.MustBe0Or1") + } + if (pn.billingDataMod.cycle && !/^(Day|Week|Month|Year)$/i.test(pn.billingDataMod.cycle)) { + errs["billing.cycle"] = t("Validation.MustBeDayWeekMonthYear") + } + // amount 允许任意非空字符串或空 + if (pn.planDataMod.trafficType && !/^(1|2)$/.test(pn.planDataMod.trafficType)) { + errs["plan.trafficType"] = t("Validation.MustBe1Or2") + } + if (!/^(0|1)$/.test(pn.planDataMod.IPv4)) { + errs["plan.IPv4"] = t("Validation.MustBe0Or1") + } + if (!/^(0|1)$/.test(pn.planDataMod.IPv6)) { + errs["plan.IPv6"] = t("Validation.MustBe0Or1") + } + + return { errors: errs, valid: Object.keys(errs).length === 0 } + } + + const onSubmit = async (values: any) => { try { values.ddns_profiles = values.ddns_profiles_raw ? conv.strToArr(values.ddns_profiles_raw).map(Number) @@ -93,6 +227,36 @@ export const ServerCard: React.FC = ({ data, mutate }) => { values.override_ddns_domains = values.override_ddns_domains_raw ? JSON.parse(values.override_ddns_domains_raw) : undefined + + // validate structured fields + const { errors, valid } = validatePublicNote(publicNoteObj) + if (!valid) { + setPublicNoteErrors(errors) + toast(t("Error"), { description: t("Validation.InvalidForm") }) + return + } + setPublicNoteErrors({}) + + // normalize datetime-local to ISO string if provided + const normalizeISO = (v: string) => { + if (!v) return v + // keep special "no expiry" value as-is + if (v === "0000-00-00T23:59:59+08:00") return v + const date = new Date(v) + return isNaN(date.getTime()) ? v : date.toISOString() + } + const pnNormalized: PublicNote = { + billingDataMod: { + ...publicNoteObj.billingDataMod, + startDate: normalizeISO(publicNoteObj.billingDataMod.startDate), + endDate: normalizeISO(publicNoteObj.billingDataMod.endDate), + // keep others as-is + }, + planDataMod: { ...publicNoteObj.planDataMod }, + } + + // serialize structured public note back to JSON string + values.public_note = JSON.stringify(pnNormalized) await updateServer(data!.id!, values) } catch (e) { console.error(e) @@ -111,7 +275,11 @@ export const ServerCard: React.FC = ({ data, mutate }) => { - + e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + >
@@ -236,19 +404,527 @@ export const ServerCard: React.FC = ({ data, mutate }) => { )} /> - ( - - {t("Public") + t("Note")} - -