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
+440 -257
View File
@@ -1,284 +1,467 @@
// src/routes/domain.tsx (最终 Bug 修复版)
import { useState, useEffect } from 'react'
import { PlusCircle, RefreshCw, MoreVertical, Trash2, Edit, CheckCircle, RefreshCcw } from 'lucide-react'
import {
addDomain,
deleteDomain,
syncDomainWHOIS,
updateDomain,
useDomainList,
verifyDomain,
} from "@/api/domain"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
// 导入 shadcn/ui 组件
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
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 { toast } from 'sonner'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Textarea } from "@/components/ui/textarea"
// 导入 API 类型和函数
import type { Domain, BillingDataMod } from '@/types/api'
import { useDomainList, addDomain, verifyDomain, deleteDomain, updateDomain, syncDomainWHOIS } from '@/api/domain'
import useSWR from 'swr'
import type { BillingDataMod, Domain } from "@/types/domain"
import {
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() {
// --- React State Hooks ---
const [domains, setDomains] = useState<Domain[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isAddModalOpen, setIsAddModalOpen] = useState(false)
const [newDomainName, setNewDomainName] = useState('')
const [verificationToken, setVerificationToken] = useState('')
const [isVerificationInfoModalOpen, setIsVerificationInfoModalOpen] = useState(false)
// --- React State Hooks ---
const [domains, setDomains] = useState<Domain[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
const [currentDomain, setCurrentDomain] = useState<Domain | null>(null)
const [editFormData, setEditFormData] = useState<Partial<BillingDataMod>>({})
const [isAddModalOpen, setIsAddModalOpen] = useState(false)
const [newDomainName, setNewDomainName] = useState("")
// --- 数据获取 (使用 SWR) ---
const { data: domainData, error, mutate } = useSWR('/api/v1/domains', useDomainList, { revalidateOnFocus: false })
const [verificationToken, setVerificationToken] = useState("")
const [isVerificationInfoModalOpen, setIsVerificationInfoModalOpen] = useState(false)
useEffect(() => {
if (domainData) {
setDomains(domainData)
setIsLoading(false)
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
const [currentDomain, setCurrentDomain] = useState<Domain | null>(null)
const [editFormData, setEditFormData] = useState<Partial<BillingDataMod>>({})
// --- 数据获取 (使用 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('无法加载域名列表,请检查后端服务是否正常。')
setIsLoading(false)
const handleVerify = async (domainId: number) => {
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 () => {
if (!newDomainName) {
toast.error('请输入域名')
return
const handleSyncWhois = async (domainId: number) => {
const loadingToast = toast.loading("正在同步 Whois 信息...")
try {
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)
setVerificationToken(response.VerifyToken)
setIsAddModalOpen(false)
setIsVerificationInfoModalOpen(true)
setNewDomainName('')
mutate()
} catch (err) {
toast.error('添加失败', { description: (err as Error).message })
const handleDelete = async (domainId: number, domainName: string) => {
if (window.confirm(`确定要删除域名 ${domainName} 吗?`)) {
try {
await deleteDomain(domainId)
toast.success("删除成功", { description: `域名 ${domainName} 已被删除。` })
mutate()
} catch (err) {
toast.error("删除失败", { description: (err as Error).message })
}
}
}
}
const handleVerify = async (domainId: number) => {
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 })
const handlePublicToggle = async (domain: Domain) => {
try {
await updateDomain(domain.ID, {
is_public: !domain.IsPublic,
billing_data: domain.BillingData as BillingDataMod,
})
toast.success(`域名 ${domain.Domain} 的可见状态已更新`)
mutate()
} catch (err) {
toast.error("更新失败", { description: (err as Error).message })
}
}
}
const handleSyncWhois = async (domainId: number) => {
const loadingToast = toast.loading('正在同步 Whois 信息...')
try {
await syncDomainWHOIS(domainId)
toast.success('同步成功', { id: loadingToast, description: '域名 Whois 信息已更新。' })
mutate()
} catch (err) {
toast.error('同步失败', { id: loadingToast, description: (err as Error).message })
const handleEditClick = (domain: Domain) => {
setCurrentDomain(domain)
setEditFormData(domain.BillingData || {})
setIsEditModalOpen(true)
}
}
const handleDelete = async (domainId: number, domainName: string) => {
if (window.confirm(`确定要删除域名 ${domainName} 吗?`)) {
try {
await deleteDomain(domainId)
toast.success('删除成功', { description: `域名 ${domainName} 已被删除。` })
mutate()
} catch (err) {
toast.error('删除失败', { description: (err as Error).message })
}
const handleEditFormChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setEditFormData({
...editFormData,
[e.target.name]: e.target.value,
})
}
}
const handlePublicToggle = async (domain: Domain) => {
try {
await updateDomain(domain.ID, {
is_public: !domain.IsPublic,
billing_data: domain.BillingData as BillingDataMod,
})
toast.success(`域名 ${domain.Domain} 的可见状态已更新`)
mutate()
} catch (err) {
toast.error('更新失败', { description: (err as Error).message })
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 handleEditClick = (domain: Domain) => {
setCurrentDomain(domain)
setEditFormData(domain.BillingData || {})
setIsEditModalOpen(true)
}
const handleEditFormChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setEditFormData({
...editFormData,
[e.target.name]: e.target.value,
})
}
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" => {
switch (status) {
case "verified":
return "default"
case "pending":
return "secondary"
case "expired":
return "destructive"
default:
return "outline"
}
}
}
const getStatusVariant = (status: string): 'default' | 'secondary' | 'destructive' | 'outline' => {
switch (status) {
case 'verified': return 'default'
case 'pending': return 'secondary'
case 'expired': return 'destructive'
default: return 'outline'
}
}
// --- JSX 渲染 (保持不变) ---
return (
<>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<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 (
<>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<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
open={isVerificationInfoModalOpen}
onOpenChange={setIsVerificationInfoModalOpen}
>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
DNS TXT
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-2">
<p> DNS </p>
<div className="p-2 bg-muted rounded-md text-sm">
<p>
<span className="font-semibold">:</span> TXT
</p>
<p>
<span className="font-semibold">/:</span> @
</p>
<p className="font-semibold">:</p>
<p className="font-mono bg-background p-2 rounded">
{verificationToken}
</p>
</div>
<p className="text-xs text-muted-foreground">
DNS
</p>
</div>
<DialogFooter>
<Button onClick={() => setIsVerificationInfoModalOpen(false)}>
</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>
{/* 验证信息弹窗 */}
<Dialog open={isVerificationInfoModalOpen} onOpenChange={setIsVerificationInfoModalOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription> DNS TXT </DialogDescription>
</DialogHeader>
<div className="py-4 space-y-2">
<p> DNS </p>
<div className="p-2 bg-muted rounded-md text-sm">
<p><span className="font-semibold">:</span> TXT</p>
<p><span className="font-semibold">/:</span> @</p>
<p className="font-semibold">:</p>
<p className="font-mono bg-background p-2 rounded">{verificationToken}</p>
</div>
<p className="text-xs text-muted-foreground">DNS </p>
</div>
<DialogFooter><Button onClick={() => setIsVerificationInfoModalOpen(false)}></Button></DialogFooter>
</DialogContent>
</Dialog>
{/* 编辑弹窗 */}
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription> <span className="font-mono">{currentDomain?.Domain}</span> </DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<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>
<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>
<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>
<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>
</>
)
}
{/* 编辑弹窗 */}
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
<span className="font-mono">{currentDomain?.Domain}</span>{" "}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<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>
<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>
<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>
<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>
</>
)
}