Files
NewSzxcn-Email/apps/web/src/pages/admin.tsx
T
LanQin 3ef8caa319 feat(email): 初始化自建邮箱 MVP
- 新增 Go + SQLite 后端,支持登录、域名、邮箱、别名、联系人、规则、统计与邮件收发。
- 新增 Webmail 前端,支持多邮箱切换、邮件列表/阅读/写信、附件、搜索、主题切换与个人中心。
- 新增 Docker Compose 部署骨架,补充 Postfix、Dovecot、OpenDKIM、Nginx 配置与部署说明。
2026-06-15 00:38:50 +08:00

333 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as React from "react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { CheckCircle2, Copy, Globe2, Mailbox, Plus, RefreshCcw, ShieldCheck, Users } from "lucide-react"
import { api, DNSRecord, Domain } from "@/lib/api"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { useToast } from "@/hooks/use-toast"
export function AdminPage() {
const domains = useQuery({ queryKey: ["admin", "domains"], queryFn: api.domains })
const mailboxes = useQuery({ queryKey: ["admin", "mailboxes"], queryFn: api.mailboxes })
const aliases = useQuery({ queryKey: ["admin", "aliases"], queryFn: api.aliases })
const [selectedDomain, setSelectedDomain] = React.useState<string | null>(null)
React.useEffect(() => {
if (!selectedDomain && domains.data?.items?.[0]) setSelectedDomain(domains.data.items[0].id)
}, [domains.data, selectedDomain])
const domain = domains.data?.items.find((d) => d.id === selectedDomain)
return (
<ScrollArea className="h-svh">
<div className="p-6">
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">系统管理</h1>
</div>
<div className="flex flex-wrap gap-2">
<CreateDomainDialog />
<CreateMailboxDialog domains={domains.data?.items || []} />
<CreateAliasDialog domains={domains.data?.items || []} />
</div>
</div>
<div className="mb-6 grid gap-4 md:grid-cols-4">
<Stat icon={<Globe2 />} label="域名" value={domains.data?.items.length || 0} />
<Stat icon={<Mailbox />} label="邮箱账号" value={mailboxes.data?.items.length || 0} />
<Stat icon={<Users />} label="别名" value={aliases.data?.items.length || 0} />
<Stat icon={<ShieldCheck />} label="DNS 正常" value={(domains.data?.items || []).filter((d) => d.dnsStatus === "ok").length} />
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_520px]">
<Card>
<CardHeader>
<CardTitle>域名</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{domains.data?.items.map((d) => (
<Button key={d.id} type="button" variant={selectedDomain === d.id ? "secondary" : "outline"} onClick={() => setSelectedDomain(d.id)} className="h-auto w-full justify-between p-4 text-left">
<div>
<div className="font-medium">{d.name}</div>
<div className="text-xs text-muted-foreground">selector: {d.dkimSelector}</div>
</div>
<Badge variant={d.dnsStatus === "ok" ? "default" : "secondary"}>{d.dnsStatus === "ok" ? "正常" : d.dnsStatus}</Badge>
</Button>
))}
</div>
</CardContent>
</Card>
<DNSPanel domain={domain} />
<Card>
<CardHeader>
<CardTitle>邮箱账号</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>地址</TableHead>
<TableHead>归属用户</TableHead>
<TableHead>名称</TableHead>
<TableHead>配额</TableHead>
<TableHead>状态</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mailboxes.data?.items.map((m) => (
<TableRow key={m.id}>
<TableCell className="font-medium">{m.address}</TableCell>
<TableCell className="text-muted-foreground">{m.userEmail || m.userId}</TableCell>
<TableCell>{m.displayName}</TableCell>
<TableCell>{m.quotaMb} MB</TableCell>
<TableCell><Badge variant="secondary">{m.status}</Badge></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>别名/转发</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>来源</TableHead>
<TableHead>目标</TableHead>
<TableHead>状态</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{aliases.data?.items.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-medium">{a.source}</TableCell>
<TableCell>{a.destination}</TableCell>
<TableCell><Badge variant={a.enabled ? "default" : "secondary"}>{a.enabled ? "启用" : "停用"}</Badge></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
</div>
</ScrollArea>
)
}
function Stat({ icon, label, value }: { icon: React.ReactNode; label: string; value: number }) {
return (
<Card>
<CardContent className="flex items-center gap-4 p-5">
<div className="grid h-10 w-10 place-items-center rounded-lg bg-muted text-foreground">{icon}</div>
<div>
<div className="text-2xl font-semibold tracking-tight">{value}</div>
<div className="text-xs text-muted-foreground">{label}</div>
</div>
</CardContent>
</Card>
)
}
function DNSPanel({ domain }: { domain?: Domain }) {
const { toast } = useToast()
const qc = useQueryClient()
const records = useQuery({ queryKey: ["dns-records", domain?.id], queryFn: () => api.dnsRecords(domain!.id), enabled: !!domain })
const check = useMutation({
mutationFn: () => api.checkDns(domain!.id),
onSuccess: (res) => {
qc.invalidateQueries({ queryKey: ["admin", "domains"] })
toast({ title: res.status === "ok" ? "DNS 检测通过" : "DNS 检测未通过", description: Object.values(res.checks).map((c) => c.message).join("") })
},
})
if (!domain) return <Card><CardContent className="p-6 text-muted-foreground">请选择域名</CardContent></Card>
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>DNS 记录</CardTitle>
</div>
<Button variant="outline" size="sm" onClick={() => check.mutate()} disabled={check.isPending}>
<RefreshCcw className="h-4 w-4" />检测
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">{records.data?.items.map((r) => <DNSRecordRow key={`${r.type}-${r.name}`} record={r} />)}</div>
{check.data && (
<>
<Separator className="my-4" />
<div className="space-y-2">
{Object.entries(check.data.checks).map(([k, v]) => (
<div key={k} className="flex items-center gap-2 text-sm">
<CheckCircle2 className={`h-4 w-4 ${v.ok ? "text-green-600" : "text-destructive"}`} />
<span className="font-medium">{k.toUpperCase()}:</span> {v.message}
</div>
))}
</div>
</>
)}
</CardContent>
</Card>
)
}
function DNSRecordRow({ record }: { record: DNSRecord }) {
const { toast } = useToast()
const text = `${record.type} ${record.name} ${record.value}`
return (
<div className="rounded-lg border bg-card p-3">
<div className="mb-2 flex items-center justify-between">
<Badge variant="outline" className="font-mono">{record.type}</Badge>
<Button size="sm" variant="ghost" className="h-7 gap-1 text-xs" onClick={() => { navigator.clipboard.writeText(text); toast({ title: "已复制" }) }}>
<Copy className="h-3.5 w-3.5" />复制
</Button>
</div>
<div className="break-all font-mono text-xs text-muted-foreground">
<div>Name: {record.name}</div>
<div>Value: {record.value}</div>
<div>TTL: {record.ttl}s</div>
</div>
</div>
)
}
function CreateDomainDialog() {
const qc = useQueryClient()
const { toast } = useToast()
const [open, setOpen] = React.useState(false)
const mut = useMutation({
mutationFn: (form: FormData) => api.createDomain(String(form.get("name"))),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["admin", "domains"] }); setOpen(false); toast({ title: "域名已创建" }) },
onError: (e) => toast({ title: "创建失败", description: e.message }),
})
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline"><Plus className="h-4 w-4" />域名</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>添加域名</DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}>
<div className="space-y-2"><Label>域名</Label><Input name="name" placeholder="example.com" required /></div>
<DialogFooter><Button disabled={mut.isPending}>创建</Button></DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
function CreateMailboxDialog({ domains }: { domains: Domain[] }) {
const qc = useQueryClient()
const { toast } = useToast()
const [open, setOpen] = React.useState(false)
const [domainId, setDomainId] = React.useState("")
const [role, setRole] = React.useState("user")
React.useEffect(() => { if (!domainId && domains[0]) setDomainId(domains[0].id) }, [domains, domainId])
const mut = useMutation({
mutationFn: (form: FormData) => api.createMailbox({
domainId,
localPart: String(form.get("localPart")),
displayName: String(form.get("displayName")),
password: String(form.get("password")),
quotaMb: Number(form.get("quotaMb") || 1024),
role: role as "admin" | "user",
ownerEmail: String(form.get("ownerEmail") || ""),
}),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["admin", "mailboxes"] }); setOpen(false); toast({ title: "邮箱已创建" }) },
onError: (e) => toast({ title: "创建失败", description: e.message }),
})
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button><Plus className="h-4 w-4" />邮箱</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>创建邮箱账号</DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}>
<DomainSelect domains={domains} value={domainId} onChange={setDomainId} />
<div className="grid grid-cols-2 gap-3">
<Field name="localPart" label="账号" placeholder="alice" />
<Field name="displayName" label="显示名" placeholder="Alice" />
</div>
<Field name="ownerEmail" label="归属用户邮箱(可选)" placeholder="留空则使用新邮箱创建用户;填已有账号则追加邮箱" required={false} />
<div className="grid grid-cols-2 gap-3">
<Field name="password" label="密码" type="password" placeholder="至少 8 位" />
<Field name="quotaMb" label="配额 MB" type="number" defaultValue="1024" />
</div>
<div className="space-y-2"><Label>角色</Label>
<Select value={role} onValueChange={setRole}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="user">普通用户</SelectItem>
<SelectItem value="admin">管理员</SelectItem>
</SelectContent>
</Select>
</div>
<DialogFooter><Button disabled={mut.isPending || !domainId}>创建</Button></DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
function CreateAliasDialog({ domains }: { domains: Domain[] }) {
const qc = useQueryClient()
const { toast } = useToast()
const [open, setOpen] = React.useState(false)
const [domainId, setDomainId] = React.useState("")
React.useEffect(() => { if (!domainId && domains[0]) setDomainId(domains[0].id) }, [domains, domainId])
const mut = useMutation({
mutationFn: (form: FormData) => api.createAlias({ domainId, source: String(form.get("source")), destination: String(form.get("destination")), enabled: true }),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["admin", "aliases"] }); setOpen(false); toast({ title: "别名已创建" }) },
onError: (e) => toast({ title: "创建失败", description: e.message }),
})
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline"><Plus className="h-4 w-4" />别名</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>创建别名/转发</DialogTitle></DialogHeader>
<form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}>
<DomainSelect domains={domains} value={domainId} onChange={setDomainId} />
<Field name="source" label="来源" placeholder="sales 或 sales@example.com" />
<Field name="destination" label="目标邮箱" placeholder="alice@example.com" />
<DialogFooter><Button disabled={mut.isPending || !domainId}>创建</Button></DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
function Field({ label, required = true, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { label: string }) {
return <div className="space-y-2"><Label>{label}</Label><Input required={required} {...props} /></div>
}
function DomainSelect({ domains, value, onChange }: { domains: Domain[]; value: string; onChange: (value: string) => void }) {
return <div className="space-y-2"><Label>域名</Label>
<Select value={value} onValueChange={onChange}>
<SelectTrigger><SelectValue placeholder="选择域名" /></SelectTrigger>
<SelectContent>{domains.map((d) => <SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>)}</SelectContent>
</Select>
</div>
}