feat(web): DomainsPage — список, импорт зон, привязка шаблона

This commit is contained in:
2026-07-03 17:43:49 +07:00
parent 1412da9a31
commit 0ce15d30a8
3 changed files with 273 additions and 1 deletions
+183
View File
@@ -0,0 +1,183 @@
import { useState } from "react"
import { Link } from "react-router-dom"
import { Inbox, Loader2, Trash2, Upload } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
useAccounts,
useDeleteDomain,
useDomains,
useImportZones,
useSetDomainTemplate,
useTemplates,
} from "@/hooks/useApi"
const NO_TEMPLATE = "__none__"
export function DomainsPage() {
const domains = useDomains()
const accounts = useAccounts()
const templates = useTemplates()
const importZones = useImportZones()
const setTemplate = useSetDomainTemplate()
const deleteDomain = useDeleteDomain()
const accountList = accounts.data ?? []
const templateList = templates.data ?? []
const domainList = domains.data ?? []
const [importAccountId, setImportAccountId] = useState<string | null>(null)
const selectedImportAccount = importAccountId ?? accountList[0]?.id ?? null
const accountItems = accountList.map((a) => ({
value: a.id,
label: `${a.provider} · ${a.comment}`,
}))
function accountLabel(id: string) {
const acc = accountList.find((a) => a.id === id)
return acc ? `${acc.provider} · ${acc.comment}` : id
}
function onImport() {
if (!selectedImportAccount) return
importZones.mutate(selectedImportAccount)
}
function onTemplateChange(domainId: string, value: unknown) {
const templateId = value === NO_TEMPLATE ? null : (value as string)
setTemplate.mutate({ id: domainId, templateId })
}
function onDelete(domainId: string, zoneName: string) {
if (window.confirm(`Удалить домен ${zoneName}? Действие необратимо.`)) {
deleteDomain.mutate(domainId)
}
}
return (
<div className="mx-auto flex max-w-5xl flex-col gap-6 px-6 py-8">
<header className="flex flex-col gap-1">
<span className="font-dns text-[11px] tracking-wider text-muted-foreground uppercase">
zones
</span>
<h1 className="text-xl font-semibold tracking-tight text-foreground">Domains</h1>
</header>
<div className="flex flex-wrap items-end gap-3 rounded-xl border border-border bg-card/60 p-4">
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-muted-foreground">Учётная запись</span>
<Select
items={accountItems}
value={selectedImportAccount}
onValueChange={(v) => setImportAccountId(v as string)}
>
<SelectTrigger aria-label="Учётная запись для импорта" className="min-w-56">
<SelectValue placeholder="Выберите учётку" />
</SelectTrigger>
<SelectContent>
{accountItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={onImport} disabled={!selectedImportAccount || importZones.isPending}>
{importZones.isPending ? (
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
) : (
<Upload className="size-4" strokeWidth={1.75} />
)}
Импортировать зоны
</Button>
{importZones.isError && (
<span className="font-dns text-xs text-destructive">{importZones.error.message}</span>
)}
</div>
{domainList.length === 0 ? (
<div className="flex flex-col items-center gap-2 rounded-xl border border-dashed border-border px-4 py-12 text-center text-sm text-muted-foreground">
<Inbox className="size-6" strokeWidth={1.5} />
Доменов пока нет импортируйте зоны из учётной записи.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Zone</TableHead>
<TableHead>Учётка</TableHead>
<TableHead>Шаблон</TableHead>
<TableHead className="text-right">Действия</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{domainList.map((d) => {
const templateItems = [
{ value: NO_TEMPLATE, label: "Без шаблона" },
...templateList.map((t) => ({ value: t.id, label: t.name })),
]
return (
<TableRow key={d.id}>
<TableCell className="font-dns">{d.zoneName}</TableCell>
<TableCell className="font-dns text-xs text-muted-foreground">
{accountLabel(d.providerAccountId)}
</TableCell>
<TableCell>
<Select
items={templateItems}
value={d.templateId ?? NO_TEMPLATE}
onValueChange={(v) => onTemplateChange(d.id, v)}
>
<SelectTrigger aria-label={`Шаблон: ${d.zoneName}`} size="sm" className="min-w-40">
<SelectValue placeholder="Без шаблона" />
</SelectTrigger>
<SelectContent>
{templateItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1.5">
<Button variant="outline" size="sm" render={<Link to={`/domains/${d.id}`} />}>
Diff
</Button>
<Button
variant="destructive"
size="icon-sm"
aria-label={`Удалить ${d.zoneName}`}
onClick={() => onDelete(d.id, d.zoneName)}
disabled={deleteDomain.isPending}
>
<Trash2 className="size-3.5" strokeWidth={1.75} />
</Button>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</div>
)
}