mirror of
https://github.com/Buriburizaem0n/admin-frontend-domain.git
synced 2026-05-06 05:38:51 +00:00
468 lines
20 KiB
TypeScript
468 lines
20 KiB
TypeScript
// src/routes/domain.tsx (最终 Bug 修复版)
|
||
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 {
|
||
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 {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table"
|
||
import { Textarea } from "@/components/ui/textarea"
|
||
// 导入 API 类型和函数
|
||
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)
|
||
|
||
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 })
|
||
}
|
||
}
|
||
|
||
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 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 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 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 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"
|
||
}
|
||
}
|
||
|
||
// --- 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>
|
||
|
||
{/* 验证信息弹窗 */}
|
||
<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>
|
||
</>
|
||
)
|
||
}
|