fix: resolve domain.ts type errors and SWR fetching bugs; fix vps config unmarshal issue

This commit is contained in:
Bot
2026-04-26 22:07:27 +08:00
parent 825bcb08f4
commit 1e4fae5306
16 changed files with 2513 additions and 2057 deletions
+1314 -1175
View File
File diff suppressed because it is too large Load Diff
+22 -13
View File
@@ -1,44 +1,53 @@
import { fetcher, FetcherMethod, swrFetcher } from './api' // 导入正确的 fetcher 函数和方法枚举 // 导入正确的 fetcher 函数和方法枚举
import type { Domain, BillingDataMod} from '@/types/api' import type { BillingDataMod, Domain } from "@/types/domain"
import { FetcherMethod, fetcher } from "./api"
// --- GET 请求 (用于 SWR) --- // --- GET 请求 (用于 SWR) ---
// 获取域名列表的函数,专门为 useSWR 设计 // 获取域名列表的函数,专门为 useSWR 设计
// swrFetcher 内部会调用 fetcher,这一部分是正确的 // swrFetcher 内部会调用 fetcher,这一部分是正确的
export const useDomainList = () => { export const useDomainList = (url: string) => {
return swrFetcher<Domain[]>('/api/v1/domains') return fetcher<Domain[]>(FetcherMethod.GET, url)
} }
// --- POST, PUT, DELETE 请求 (使用 fetcher) --- // --- POST, PUT, DELETE 请求 (使用 fetcher) ---
// 添加一个新的域名 // 添加一个新的域名
export const addDomain = (domain: string) => { export const addDomain = (domain: string) => {
return fetcher<Domain>(FetcherMethod.POST, '/api/v1/domains', { domain }) return fetcher<Domain>(FetcherMethod.POST, "/api/v1/domains", { domain })
} }
// 触发域名验证 // 触发域名验证
export const verifyDomain = (id: number) => { export const verifyDomain = (id: number) => {
return fetcher<{ success: boolean; message: string }>(FetcherMethod.POST, `/api/v1/domains/${id}/verify`) return fetcher<{ success: boolean; message: string }>(
FetcherMethod.POST,
`/api/v1/domains/${id}/verify`,
)
} }
// 更新域名的配置信息 // 更新域名的配置信息
export const updateDomainConfig = (id: number, billingData: BillingDataMod) => { export const updateDomainConfig = (id: number, billingData: BillingDataMod) => {
return fetcher<Domain>(FetcherMethod.PUT, `/api/v1/domains/${id}`, { billing_data: billingData }) return fetcher<Domain>(FetcherMethod.PUT, `/api/v1/domains/${id}`, {
billing_data: billingData,
})
} }
// 删除一个域名 // 删除一个域名
export const deleteDomain = (id: number) => { export const deleteDomain = (id: number) => {
// DELETE 请求通常没有响应体,所以 T 可以是 any 或 unknown // DELETE 请求通常没有响应体,所以 T 可以是 any 或 unknown
return fetcher<any>(FetcherMethod.DELETE, `/api/v1/domains/${id}`) return fetcher<any>(FetcherMethod.DELETE, `/api/v1/domains/${id}`)
} }
// 更新一个域名(包括公开状态和配置信息) // 更新一个域名(包括公开状态和配置信息)
export const updateDomain = (id: number, data: { is_public: boolean, billing_data: BillingDataMod }) => { export const updateDomain = (
id: number,
data: { is_public: boolean; billing_data: BillingDataMod },
) => {
return fetcher<Domain>(FetcherMethod.PUT, `/api/v1/domains/${id}`, data) return fetcher<Domain>(FetcherMethod.PUT, `/api/v1/domains/${id}`, data)
} }
// 同步 Whois 信息 // 同步 Whois 信息
export const syncDomainWHOIS = (id: number) => { export const syncDomainWHOIS = (id: number) => {
return fetcher<Domain>(FetcherMethod.POST, `/api/v1/domains/${id}/sync`) return fetcher<Domain>(FetcherMethod.POST, `/api/v1/domains/${id}/sync`)
} }
+15 -17
View File
@@ -1,4 +1,6 @@
import { ModeToggle } from "@/components/mode-toggle" import { ModeToggle } from "@/components/mode-toggle"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { import {
Drawer, Drawer,
DrawerClose, DrawerClose,
@@ -9,12 +11,24 @@ import {
DrawerTitle, DrawerTitle,
DrawerTrigger, DrawerTrigger,
} from "@/components/ui/drawer" } from "@/components/ui/drawer"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { import {
NavigationMenu, NavigationMenu,
NavigationMenuItem, NavigationMenuItem,
NavigationMenuLink, NavigationMenuLink,
navigationMenuTriggerStyle, navigationMenuTriggerStyle,
} from "@/components/ui/navigation-menu" } from "@/components/ui/navigation-menu"
import { IconButton } from "@/components/xui/icon-button"
import { NzNavigationMenuLink } from "@/components/xui/navigation-menu"
import { useAuth } from "@/hooks/useAuth" import { useAuth } from "@/hooks/useAuth"
import { useMainStore } from "@/hooks/useMainStore" import { useMainStore } from "@/hooks/useMainStore"
import { useMediaQuery } from "@/hooks/useMediaQuery" import { useMediaQuery } from "@/hooks/useMediaQuery"
@@ -26,21 +40,6 @@ import { useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Link, useLocation, useNavigate } from "react-router-dom" import { Link, useLocation, useNavigate } from "react-router-dom"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { IconButton } from "@/components/xui/icon-button"
import { NzNavigationMenuLink } from "@/components/xui/navigation-menu"
// ======================================================= // =======================================================
// vvvvvvvvvvv 1. 在这里为移动端菜单添加新页面 vvvvvvvvvvv // vvvvvvvvvvv 1. 在这里为移动端菜单添加新页面 vvvvvvvvvvv
const pages = [ const pages = [
@@ -252,7 +251,7 @@ export default function Header() {
</NavigationMenuItem> </NavigationMenuItem>
{/* ^^^^^^^^^^^ 2. 在这里为桌面端菜单添加新链接 ^^^^^^^^^^^ */} {/* ^^^^^^^^^^^ 2. 在这里为桌面端菜单添加新链接 ^^^^^^^^^^^ */}
{/* ======================================================= */} {/* ======================================================= */}
<NavigationMenuItem> <NavigationMenuItem>
<NzNavigationMenuLink <NzNavigationMenuLink
asChild asChild
@@ -428,4 +427,3 @@ function Overview() {
</section> </section>
) )
} }
+45 -15
View File
@@ -177,11 +177,21 @@ export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{form.watch("type") == 2 ? "SMTP Server (host:port)" : {form.watch("type") == 2
form.watch("type") == 3 ? "Bot Token" : "URL"} ? "SMTP Server (host:port)"
: form.watch("type") == 3
? "Bot Token"
: "URL"}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder={form.watch("type") == 3 ? "123456:ABC-DEF" : ""} /> <Input
{...field}
placeholder={
form.watch("type") == 3
? "123456:ABC-DEF"
: ""
}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -234,11 +244,13 @@ export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
{Object.entries(nrequestTypes).map(([k, v]) => ( {Object.entries(nrequestTypes).map(
<SelectItem key={k} value={k}> ([k, v]) => (
{v} <SelectItem key={k} value={k}>
</SelectItem> {v}
))} </SelectItem>
),
)}
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage /> <FormMessage />
@@ -253,15 +265,21 @@ export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{form.watch("type") == 2 ? "SMTP User:Pass" : {form.watch("type") == 2
form.watch("type") == 3 ? "Chat ID" : t("RequestHeader")} ? "SMTP User:Pass"
: form.watch("type") == 3
? "Chat ID"
: t("RequestHeader")}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Textarea <Textarea
className="resize-y" className="resize-y"
placeholder={ placeholder={
form.watch("type") == 2 ? "user:pass" : form.watch("type") == 2
form.watch("type") == 3 ? "123456789" : '{"User-Agent":"Nezha-Agent"}' ? "user:pass"
: form.watch("type") == 3
? "123456789"
: '{"User-Agent":"Nezha-Agent"}'
} }
{...field} {...field}
/> />
@@ -276,11 +294,23 @@ export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
name="request_body" name="request_body"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{form.watch("type") == 2 ? "Recipient Email" : t("RequestBody")}</FormLabel> <FormLabel>
{form.watch("type") == 2
? "Recipient Email"
: t("RequestBody")}
</FormLabel>
<FormControl> <FormControl>
<Textarea <Textarea
className={form.watch("type") == 2 ? "resize-y" : "resize-y h-[240px]"} className={
placeholder={form.watch("type") == 2 ? "target@example.com" : '...'} form.watch("type") == 2
? "resize-y"
: "resize-y h-[240px]"
}
placeholder={
form.watch("type") == 2
? "target@example.com"
: "..."
}
{...field} {...field}
/> />
</FormControl> </FormControl>
+10 -6
View File
@@ -160,13 +160,17 @@ export const ServerConfigCard = ({ sid, menuItem = false, ...props }: ServerConf
const onSubmit = async (values: any) => { const onSubmit = async (values: any) => {
let resp: ModelServerTaskResponse = {} let resp: ModelServerTaskResponse = {}
try { try {
values.nic_allowlist = values.nic_allowlist_raw const submitValues = { ...values }
? JSON.parse(values.nic_allowlist_raw) submitValues.nic_allowlist = submitValues.nic_allowlist_raw
? JSON.parse(submitValues.nic_allowlist_raw)
: undefined : undefined
values.hard_drive_partition_allowlist = values.hard_drive_partition_allowlist_raw submitValues.hard_drive_partition_allowlist =
? JSON.parse(values.hard_drive_partition_allowlist_raw) submitValues.hard_drive_partition_allowlist_raw
: undefined ? JSON.parse(submitValues.hard_drive_partition_allowlist_raw)
resp = await setServerConfig({ config: JSON.stringify(values), servers: [sid] }) : undefined
delete submitValues.nic_allowlist_raw
delete submitValues.hard_drive_partition_allowlist_raw
resp = await setServerConfig({ config: JSON.stringify(submitValues), servers: [sid] })
} catch (e) { } catch (e) {
console.error(e) console.error(e)
toast(t("Error"), { toast(t("Error"), {
+59 -29
View File
@@ -28,7 +28,7 @@ import { conv } from "@/lib/utils"
import { asOptionalField } from "@/lib/utils" import { asOptionalField } from "@/lib/utils"
import { ModelServer } from "@/types" import { ModelServer } from "@/types"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { useState, useEffect } from "react" import { useEffect, useState } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { toast } from "sonner" import { toast } from "sonner"
@@ -65,11 +65,13 @@ const serverFormSchema = z.object({
}, },
), ),
), ),
billing_data: z.object({ billing_data: z
registrar: asOptionalField(z.string()), .object({
endDate: asOptionalField(z.string()), registrar: asOptionalField(z.string()),
notes: asOptionalField(z.string()), endDate: asOptionalField(z.string()),
}).optional(), notes: asOptionalField(z.string()),
})
.optional(),
}) })
export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => { export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
@@ -92,16 +94,16 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
useEffect(() => { useEffect(() => {
const handleMessage = (e: MessageEvent) => { const handleMessage = (e: MessageEvent) => {
if (e.data?.type === 'NZCFG_JSON') { if (e.data?.type === "NZCFG_JSON") {
if (e.data.target === 'public_note') { if (e.data.target === "public_note") {
form.setValue('public_note', e.data.payload); form.setValue("public_note", e.data.payload)
toast(t("Success"), { description: "配置已通过可视化构建器自动填入" }); toast(t("Success"), { description: "配置已通过可视化构建器自动填入" })
} }
} }
}; }
window.addEventListener('message', handleMessage); window.addEventListener("message", handleMessage)
return () => window.removeEventListener('message', handleMessage); return () => window.removeEventListener("message", handleMessage)
}, [form, t]); }, [form, t])
const onSubmit = async (values: any) => { const onSubmit = async (values: any) => {
try { try {
@@ -255,7 +257,9 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
)} )}
/> />
<div className="p-3 border rounded-md border-dashed space-y-2"> <div className="p-3 border rounded-md border-dashed space-y-2">
<Label className="text-xs text-muted-foreground uppercase font-bold">Billing & Expiry</Label> <Label className="text-xs text-muted-foreground uppercase font-bold">
Billing & Expiry
</Label>
<FormField <FormField
control={form.control as any} control={form.control as any}
name="billing_data.registrar" name="billing_data.registrar"
@@ -263,7 +267,10 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
<FormItem> <FormItem>
<FormLabel>Registrar</FormLabel> <FormLabel>Registrar</FormLabel>
<FormControl> <FormControl>
<Input placeholder="AWS / Azure /阿里云" {...field} /> <Input
placeholder="AWS / Azure /阿里云"
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -276,7 +283,20 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
<FormItem> <FormItem>
<FormLabel>Expiry Date</FormLabel> <FormLabel>Expiry Date</FormLabel>
<FormControl> <FormControl>
<Input type="date" {...field} value={field.value?.split('T')[0] || ''} onChange={(e) => field.onChange(e.target.value ? new Date(e.target.value).toISOString() : '')} /> <Input
type="date"
{...field}
value={field.value?.split("T")[0] || ""}
onChange={(e) =>
field.onChange(
e.target.value
? new Date(
e.target.value,
).toISOString()
: "",
)
}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -290,18 +310,28 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
<FormItem> <FormItem>
<FormLabel className="flex justify-between items-center w-full"> <FormLabel className="flex justify-between items-center w-full">
<span>{t("Public") + t("Note")}</span> <span>{t("Public") + t("Note")}</span>
<a href="/dashboard/nzcfg.html" target="_blank" className="text-blue-500 hover:text-blue-700 text-xs flex items-center gap-1" onClick={(e) => { <a
e.preventDefault(); href="/dashboard/nzcfg.html"
const popup = window.open('/dashboard/nzcfg.html', 'nzcfg', 'width=1000,height=800'); target="_blank"
if(popup) { className="text-blue-500 hover:text-blue-700 text-xs flex items-center gap-1"
const timer = setInterval(() => { onClick={(e) => {
if(popup.closed) { e.preventDefault()
clearInterval(timer); const popup = window.open(
} "/dashboard/nzcfg.html",
}, 500); "nzcfg",
} "width=1000,height=800",
}}> )
<i className="fa-solid fa-up-right-from-square"></i> if (popup) {
const timer = setInterval(() => {
if (popup.closed) {
clearInterval(timer)
}
}, 500)
}
}}
>
{" "}
<i className="fa-solid fa-up-right-from-square"></i>
</a> </a>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
+1 -2
View File
@@ -23,8 +23,7 @@ const badgeVariants = cva(
) )
export interface BadgeProps export interface BadgeProps
extends HTMLAttributes<HTMLDivElement>, extends HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) { function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} /> return <div className={cn(badgeVariants({ variant }), className)} {...props} />
+1 -2
View File
@@ -31,8 +31,7 @@ const buttonVariants = cva(
) )
export interface ButtonProps export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>, extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
VariantProps<typeof buttonVariants> {
asChild?: boolean asChild?: boolean
} }
+2 -1
View File
@@ -67,7 +67,8 @@ const multiSelectVariants = cva(
* Props for MultiSelect component * Props for MultiSelect component
*/ */
interface MultiSelectProps interface MultiSelectProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof multiSelectVariants> { VariantProps<typeof multiSelectVariants> {
/** /**
* An array of option objects to be displayed in the multi-select component. * An array of option objects to be displayed in the multi-select component.
+2 -1
View File
@@ -37,7 +37,8 @@ const sheetVariants = cva(
) )
interface SheetContentProps interface SheetContentProps
extends ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, extends
ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> { VariantProps<typeof sheetVariants> {
setOpen: Dispatch<SetStateAction<boolean>> setOpen: Dispatch<SetStateAction<boolean>>
} }
+440 -257
View File
@@ -1,284 +1,467 @@
// src/routes/domain.tsx (最终 Bug 修复版) // src/routes/domain.tsx (最终 Bug 修复版)
import {
import { useState, useEffect } from 'react' addDomain,
import { PlusCircle, RefreshCw, MoreVertical, Trash2, Edit, CheckCircle, RefreshCcw } from 'lucide-react' deleteDomain,
syncDomainWHOIS,
updateDomain,
useDomainList,
verifyDomain,
} from "@/api/domain"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
// 导入 shadcn/ui 组件 // 导入 shadcn/ui 组件
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import {
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' Dialog,
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' DialogContent,
import { Badge } from '@/components/ui/badge' DialogDescription,
import { Button } from '@/components/ui/button' DialogFooter,
import { Input } from '@/components/ui/input' DialogHeader,
import { Textarea } from '@/components/ui/textarea' DialogTitle,
import { Label } from '@/components/ui/label' DialogTrigger,
} from "@/components/ui/dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import {
import { toast } from 'sonner' Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Textarea } from "@/components/ui/textarea"
// 导入 API 类型和函数 // 导入 API 类型和函数
import type { Domain, BillingDataMod } from '@/types/api' import type { BillingDataMod, Domain } from "@/types/domain"
import { useDomainList, addDomain, verifyDomain, deleteDomain, updateDomain, syncDomainWHOIS } from '@/api/domain' import {
import useSWR from 'swr' CheckCircle,
Edit,
MoreVertical,
PlusCircle,
RefreshCcw,
RefreshCw,
Trash2,
} from "lucide-react"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import useSWR from "swr"
export default function DomainPage() { export default function DomainPage() {
// --- React State Hooks --- // --- React State Hooks ---
const [domains, setDomains] = useState<Domain[]>([]) const [domains, setDomains] = useState<Domain[]>([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [isAddModalOpen, setIsAddModalOpen] = useState(false)
const [newDomainName, setNewDomainName] = useState('')
const [verificationToken, setVerificationToken] = useState('')
const [isVerificationInfoModalOpen, setIsVerificationInfoModalOpen] = useState(false)
const [isEditModalOpen, setIsEditModalOpen] = useState(false) const [isAddModalOpen, setIsAddModalOpen] = useState(false)
const [currentDomain, setCurrentDomain] = useState<Domain | null>(null) const [newDomainName, setNewDomainName] = useState("")
const [editFormData, setEditFormData] = useState<Partial<BillingDataMod>>({})
// --- 数据获取 (使用 SWR) --- const [verificationToken, setVerificationToken] = useState("")
const { data: domainData, error, mutate } = useSWR('/api/v1/domains', useDomainList, { revalidateOnFocus: false }) const [isVerificationInfoModalOpen, setIsVerificationInfoModalOpen] = useState(false)
useEffect(() => { const [isEditModalOpen, setIsEditModalOpen] = useState(false)
if (domainData) { const [currentDomain, setCurrentDomain] = useState<Domain | null>(null)
setDomains(domainData) const [editFormData, setEditFormData] = useState<Partial<BillingDataMod>>({})
setIsLoading(false)
// --- 数据获取 (使用 SWR) ---
const {
data: domainData,
error,
mutate,
} = useSWR("/api/v1/domains", useDomainList, { revalidateOnFocus: false })
useEffect(() => {
if (domainData) {
setDomains(domainData)
setIsLoading(false)
}
if (error) {
toast.error("无法加载域名列表,请检查后端服务是否正常。")
setIsLoading(false)
}
}, [domainData, error])
const handleAddDomain = async () => {
if (!newDomainName) {
toast.error("请输入域名")
return
}
try {
const response = await addDomain(newDomainName)
setVerificationToken(response.VerifyToken)
setIsAddModalOpen(false)
setIsVerificationInfoModalOpen(true)
setNewDomainName("")
mutate()
} catch (err) {
toast.error("添加失败", { description: (err as Error).message })
}
} }
if (error) {
toast.error('无法加载域名列表,请检查后端服务是否正常。') const handleVerify = async (domainId: number) => {
setIsLoading(false) try {
const response = await verifyDomain(domainId)
if (response.success) {
toast.success("验证成功", { description: response.message })
} else {
toast.warning("验证失败", { description: response.message })
}
setTimeout(() => mutate(), 2000)
} catch (err) {
toast.error("操作失败", { description: (err as Error).message })
}
} }
}, [domainData, error])
const handleAddDomain = async () => { const handleSyncWhois = async (domainId: number) => {
if (!newDomainName) { const loadingToast = toast.loading("正在同步 Whois 信息...")
toast.error('请输入域名') try {
return await syncDomainWHOIS(domainId)
toast.success("同步成功", { id: loadingToast, description: "域名 Whois 信息已更新。" })
mutate()
} catch (err) {
toast.error("同步失败", { id: loadingToast, description: (err as Error).message })
}
} }
try {
const response = await addDomain(newDomainName) const handleDelete = async (domainId: number, domainName: string) => {
setVerificationToken(response.VerifyToken) if (window.confirm(`确定要删除域名 ${domainName} 吗?`)) {
setIsAddModalOpen(false) try {
setIsVerificationInfoModalOpen(true) await deleteDomain(domainId)
setNewDomainName('') toast.success("删除成功", { description: `域名 ${domainName} 已被删除。` })
mutate() mutate()
} catch (err) { } catch (err) {
toast.error('添加失败', { description: (err as Error).message }) toast.error("删除失败", { description: (err as Error).message })
}
}
} }
}
const handleVerify = async (domainId: number) => { const handlePublicToggle = async (domain: Domain) => {
try { try {
const response = await verifyDomain(domainId) await updateDomain(domain.ID, {
if (response.success) { is_public: !domain.IsPublic,
toast.success('验证成功', { description: response.message }) billing_data: domain.BillingData as BillingDataMod,
} else { })
toast.warning('验证失败', { description: response.message }) toast.success(`域名 ${domain.Domain} 的可见状态已更新`)
} mutate()
setTimeout(() => mutate(), 2000) } catch (err) {
} catch (err) { toast.error("更新失败", { description: (err as Error).message })
toast.error('操作失败', { description: (err as Error).message }) }
} }
}
const handleSyncWhois = async (domainId: number) => { const handleEditClick = (domain: Domain) => {
const loadingToast = toast.loading('正在同步 Whois 信息...') setCurrentDomain(domain)
try { setEditFormData(domain.BillingData || {})
await syncDomainWHOIS(domainId) setIsEditModalOpen(true)
toast.success('同步成功', { id: loadingToast, description: '域名 Whois 信息已更新。' })
mutate()
} catch (err) {
toast.error('同步失败', { id: loadingToast, description: (err as Error).message })
} }
}
const handleDelete = async (domainId: number, domainName: string) => { const handleEditFormChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
if (window.confirm(`确定要删除域名 ${domainName} 吗?`)) { setEditFormData({
try { ...editFormData,
await deleteDomain(domainId) [e.target.name]: e.target.value,
toast.success('删除成功', { description: `域名 ${domainName} 已被删除。` }) })
mutate()
} catch (err) {
toast.error('删除失败', { description: (err as Error).message })
}
} }
}
const handleUpdateDomain = async () => {
const handlePublicToggle = async (domain: Domain) => { if (!currentDomain) return
try { try {
await updateDomain(domain.ID, { const dataToSend = { ...editFormData }
is_public: !domain.IsPublic, if (dataToSend.registeredDate) {
billing_data: domain.BillingData as BillingDataMod, dataToSend.registeredDate = new Date(dataToSend.registeredDate).toISOString()
}) }
toast.success(`域名 ${domain.Domain} 的可见状态已更新`) if (dataToSend.endDate) {
mutate() dataToSend.endDate = new Date(dataToSend.endDate).toISOString()
} catch (err) { }
toast.error('更新失败', { description: (err as Error).message })
await updateDomain(currentDomain.ID, {
is_public: currentDomain.IsPublic,
billing_data: dataToSend as BillingDataMod,
})
toast.success("更新成功", {
description: `域名 ${currentDomain.Domain} 的配置已保存。`,
})
setIsEditModalOpen(false)
mutate()
} catch (err) {
toast.error("更新失败", { description: (err as Error).message })
}
} }
}
const handleEditClick = (domain: Domain) => { const getStatusVariant = (
setCurrentDomain(domain) status: string,
setEditFormData(domain.BillingData || {}) ): "default" | "secondary" | "destructive" | "outline" => {
setIsEditModalOpen(true) switch (status) {
} case "verified":
return "default"
const handleEditFormChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { case "pending":
setEditFormData({ return "secondary"
...editFormData, case "expired":
[e.target.name]: e.target.value, return "destructive"
}) default:
} return "outline"
}
const handleUpdateDomain = async () => {
if (!currentDomain) return
try {
const dataToSend = { ...editFormData };
if (dataToSend.registeredDate) {
dataToSend.registeredDate = new Date(dataToSend.registeredDate).toISOString();
}
if (dataToSend.endDate) {
dataToSend.endDate = new Date(dataToSend.endDate).toISOString();
}
await updateDomain(currentDomain.ID, {
is_public: currentDomain.IsPublic,
billing_data: dataToSend as BillingDataMod
})
toast.success('更新成功', { description: `域名 ${currentDomain.Domain} 的配置已保存。` })
setIsEditModalOpen(false)
mutate()
} catch (err) {
toast.error('更新失败', { description: (err as Error).message })
} }
}
const getStatusVariant = (status: string): 'default' | 'secondary' | 'destructive' | 'outline' => { // --- JSX 渲染 (保持不变) ---
switch (status) { return (
case 'verified': return 'default' <>
case 'pending': return 'secondary' <Card>
case 'expired': return 'destructive' <CardHeader className="flex flex-row items-center justify-between">
default: return 'outline' <div>
} <CardTitle></CardTitle>
} <CardDescription></CardDescription>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={() => mutate()}
disabled={isLoading}
>
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
</Button>
<Dialog open={isAddModalOpen} onOpenChange={setIsAddModalOpen}>
<DialogTrigger asChild>
<Button>
<PlusCircle className="mr-2 h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
"example.com"
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Input
value={newDomainName}
onChange={(e) => setNewDomainName(e.target.value)}
placeholder="your-domain.com"
onKeyUp={(e) => e.key === "Enter" && handleAddDomain()}
/>
</div>
<DialogFooter>
<Button
variant="secondary"
onClick={() => setIsAddModalOpen(false)}
>
</Button>
<Button onClick={handleAddDomain}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-10 text-muted-foreground">...</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{domains.map((domain) => (
<TableRow key={domain.ID}>
<TableCell className="font-medium">
{domain.Domain}
</TableCell>
<TableCell>
<Badge variant={getStatusVariant(domain.Status)}>
{domain.Status}
</Badge>
</TableCell>
<TableCell>{domain.expires_in_days ?? "N/A"}</TableCell>
<TableCell>
<Switch
checked={domain.IsPublic}
onCheckedChange={() => handlePublicToggle(domain)}
/>
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{domain.Status === "pending" && (
<DropdownMenuItem
onClick={() => handleVerify(domain.ID)}
>
<CheckCircle className="mr-2 h-4 w-4" />{" "}
</DropdownMenuItem>
)}
{domain.Status === "verified" && (
<DropdownMenuItem
onClick={() =>
handleSyncWhois(domain.ID)
}
>
<RefreshCcw className="mr-2 h-4 w-4" />{" "}
Whois
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => handleEditClick(domain)}
>
<Edit className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
className="text-red-600"
onClick={() =>
handleDelete(domain.ID, domain.Domain)
}
>
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
// --- JSX 渲染 (保持不变) --- {/* 验证信息弹窗 */}
return ( <Dialog
<> open={isVerificationInfoModalOpen}
<Card> onOpenChange={setIsVerificationInfoModalOpen}
<CardHeader className="flex flex-row items-center justify-between"> >
<div> <DialogContent>
<CardTitle></CardTitle> <DialogHeader>
<CardDescription></CardDescription> <DialogTitle></DialogTitle>
</div> <DialogDescription>
<div className="flex items-center gap-2"> DNS TXT
<Button variant="outline" size="icon" onClick={() => mutate()} disabled={isLoading}> </DialogDescription>
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} /> </DialogHeader>
</Button> <div className="py-4 space-y-2">
<Dialog open={isAddModalOpen} onOpenChange={setIsAddModalOpen}> <p> DNS </p>
<DialogTrigger asChild><Button><PlusCircle className="mr-2 h-4 w-4" /></Button></DialogTrigger> <div className="p-2 bg-muted rounded-md text-sm">
<DialogContent> <p>
<DialogHeader> <span className="font-semibold">:</span> TXT
<DialogTitle></DialogTitle> </p>
<DialogDescription> "example.com"</DialogDescription> <p>
</DialogHeader> <span className="font-semibold">/:</span> @
<div className="py-4"> </p>
<Input value={newDomainName} onChange={(e) => setNewDomainName(e.target.value)} placeholder="your-domain.com" onKeyUp={(e) => e.key === 'Enter' && handleAddDomain()} /> <p className="font-semibold">:</p>
</div> <p className="font-mono bg-background p-2 rounded">
<DialogFooter> {verificationToken}
<Button variant="secondary" onClick={() => setIsAddModalOpen(false)}></Button> </p>
<Button onClick={handleAddDomain}></Button> </div>
</DialogFooter> <p className="text-xs text-muted-foreground">
</DialogContent> DNS
</p>
</div>
<DialogFooter>
<Button onClick={() => setIsVerificationInfoModalOpen(false)}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog> </Dialog>
</div>
</CardHeader>
<CardContent>
{isLoading ? ( <div className="text-center py-10 text-muted-foreground">...</div> ) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{domains.map((domain) => (
<TableRow key={domain.ID}>
<TableCell className="font-medium">{domain.Domain}</TableCell>
<TableCell><Badge variant={getStatusVariant(domain.Status)}>{domain.Status}</Badge></TableCell>
<TableCell>{domain.expires_in_days ?? 'N/A'}</TableCell>
<TableCell>
<Switch
checked={domain.IsPublic}
onCheckedChange={() => handlePublicToggle(domain)}
/>
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreVertical className="h-4 w-4" /></Button></DropdownMenuTrigger>
<DropdownMenuContent>
{domain.Status === 'pending' && (<DropdownMenuItem onClick={() => handleVerify(domain.ID)}><CheckCircle className="mr-2 h-4 w-4" /> </DropdownMenuItem>)}
{domain.Status === 'verified' && (<DropdownMenuItem onClick={() => handleSyncWhois(domain.ID)}><RefreshCcw className="mr-2 h-4 w-4" /> Whois</DropdownMenuItem>)}
<DropdownMenuItem onClick={() => handleEditClick(domain)}><Edit className="mr-2 h-4 w-4" /> </DropdownMenuItem>
<DropdownMenuItem className="text-red-600" onClick={() => handleDelete(domain.ID, domain.Domain)}><Trash2 className="mr-2 h-4 w-4" /> </DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* 验证信息弹窗 */} {/* 编辑弹窗 */}
<Dialog open={isVerificationInfoModalOpen} onOpenChange={setIsVerificationInfoModalOpen}> <Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
<DialogContent> <DialogContent className="sm:max-w-[425px]">
<DialogHeader> <DialogHeader>
<DialogTitle></DialogTitle> <DialogTitle></DialogTitle>
<DialogDescription> DNS TXT </DialogDescription> <DialogDescription>
</DialogHeader> <span className="font-mono">{currentDomain?.Domain}</span>{" "}
<div className="py-4 space-y-2">
<p> DNS </p> </DialogDescription>
<div className="p-2 bg-muted rounded-md text-sm"> </DialogHeader>
<p><span className="font-semibold">:</span> TXT</p> <div className="grid gap-4 py-4">
<p><span className="font-semibold">/:</span> @</p> <div className="grid grid-cols-4 items-center gap-4">
<p className="font-semibold">:</p> <Label htmlFor="registrar" className="text-right">
<p className="font-mono bg-background p-2 rounded">{verificationToken}</p>
</div> </Label>
<p className="text-xs text-muted-foreground">DNS </p> <Input
</div> id="registrar"
<DialogFooter><Button onClick={() => setIsVerificationInfoModalOpen(false)}></Button></DialogFooter> name="registrar"
</DialogContent> value={editFormData.registrar || ""}
</Dialog> onChange={handleEditFormChange}
className="col-span-3"
{/* 编辑弹窗 */} />
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}> </div>
<DialogContent className="sm:max-w-[425px]"> <div className="grid grid-cols-4 items-center gap-4">
<DialogHeader> <Label htmlFor="registeredDate" className="text-right">
<DialogTitle></DialogTitle>
<DialogDescription> <span className="font-mono">{currentDomain?.Domain}</span> </DialogDescription> </Label>
</DialogHeader> <Input
<div className="grid gap-4 py-4"> id="registeredDate"
<div className="grid grid-cols-4 items-center gap-4"><Label htmlFor="registrar" className="text-right"></Label><Input id="registrar" name="registrar" value={editFormData.registrar || ''} onChange={handleEditFormChange} className="col-span-3" /></div> name="registeredDate"
<div className="grid grid-cols-4 items-center gap-4"><Label htmlFor="registeredDate" className="text-right"></Label><Input id="registeredDate" name="registeredDate" type="date" value={editFormData.registeredDate?.split('T')[0] || ''} onChange={handleEditFormChange} className="col-span-3" /></div> type="date"
<div className="grid grid-cols-4 items-center gap-4"><Label htmlFor="endDate" className="text-right"></Label><Input id="endDate" name="endDate" type="date" value={editFormData.endDate?.split('T')[0] || ''} onChange={handleEditFormChange} className="col-span-3" /></div> value={editFormData.registeredDate?.split("T")[0] || ""}
<div className="grid grid-cols-4 items-center gap-4"><Label htmlFor="renewalPrice" className="text-right"></Label><Input id="renewalPrice" name="renewalPrice" value={editFormData.renewalPrice || ''} onChange={handleEditFormChange} className="col-span-3" /></div> onChange={handleEditFormChange}
<div className="grid grid-cols-4 items-center gap-4"><Label htmlFor="notes" className="text-right"></Label><Textarea id="notes" name="notes" value={editFormData.notes || ''} onChange={handleEditFormChange} className="col-span-3" /></div> className="col-span-3"
</div> />
<DialogFooter> </div>
<Button variant="secondary" onClick={() => setIsEditModalOpen(false)}></Button> <div className="grid grid-cols-4 items-center gap-4">
<Button onClick={handleUpdateDomain}></Button> <Label htmlFor="endDate" className="text-right">
</DialogFooter>
</DialogContent> </Label>
</Dialog> <Input
</> id="endDate"
) name="endDate"
} type="date"
value={editFormData.endDate?.split("T")[0] || ""}
onChange={handleEditFormChange}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="renewalPrice" className="text-right">
</Label>
<Input
id="renewalPrice"
name="renewalPrice"
value={editFormData.renewalPrice || ""}
onChange={handleEditFormChange}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="notes" className="text-right">
</Label>
<Textarea
id="notes"
name="notes"
value={editFormData.notes || ""}
onChange={handleEditFormChange}
className="col-span-3"
/>
</div>
</div>
<DialogFooter>
<Button variant="secondary" onClick={() => setIsEditModalOpen(false)}>
</Button>
<Button onClick={handleUpdateDomain}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
+3 -1
View File
@@ -212,7 +212,9 @@ export default function ServerPage() {
}) })
}} }}
/> />
<BatchMoveServerIcon serverIds={selectedRows.map((r) => r.original.id) as number[]} /> <BatchMoveServerIcon
serverIds={selectedRows.map((r) => r.original.id) as number[]}
/>
<ServerConfigCardBatch <ServerConfigCardBatch
sid={selectedRows.map((r) => r.original.id) as number[]} sid={selectedRows.map((r) => r.original.id) as number[]}
className="shadow-[inset_0_1px_0_rgba(255,255,255,0.2)] bg-yellow-600 text-white hover:bg-yellow-500 dark:hover:bg-yellow-700 rounded-lg" className="shadow-[inset_0_1px_0_rgba(255,255,255,0.2)] bg-yellow-600 text-white hover:bg-yellow-500 dark:hover:bg-yellow-700 rounded-lg"
+23 -7
View File
@@ -3,6 +3,7 @@ import { SettingsTab } from "@/components/settings-tab"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { Combobox } from "@/components/ui/combobox"
import { import {
Form, Form,
FormControl, FormControl,
@@ -21,9 +22,8 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { Combobox } from "@/components/ui/combobox"
import { useNotification } from "@/hooks/useNotfication"
import { useAuth } from "@/hooks/useAuth" import { useAuth } from "@/hooks/useAuth"
import { useNotification } from "@/hooks/useNotfication"
import useSetting from "@/hooks/useSetting" import useSetting from "@/hooks/useSetting"
import { asOptionalField } from "@/lib/utils" import { asOptionalField } from "@/lib/utils"
import { nezhaLang, settingCoverageTypes } from "@/types" import { nezhaLang, settingCoverageTypes } from "@/types"
@@ -151,7 +151,11 @@ export default function SettingsPage() {
<FormItem> <FormItem>
<FormLabel>Expiry Notification Group ID</FormLabel> <FormLabel>Expiry Notification Group ID</FormLabel>
<FormControl> <FormControl>
<Input type="number" placeholder="Enter Group ID" {...field} /> <Input
type="number"
placeholder="Enter Group ID"
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -177,7 +181,10 @@ export default function SettingsPage() {
<FormItem> <FormItem>
<FormLabel>Custom Logo URL</FormLabel> <FormLabel>Custom Logo URL</FormLabel>
<FormControl> <FormControl>
<Input placeholder="https://example.com/logo.png" {...field} /> <Input
placeholder="https://example.com/logo.png"
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -203,7 +210,10 @@ export default function SettingsPage() {
<FormItem> <FormItem>
<FormLabel>Custom Links (JSON Array)</FormLabel> <FormLabel>Custom Links (JSON Array)</FormLabel>
<FormControl> <FormControl>
<Input placeholder='[{"link":"https://loohui.com/","name":"Blog","blank":false}]' {...field} /> <Input
placeholder='[{"link":"https://loohui.com/","name":"Blog","blank":false}]'
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -216,7 +226,10 @@ export default function SettingsPage() {
<FormItem> <FormItem>
<FormLabel>Background Image (Day)</FormLabel> <FormLabel>Background Image (Day)</FormLabel>
<FormControl> <FormControl>
<Input placeholder="https://example.com/day.jpg" {...field} /> <Input
placeholder="https://example.com/day.jpg"
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -229,7 +242,10 @@ export default function SettingsPage() {
<FormItem> <FormItem>
<FormLabel>Background Image (Night)</FormLabel> <FormLabel>Background Image (Night)</FormLabel>
<FormControl> <FormControl>
<Input placeholder="https://example.com/night.jpg" {...field} /> <Input
placeholder="https://example.com/night.jpg"
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
+531 -531
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
export interface BillingDataMod {
registrar?: string;
registeredDate?: string;
endDate?: string;
renewalPrice?: string;
autoRenewal?: string;
notes?: string;
cycle?: string;
amount?: string;
}
export interface Domain {
ID: number;
Domain: string;
Status: string;
VerifyToken: string;
IsPublic: boolean;
BillingData: BillingDataMod | null;
expires_in_days?: number;
}
+25
View File
@@ -0,0 +1,25 @@
package main
import (
"encoding/json"
"fmt"
"strings"
)
type Config struct {
IPReportPeriod int `json:"ip_report_period"`
}
func main() {
jsonData := `{"ip_report_period":30, "unknown_field": 123}`
var c Config
dec := json.NewDecoder(strings.NewReader(jsonData))
dec.DisallowUnknownFields()
err := dec.Decode(&c)
if err != nil {
fmt.Println("Error:", err)
} else {
fmt.Println("Success")
}
}