feat(admin,mail,profile): 增强邮件管理与交互体验

- 新增统一确认弹窗,替换多个删除/清理操作的直接执行。
- 管理后台支持首次配置引导、域名/邮箱/别名创建入口,以及邮件列表分页加载更多。
- 邮箱页改为分页拉取邮件,补充无邮箱状态、批量删除确认和“写邮件”可用性控制。
- 个人中心为联系人、清理、规则、拦截操作增加二次确认。
- 将 DKIM 密钥长度提升为 2048 位,增强安全性。
This commit is contained in:
LanQin_
2026-06-16 15:40:38 +08:00
parent 3093be2222
commit d87f635899
5 changed files with 350 additions and 41 deletions
+1 -1
View File
@@ -695,7 +695,7 @@ func (a *App) createDomainTx(ctx context.Context, tx *sql.Tx, name string) (stri
} }
func generateDKIMMaterial() (string, string, error) { func generateDKIMMaterial() (string, string, error) {
key, err := rsa.GenerateKey(rand.Reader, 1024) key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
@@ -0,0 +1,46 @@
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
type ConfirmDialogProps = {
open: boolean
title: string
description?: string
confirmText?: string
cancelText?: string
destructive?: boolean
pending?: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
}
export function ConfirmDialog({
open,
title,
description,
confirmText = "确认",
cancelText = "取消",
destructive = false,
pending = false,
onOpenChange,
onConfirm,
}: ConfirmDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
{description && <div className="text-sm text-muted-foreground">{description}</div>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
{cancelText}
</Button>
<Button type="button" variant={destructive ? "destructive" : "default"} onClick={onConfirm} disabled={pending}>
{pending ? "处理中..." : confirmText}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+102 -20
View File
@@ -1,8 +1,8 @@
import * as React from "react" import * as React from "react"
import DOMPurify from "dompurify" import DOMPurify from "dompurify"
import { useSearchParams } from "react-router-dom" import { useSearchParams } from "react-router-dom"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { CheckCircle2, Copy, Globe2, Mailbox, MoreHorizontal, Plus, RefreshCcw, Search, ShieldCheck, Trash2, Users } from "lucide-react" import { ArrowRight, CheckCircle2, Circle, Copy, Globe2, Mailbox, MoreHorizontal, Plus, RefreshCcw, Search, ShieldCheck, Trash2, Users } from "lucide-react"
import { api, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, SystemSettings } from "@/lib/api" import { api, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, SystemSettings } from "@/lib/api"
import { cn, formatBytes, formatDate } from "@/lib/utils" import { cn, formatBytes, formatDate } from "@/lib/utils"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -19,9 +19,11 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { ConfirmDialog } from "@/components/confirm-dialog"
import { useToast } from "@/hooks/use-toast" import { useToast } from "@/hooks/use-toast"
type Section = "overview" | "users" | "domains" | "mailboxes" | "aliases" | "messages" | "settings" type Section = "overview" | "users" | "domains" | "mailboxes" | "aliases" | "messages" | "settings"
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
const sectionLabels: Record<Section, string> = { const sectionLabels: Record<Section, string> = {
overview: "概览", overview: "概览",
@@ -74,10 +76,10 @@ export function AdminPage() {
</div> </div>
)} )}
{section === "overview" && <OverviewSection overview={overview.data} domains={domainItems} />} {section === "overview" && <OverviewSection overview={overview.data} domains={domainItems} settings={settings.data} onSectionChange={(next) => setParams(next === "overview" ? {} : { section: next })} />}
{section === "users" && <UsersSection users={userItems} />} {section === "users" && <UsersSection users={userItems} />}
{section === "domains" && <DomainsSection domains={domainItems} />} {section === "domains" && <DomainsSection domains={domainItems} />}
{section === "mailboxes" && <MailboxesSection mailboxes={mailboxItems} users={userItems} />} {section === "mailboxes" && <MailboxesSection mailboxes={mailboxItems} users={userItems} domains={domainItems} />}
{section === "aliases" && <AliasesSection aliases={aliasItems} domains={domainItems} />} {section === "aliases" && <AliasesSection aliases={aliasItems} domains={domainItems} />}
{section === "messages" && <AdminMessagesSection mailboxes={mailboxItems} />} {section === "messages" && <AdminMessagesSection mailboxes={mailboxItems} />}
{section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} />} {section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} />}
@@ -85,7 +87,8 @@ export function AdminPage() {
</ScrollArea> </ScrollArea>
) )
} }
function OverviewSection({ overview, domains }: { overview?: { activeUsers: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number }; domains: Domain[] }) { function OverviewSection({ overview, domains, settings, onSectionChange }: { overview?: { activeUsers: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number }; domains: Domain[]; settings?: SystemSettings; onSectionChange: (section: Section) => void }) {
const checklist = setupChecklist(overview, domains, settings)
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_420px]"> <div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_420px]">
@@ -98,6 +101,23 @@ function OverviewSection({ overview, domains }: { overview?: { activeUsers: numb
<InfoBox label="未读邮件" value={overview?.unreadMessages || 0} /> <InfoBox label="未读邮件" value={overview?.unreadMessages || 0} />
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent className="space-y-2">
{checklist.map((item) => (
<Button key={item.key} type="button" variant="outline" className="h-auto w-full justify-start gap-3 px-3 py-2 text-left font-normal" onClick={() => onSectionChange(item.section)}>
{item.done ? <CheckCircle2 className="h-4 w-4 shrink-0 text-green-600" /> : <Circle className="h-4 w-4 shrink-0 text-muted-foreground" />}
<span className="min-w-0 flex-1">
<span className="block font-medium">{item.title}</span>
<span className="block truncate text-xs text-muted-foreground">{item.detail}</span>
</span>
<ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</Button>
))}
</CardContent>
</Card>
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_420px]">
<Card> <Card>
<CardHeader><CardTitle>DNS </CardTitle></CardHeader> <CardHeader><CardTitle>DNS </CardTitle></CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
@@ -105,17 +125,45 @@ function OverviewSection({ overview, domains }: { overview?: { activeUsers: numb
{domains.length === 0 && <Empty text="暂无域名" />} {domains.length === 0 && <Empty text="暂无域名" />}
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<InfoLine label="公网地址" value={settings?.publicBaseUrl || "-"} />
<InfoLine label="SMTP" value={settings?.smtpHost ? `${settings.smtpHost}:${settings.smtpPort}` : "-"} />
<InfoLine label="注册" value={settings?.openRegistration ? "已开放" : "关闭"} />
<InfoLine label="用户自助申请" value={settings?.userMailboxApplyEnabled ? "已启用" : "关闭"} />
</CardContent>
</Card>
</div> </div>
</div> </div>
) )
} }
function setupChecklist(overview: { activeUsers: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number } | undefined, domains: Domain[], settings?: SystemSettings) {
const hasDomain = domains.length > 0
const dnsReady = domains.some((domain) => domain.dnsStatus === "ok")
const hasMailbox = (overview?.activeMailboxes || 0) > 0
const hasMail = (overview?.messages || 0) > 0
return [
{ key: "domain", title: "添加邮件域名", detail: hasDomain ? `${domains.length} 个域名已添加` : "先添加 example.com 这样的邮件域名", done: hasDomain, section: "domains" as Section },
{ key: "dns", title: "完成 DNS 检测", detail: dnsReady ? "至少一个域名 DNS 正常" : "配置 MX、SPF、DKIM、DMARC 后执行检测", done: dnsReady, section: "domains" as Section },
{ key: "mailbox", title: "创建邮箱账号", detail: hasMailbox ? `${overview?.activeMailboxes || 0} 个活跃邮箱` : "给管理员或用户创建第一个邮箱", done: hasMailbox, section: "mailboxes" as Section },
{ key: "smtp", title: "确认发信配置", detail: settings?.smtpHost ? `${settings.smtpHost}:${settings.smtpPort}` : "配置本机 Postfix 或外部 SMTP", done: !!settings?.smtpHost, section: "settings" as Section },
{ key: "mail", title: "完成收发测试", detail: hasMail ? `${overview?.messages || 0} 封邮件已入库` : "发送或接收一封测试邮件", done: hasMail, section: "messages" as Section },
]
}
function InfoLine({ label, value }: { label: string; value: React.ReactNode }) {
return <div className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"><span>{label}</span><span className="min-w-0 truncate font-medium text-foreground">{value}</span></div>
}
function UsersSection({ users }: { users: AdminUser[] }) { function UsersSection({ users }: { users: AdminUser[] }) {
const qc = useQueryClient() const qc = useQueryClient()
const { toast } = useToast() const { toast } = useToast()
const [query, setQuery] = React.useState("") const [query, setQuery] = React.useState("")
const [roleFilter, setRoleFilter] = React.useState("all") const [roleFilter, setRoleFilter] = React.useState("all")
const [statusFilter, setStatusFilter] = React.useState("all") const [statusFilter, setStatusFilter] = React.useState("all")
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
const filteredUsers = users.filter((user) => { const filteredUsers = users.filter((user) => {
const keyword = query.trim().toLowerCase() const keyword = query.trim().toLowerCase()
const matchesKeyword = !keyword || [user.email, user.displayName, ...(user.mailboxes || [])].some((value) => value.toLowerCase().includes(keyword)) const matchesKeyword = !keyword || [user.email, user.displayName, ...(user.mailboxes || [])].some((value) => value.toLowerCase().includes(keyword))
@@ -123,7 +171,7 @@ function UsersSection({ users }: { users: AdminUser[] }) {
const matchesStatus = statusFilter === "all" || (statusFilter === "active" ? !user.disabled : user.disabled) const matchesStatus = statusFilter === "all" || (statusFilter === "active" ? !user.disabled : user.disabled)
return matchesKeyword && matchesRole && matchesStatus return matchesKeyword && matchesRole && matchesStatus
}) })
const remove = useMutation({ mutationFn: api.deleteUser, onSuccess: () => { invalidateAdmin(qc); toast({ title: "用户已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) }) const remove = useMutation({ mutationFn: api.deleteUser, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "用户已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) })
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -168,13 +216,14 @@ function UsersSection({ users }: { users: AdminUser[] }) {
<TableCell><UserMailboxCell user={user} /></TableCell> <TableCell><UserMailboxCell user={user} /></TableCell>
<TableCell><Badge variant={user.disabled ? "secondary" : "default"}>{user.disabled ? "停用" : "正常"}</Badge></TableCell> <TableCell><Badge variant={user.disabled ? "secondary" : "default"}>{user.disabled ? "停用" : "正常"}</Badge></TableCell>
<TableCell className="text-muted-foreground">{new Date(user.createdAt).toLocaleDateString()}</TableCell> <TableCell className="text-muted-foreground">{new Date(user.createdAt).toLocaleDateString()}</TableCell>
<TableCell><UserActions user={user} onDelete={() => remove.mutate(user.id)} /></TableCell> <TableCell><UserActions user={user} onDelete={() => setPendingConfirm({ title: "删除用户?", description: `将删除 ${user.email} 及其关联数据。`, confirmText: "删除用户", onConfirm: () => remove.mutate(user.id) })} /></TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
{filteredUsers.length === 0 && <Empty text="没有匹配的用户" />} {filteredUsers.length === 0 && <Empty text="没有匹配的用户" />}
</CardContent> </CardContent>
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive pending={remove.isPending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
</Card> </Card>
) )
} }
@@ -182,11 +231,17 @@ function UsersSection({ users }: { users: AdminUser[] }) {
function DomainsSection({ domains }: { domains: Domain[] }) { function DomainsSection({ domains }: { domains: Domain[] }) {
const qc = useQueryClient() const qc = useQueryClient()
const { toast } = useToast() const { toast } = useToast()
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
const update = useMutation({ mutationFn: ({ id, status }: { id: string; status: string }) => api.updateDomain(id, { status }), onSuccess: () => { invalidateAdmin(qc); toast({ title: "域名已更新" }) }, onError: (e) => toast({ title: "更新失败", description: e.message }) }) const update = useMutation({ mutationFn: ({ id, status }: { id: string; status: string }) => api.updateDomain(id, { status }), onSuccess: () => { invalidateAdmin(qc); toast({ title: "域名已更新" }) }, onError: (e) => toast({ title: "更新失败", description: e.message }) })
const remove = useMutation({ mutationFn: api.deleteDomain, onSuccess: () => { invalidateAdmin(qc); toast({ title: "域名已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) }) const remove = useMutation({ mutationFn: api.deleteDomain, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "域名已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) })
return ( return (
<Card> <Card>
<CardHeader><CardTitle></CardTitle></CardHeader> <CardHeader>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<CardTitle></CardTitle>
<CreateDomainDialog />
</div>
</CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{domains.map((domain) => ( {domains.map((domain) => (
<div key={domain.id} className="flex flex-col gap-3 rounded-lg border p-4 md:flex-row md:items-center md:justify-between"> <div key={domain.id} className="flex flex-col gap-3 rounded-lg border p-4 md:flex-row md:items-center md:justify-between">
@@ -199,12 +254,13 @@ function DomainsSection({ domains }: { domains: Domain[] }) {
<Badge variant={domain.dnsStatus === "ok" ? "default" : "secondary"}>{domain.dnsStatus === "ok" ? "DNS 正常" : domain.dnsStatus}</Badge> <Badge variant={domain.dnsStatus === "ok" ? "default" : "secondary"}>{domain.dnsStatus === "ok" ? "DNS 正常" : domain.dnsStatus}</Badge>
<DomainDNSDialog domain={domain} /> <DomainDNSDialog domain={domain} />
<Button variant="outline" size="sm" onClick={() => update.mutate({ id: domain.id, status: domain.status === "active" ? "disabled" : "active" })}>{domain.status === "active" ? "停用" : "启用"}</Button> <Button variant="outline" size="sm" onClick={() => update.mutate({ id: domain.id, status: domain.status === "active" ? "disabled" : "active" })}>{domain.status === "active" ? "停用" : "启用"}</Button>
<Button variant="outline" size="sm" onClick={() => remove.mutate(domain.id)}><Trash2 className="h-4 w-4" /></Button> <Button variant="outline" size="sm" onClick={() => setPendingConfirm({ title: "删除域名?", description: `将删除 ${domain.name},相关邮箱、别名和邮件也可能受影响。`, confirmText: "删除域名", onConfirm: () => remove.mutate(domain.id) })}><Trash2 className="h-4 w-4" /></Button>
</div> </div>
</div> </div>
))} ))}
{domains.length === 0 && <Empty text="暂无域名" />} {domains.length === 0 && <Empty text="暂无域名" />}
</CardContent> </CardContent>
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive pending={remove.isPending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
</Card> </Card>
) )
} }
@@ -223,13 +279,19 @@ function DomainDNSDialog({ domain }: { domain: Domain }) {
) )
} }
function MailboxesSection({ mailboxes, users }: { mailboxes: MailboxType[]; users: AdminUser[] }) { function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxType[]; users: AdminUser[]; domains: Domain[] }) {
const qc = useQueryClient() const qc = useQueryClient()
const { toast } = useToast() const { toast } = useToast()
const remove = useMutation({ mutationFn: api.deleteMailbox, onSuccess: () => { invalidateAdmin(qc); toast({ title: "邮箱已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) }) const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
const remove = useMutation({ mutationFn: api.deleteMailbox, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "邮箱已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) })
return ( return (
<Card> <Card>
<CardHeader><CardTitle></CardTitle></CardHeader> <CardHeader>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<CardTitle></CardTitle>
<CreateMailboxDialog domains={domains} users={users} />
</div>
</CardHeader>
<CardContent> <CardContent>
<Table> <Table>
<TableHeader><TableRow><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead className="w-16"></TableHead></TableRow></TableHeader> <TableHeader><TableRow><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead className="w-16"></TableHead></TableRow></TableHeader>
@@ -241,12 +303,14 @@ function MailboxesSection({ mailboxes, users }: { mailboxes: MailboxType[]; user
<TableCell>{mailbox.displayName}</TableCell> <TableCell>{mailbox.displayName}</TableCell>
<TableCell>{mailbox.quotaMb} MB</TableCell> <TableCell>{mailbox.quotaMb} MB</TableCell>
<TableCell><Badge variant={mailbox.status === "active" ? "default" : "secondary"}>{mailbox.status === "active" ? "启用" : "停用"}</Badge></TableCell> <TableCell><Badge variant={mailbox.status === "active" ? "default" : "secondary"}>{mailbox.status === "active" ? "启用" : "停用"}</Badge></TableCell>
<TableCell><MailboxActions mailbox={mailbox} users={users} onDelete={() => remove.mutate(mailbox.id)} /></TableCell> <TableCell><MailboxActions mailbox={mailbox} users={users} onDelete={() => setPendingConfirm({ title: "删除邮箱?", description: `将删除 ${mailbox.address} 和其中邮件。`, confirmText: "删除邮箱", onConfirm: () => remove.mutate(mailbox.id) })} /></TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
{mailboxes.length === 0 && <Empty text="暂无邮箱账号" />}
</CardContent> </CardContent>
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive pending={remove.isPending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
</Card> </Card>
) )
} }
@@ -254,11 +318,17 @@ function MailboxesSection({ mailboxes, users }: { mailboxes: MailboxType[]; user
function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domain[] }) { function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domain[] }) {
const qc = useQueryClient() const qc = useQueryClient()
const { toast } = useToast() const { toast } = useToast()
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
const update = useMutation({ mutationFn: ({ id, payload }: { id: string; payload: { source: string; destination: string; enabled: boolean } }) => api.updateAlias(id, payload), onSuccess: () => { invalidateAdmin(qc); toast({ title: "别名已更新" }) }, onError: (e) => toast({ title: "更新失败", description: e.message }) }) const update = useMutation({ mutationFn: ({ id, payload }: { id: string; payload: { source: string; destination: string; enabled: boolean } }) => api.updateAlias(id, payload), onSuccess: () => { invalidateAdmin(qc); toast({ title: "别名已更新" }) }, onError: (e) => toast({ title: "更新失败", description: e.message }) })
const remove = useMutation({ mutationFn: api.deleteAlias, onSuccess: () => { invalidateAdmin(qc); toast({ title: "别名已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) }) const remove = useMutation({ mutationFn: api.deleteAlias, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "别名已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) })
return ( return (
<Card> <Card>
<CardHeader><CardTitle>/</CardTitle></CardHeader> <CardHeader>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<CardTitle>/</CardTitle>
<CreateAliasDialog domains={domains} />
</div>
</CardHeader>
<CardContent> <CardContent>
<Table> <Table>
<TableHeader><TableRow><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead className="w-16"></TableHead></TableRow></TableHeader> <TableHeader><TableRow><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead className="w-16"></TableHead></TableRow></TableHeader>
@@ -269,12 +339,14 @@ function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domai
<TableCell>{alias.destination}</TableCell> <TableCell>{alias.destination}</TableCell>
<TableCell className="text-muted-foreground">{domains.find((d) => d.id === alias.domainId)?.name || alias.domainId}</TableCell> <TableCell className="text-muted-foreground">{domains.find((d) => d.id === alias.domainId)?.name || alias.domainId}</TableCell>
<TableCell><Badge variant={alias.enabled ? "default" : "secondary"}>{alias.enabled ? "启用" : "停用"}</Badge></TableCell> <TableCell><Badge variant={alias.enabled ? "default" : "secondary"}>{alias.enabled ? "启用" : "停用"}</Badge></TableCell>
<TableCell><AliasActions alias={alias} onToggle={() => update.mutate({ id: alias.id, payload: { source: alias.source, destination: alias.destination, enabled: !alias.enabled } })} onDelete={() => remove.mutate(alias.id)} /></TableCell> <TableCell><AliasActions alias={alias} onToggle={() => update.mutate({ id: alias.id, payload: { source: alias.source, destination: alias.destination, enabled: !alias.enabled } })} onDelete={() => setPendingConfirm({ title: "删除别名?", description: `${alias.source} 将不再转发到 ${alias.destination}`, confirmText: "删除别名", onConfirm: () => remove.mutate(alias.id) })} /></TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
{aliases.length === 0 && <Empty text="暂无别名转发" />}
</CardContent> </CardContent>
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive pending={remove.isPending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
</Card> </Card>
) )
} }
@@ -285,16 +357,19 @@ function AdminMessagesSection({ mailboxes }: { mailboxes: MailboxType[] }) {
const [mailboxId, setMailboxId] = React.useState("all") const [mailboxId, setMailboxId] = React.useState("all")
const [folder, setFolder] = React.useState("all") const [folder, setFolder] = React.useState("all")
const [selectedId, setSelectedId] = React.useState<string | null>(null) const [selectedId, setSelectedId] = React.useState<string | null>(null)
const messages = useQuery({ const messages = useInfiniteQuery({
queryKey: ["admin", "messages", mailboxId, folder, query], queryKey: ["admin", "messages", mailboxId, folder, query],
queryFn: () => api.adminMessages({ queryFn: ({ pageParam }) => api.adminMessages({
mailboxId: mailboxId === "all" ? "" : mailboxId, mailboxId: mailboxId === "all" ? "" : mailboxId,
folder: folder === "all" ? "" : folder, folder: folder === "all" ? "" : folder,
q: query, q: query,
cursor: typeof pageParam === "string" ? pageParam : "",
}), }),
initialPageParam: "",
getNextPageParam: (lastPage) => lastPage.nextCursor || undefined,
}) })
const detail = useQuery({ queryKey: ["admin", "message", selectedId], queryFn: () => api.adminMessage(selectedId!), enabled: !!selectedId }) const detail = useQuery({ queryKey: ["admin", "message", selectedId], queryFn: () => api.adminMessage(selectedId!), enabled: !!selectedId })
const items = messages.data?.items || [] const items = messages.data?.pages.flatMap((page) => page.items || []) || []
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -366,6 +441,13 @@ function AdminMessagesSection({ mailboxes }: { mailboxes: MailboxType[] }) {
</Table> </Table>
{messages.isLoading && <Empty text="加载中..." />} {messages.isLoading && <Empty text="加载中..." />}
{!messages.isLoading && items.length === 0 && <Empty text="暂无邮件" />} {!messages.isLoading && items.length === 0 && <Empty text="暂无邮件" />}
{!messages.isLoading && messages.hasNextPage && (
<div className="flex justify-center">
<Button variant="outline" size="sm" disabled={messages.isFetchingNextPage} onClick={() => messages.fetchNextPage()}>
{messages.isFetchingNextPage ? "加载中..." : "加载更多"}
</Button>
</div>
)}
</CardContent> </CardContent>
<AdminMessageDialog message={detail.data} loading={detail.isLoading} open={!!selectedId} onOpenChange={(open) => { if (!open) setSelectedId(null) }} /> <AdminMessageDialog message={detail.data} loading={detail.isLoading} open={!!selectedId} onOpenChange={(open) => { if (!open) setSelectedId(null) }} />
</Card> </Card>
+105 -16
View File
@@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react"
import DOMPurify from "dompurify" import DOMPurify from "dompurify"
import { marked } from "marked" import { marked } from "marked"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { type InfiniteData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import type { ImperativePanelHandle } from "react-resizable-panels" import type { ImperativePanelHandle } from "react-resizable-panels"
import { Archive, ArrowLeft, Bold, Check, ChevronsUpDown, Code2, Copy, Forward, Image, Inbox, Italic, Link, List, ListOrdered, Mail, MailCheck, Minus, Moon, PanelLeftClose, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, RefreshCcw, Reply, Search, Send, Settings, SlidersHorizontal, Star, Strikethrough, Sun, Tag, Trash2, WrapText, X } from "lucide-react" import { Archive, ArrowLeft, Bold, Check, ChevronsUpDown, Code2, Copy, Forward, Image, Inbox, Italic, Link, List, ListOrdered, Mail, MailCheck, Minus, Moon, PanelLeftClose, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, RefreshCcw, Reply, Search, Send, Settings, SlidersHorizontal, Star, Strikethrough, Sun, Tag, Trash2, WrapText, X } from "lucide-react"
@@ -23,6 +23,7 @@ import { Separator } from "@/components/ui/separator"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Avatar, AvatarFallback } from "@/components/ui/avatar" import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable" import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"
import { ConfirmDialog } from "@/components/confirm-dialog"
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -51,6 +52,8 @@ const folderLabels: Record<string, string> = {
type ComposeDraft = { key: string; to?: string; cc?: string; bcc?: string; subject?: string; text?: string } type ComposeDraft = { key: string; to?: string; cc?: string; bcc?: string; subject?: string; text?: string }
type MailFilter = "all" | "unread" | "starred" | "attachments" type MailFilter = "all" | "unread" | "starred" | "attachments"
type MailView = "folder" | "starred" | "label" type MailView = "folder" | "starred" | "label"
type MailListResponse = { items?: MailMessage[]; nextCursor?: string }
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
type MailMenuItem = type MailMenuItem =
| { type: "starred"; key: string; label: string; icon: React.ReactNode; count: number } | { type: "starred"; key: string; label: string; icon: React.ReactNode; count: number }
| { type: "folder"; key: string; folderName: string; label: string; icon: React.ReactNode; count: number } | { type: "folder"; key: string; folderName: string; label: string; icon: React.ReactNode; count: number }
@@ -82,6 +85,7 @@ export function MailPage() {
const [displayMode] = useDisplayMode() const [displayMode] = useDisplayMode()
const [refreshing, setRefreshing] = React.useState(false) const [refreshing, setRefreshing] = React.useState(false)
const [bulkPending, setBulkPending] = React.useState(false) const [bulkPending, setBulkPending] = React.useState(false)
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
const sidebarPanelRef = React.useRef<ImperativePanelHandle>(null) const sidebarPanelRef = React.useRef<ImperativePanelHandle>(null)
const themeMountedRef = React.useRef(false) const themeMountedRef = React.useRef(false)
@@ -89,24 +93,34 @@ export function MailPage() {
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings }) const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
const selectedMailbox = React.useMemo(() => mailboxList.data?.items.find((item) => item.id === selectedMailboxId), [mailboxList.data?.items, selectedMailboxId]) const selectedMailbox = React.useMemo(() => mailboxList.data?.items.find((item) => item.id === selectedMailboxId), [mailboxList.data?.items, selectedMailboxId])
const activeMailboxId = selectedMailbox?.id || "" const activeMailboxId = selectedMailbox?.id || ""
const hasMailboxes = (mailboxList.data?.items.length || 0) > 0
const folders = useQuery({ queryKey: ["folders", activeMailboxId], queryFn: () => api.folders(activeMailboxId), enabled: !!activeMailboxId }) const folders = useQuery({ queryKey: ["folders", activeMailboxId], queryFn: () => api.folders(activeMailboxId), enabled: !!activeMailboxId })
const labels = useQuery({ queryKey: ["labels", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId }) const labels = useQuery({ queryKey: ["labels", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId })
const mailStats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId }) const mailStats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId })
const messages = useQuery({ const messages = useInfiniteQuery({
queryKey: ["messages", activeMailboxId, mailView, folder, selectedLabelId, query], queryKey: ["messages", activeMailboxId, mailView, folder, selectedLabelId, query],
queryFn: () => { queryFn: ({ pageParam }) => {
if (mailView === "starred") return api.starredMessages(query, "", activeMailboxId) const cursor = typeof pageParam === "string" ? pageParam : ""
if (mailView === "label") return api.labelMessages(selectedLabelId, query, "", activeMailboxId) if (mailView === "starred") return api.starredMessages(query, cursor, activeMailboxId)
return api.messages(folder, query, "", activeMailboxId) if (mailView === "label") return api.labelMessages(selectedLabelId, query, cursor, activeMailboxId)
return api.messages(folder, query, cursor, activeMailboxId)
}, },
initialPageParam: "",
getNextPageParam: (lastPage) => lastPage.nextCursor || undefined,
enabled: !!activeMailboxId && (mailView !== "label" || !!selectedLabelId), enabled: !!activeMailboxId && (mailView !== "label" || !!selectedLabelId),
}) })
const detail = useQuery({ queryKey: ["message", selectedId], queryFn: () => api.message(selectedId!, { markRead: false }), enabled: !!selectedId }) const detail = useQuery({ queryKey: ["message", selectedId], queryFn: () => api.message(selectedId!, { markRead: false }), enabled: !!selectedId })
function updateCachedMessage(id: string, patch: Partial<MailMessage>) { function updateCachedMessage(id: string, patch: Partial<MailMessage>) {
qc.setQueryData(["message", id], (current: MailMessage | undefined) => current ? { ...current, ...patch } : current) qc.setQueryData(["message", id], (current: MailMessage | undefined) => current ? { ...current, ...patch } : current)
qc.setQueriesData({ queryKey: ["messages"] }, (current: { items?: MailMessage[] } | undefined) => { qc.setQueriesData({ queryKey: ["messages"] }, (current: InfiniteData<MailListResponse> | undefined) => {
if (!current?.items) return current if (!current?.pages) return current
return { ...current, items: current.items.map((message) => message.id === id ? { ...message, ...patch } : message) } return {
...current,
pages: current.pages.map((page) => ({
...page,
items: (page.items || []).map((message) => message.id === id ? { ...message, ...patch } : message),
})),
}
}) })
} }
const star = useMutation({ const star = useMutation({
@@ -157,7 +171,7 @@ export function MailPage() {
}, },
onError: (error) => toast({ title: "创建标签失败", description: error.message }), onError: (error) => toast({ title: "创建标签失败", description: error.message }),
}) })
const del = useMutation({ mutationFn: (id: string) => api.delete(id), onSuccess: async () => { setSelectedId(null); await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["folders"] }); await qc.invalidateQueries({ queryKey: ["mail-stats"] }); await qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "已删除" }) } }) const del = useMutation({ mutationFn: (id: string) => api.delete(id), onSuccess: async () => { setSelectedId(null); setPendingConfirm(null); await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["folders"] }); await qc.invalidateQueries({ queryKey: ["mail-stats"] }); await qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "已删除" }) }, onError: (error) => toast({ title: "删除失败", description: error.message }) })
const move = useMutation({ mutationFn: ({ id, folder }: { id: string; folder: string }) => api.move(id, folder), onSuccess: async () => { setSelectedId(null); await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["folders"] }); await qc.invalidateQueries({ queryKey: ["mail-stats"] }); await qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "已移动" }) } }) const move = useMutation({ mutationFn: ({ id, folder }: { id: string; folder: string }) => api.move(id, folder), onSuccess: async () => { setSelectedId(null); await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["folders"] }); await qc.invalidateQueries({ queryKey: ["mail-stats"] }); await qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "已移动" }) } })
const markAllRead = useMutation({ const markAllRead = useMutation({
mutationFn: async (items: MailMessage[]) => { mutationFn: async (items: MailMessage[]) => {
@@ -233,7 +247,7 @@ export function MailPage() {
}, [publicSettings.data?.mailAutoRefresh, publicSettings.data?.mailRefreshMs, qc]) }, [publicSettings.data?.mailAutoRefresh, publicSettings.data?.mailRefreshMs, qc])
const selected = detail.data const selected = detail.data
const allMessages = messages.data?.items || [] const allMessages = messages.data?.pages.flatMap((page) => page.items || []) || []
const visibleMessages = allMessages.filter((message) => { const visibleMessages = allMessages.filter((message) => {
if (mailFilter === "unread") return !message.isRead if (mailFilter === "unread") return !message.isRead
if (mailFilter === "starred") return message.isStarred if (mailFilter === "starred") return message.isStarred
@@ -251,6 +265,8 @@ export function MailPage() {
const selectedCountOnPage = compactSelectedIds.filter((id) => visibleMessageIds.includes(id)).length const selectedCountOnPage = compactSelectedIds.filter((id) => visibleMessageIds.includes(id)).length
const compactAllSelected = visibleMessageIds.length > 0 && selectedCountOnPage === visibleMessageIds.length const compactAllSelected = visibleMessageIds.length > 0 && selectedCountOnPage === visibleMessageIds.length
const compactSomeSelected = selectedCountOnPage > 0 && !compactAllSelected const compactSomeSelected = selectedCountOnPage > 0 && !compactAllSelected
const hasMoreMessages = !!messages.hasNextPage
const canLoadMore = !!messages.hasNextPage && !messages.isFetchingNextPage
function toggleCompactSelectAll(checked: boolean) { function toggleCompactSelectAll(checked: boolean) {
setCompactSelectedIds(checked ? visibleMessageIds : []) setCompactSelectedIds(checked ? visibleMessageIds : [])
} }
@@ -268,6 +284,18 @@ export function MailPage() {
async function runBulkAction(action: BulkAction) { async function runBulkAction(action: BulkAction) {
const ids = compactSelectedIds.filter((id) => visibleMessageIds.includes(id)) const ids = compactSelectedIds.filter((id) => visibleMessageIds.includes(id))
if (ids.length === 0) return if (ids.length === 0) return
if (action === "delete") {
setPendingConfirm({
title: "删除所选邮件?",
description: `将删除当前选中的 ${ids.length} 封邮件,此操作无法从邮件列表中恢复。`,
confirmText: "删除邮件",
onConfirm: () => runConfirmedBulkAction("delete", ids),
})
return
}
await runConfirmedBulkAction(action, ids)
}
async function runConfirmedBulkAction(action: BulkAction, ids: string[]) {
setBulkPending(true) setBulkPending(true)
try { try {
if (action === "read" || action === "unread") { if (action === "read" || action === "unread") {
@@ -284,6 +312,7 @@ export function MailPage() {
} }
if (selectedId && ids.includes(selectedId)) setSelectedId(null) if (selectedId && ids.includes(selectedId)) setSelectedId(null)
setCompactSelectedIds([]) setCompactSelectedIds([])
setPendingConfirm(null)
await refreshMailData() await refreshMailData()
toast({ title: `已处理 ${ids.length} 封邮件` }) toast({ title: `已处理 ${ids.length} 封邮件` })
} catch (error) { } catch (error) {
@@ -292,6 +321,14 @@ export function MailPage() {
setBulkPending(false) setBulkPending(false)
} }
} }
function confirmDeleteMessage(message: MailMessage) {
setPendingConfirm({
title: "删除这封邮件?",
description: `邮件“${message.subject || "无主题"}”将被删除。`,
confirmText: "删除邮件",
onConfirm: () => del.mutate(message.id),
})
}
function openCompose(draft?: ComposeDraft) { setComposeDraft(draft || { key: `new-${Date.now()}` }); setComposeOpen(true) } function openCompose(draft?: ComposeDraft) { setComposeDraft(draft || { key: `new-${Date.now()}` }); setComposeOpen(true) }
function openReply(message: MailMessage) { openCompose({ key: `reply-${message.id}-${Date.now()}`, to: message.from, subject: withPrefix(message.subject, "Re:"), text: quoteMessage(message) }) } function openReply(message: MailMessage) { openCompose({ key: `reply-${message.id}-${Date.now()}`, to: message.from, subject: withPrefix(message.subject, "Re:"), text: quoteMessage(message) }) }
function openForward(message: MailMessage) { openCompose({ key: `forward-${message.id}-${Date.now()}`, subject: withPrefix(message.subject, "Fwd:"), text: quoteMessage(message) }) } function openForward(message: MailMessage) { openCompose({ key: `forward-${message.id}-${Date.now()}`, subject: withPrefix(message.subject, "Fwd:"), text: quoteMessage(message) }) }
@@ -383,7 +420,7 @@ export function MailPage() {
</Button> </Button>
)} )}
</div> </div>
<Button className={cn("mt-2 h-10 w-full rounded-md text-sm", sidebarCollapsed && "px-0")} size={sidebarCollapsed ? "icon" : "default"} onClick={() => openCompose()}> <Button className={cn("mt-2 h-10 w-full rounded-md text-sm", sidebarCollapsed && "px-0")} size={sidebarCollapsed ? "icon" : "default"} onClick={() => openCompose()} disabled={!selectedMailbox}>
<PencilLine className="h-4 w-4" /> <PencilLine className="h-4 w-4" />
{!sidebarCollapsed && <span></span>} {!sidebarCollapsed && <span></span>}
</Button> </Button>
@@ -447,7 +484,7 @@ export function MailPage() {
<header className="flex h-16 shrink-0 items-center justify-between gap-3 border-b px-5"> <header className="flex h-16 shrink-0 items-center justify-between gap-3 border-b px-5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button size="icon" variant="ghost" onClick={refreshMail} disabled={refreshing} className={cn("transition-all", refreshing && "bg-primary/5 text-primary")}><RefreshCcw className={cn("h-4 w-4", refreshing && "animate-spin")} /></Button> <Button size="icon" variant="ghost" onClick={refreshMail} disabled={refreshing} className={cn("transition-all", refreshing && "bg-primary/5 text-primary")}><RefreshCcw className={cn("h-4 w-4", refreshing && "animate-spin")} /></Button>
<Button variant="outline" size="sm" disabled={markAllRead.isPending || unreadCount === 0} onClick={() => markAllRead.mutate(allMessages)}><MailCheck className="h-4 w-4" /></Button> <Button variant="outline" size="sm" disabled={!activeMailboxId || markAllRead.isPending || unreadCount === 0} onClick={() => markAllRead.mutate(allMessages)}><MailCheck className="h-4 w-4" /></Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="sm"><SlidersHorizontal className="h-4 w-4" />{filterLabels[mailFilter]}</Button> <Button variant="outline" size="sm"><SlidersHorizontal className="h-4 w-4" />{filterLabels[mailFilter]}</Button>
@@ -467,7 +504,9 @@ export function MailPage() {
</div> </div>
</header> </header>
{displayMode === "compact" ? ( {!mailboxList.isLoading && !hasMailboxes ? (
<NoMailboxState onOpenSettings={openSettings} />
) : displayMode === "compact" ? (
<CompactMailView <CompactMailView
title={viewTitle} title={viewTitle}
icon={mailView === "label" && selectedLabel ? <Tag className="h-4 w-4" style={{ color: selectedLabel.color }} /> : undefined} icon={mailView === "label" && selectedLabel ? <Tag className="h-4 w-4" style={{ color: selectedLabel.color }} /> : undefined}
@@ -477,6 +516,9 @@ export function MailPage() {
allSelected={compactAllSelected} allSelected={compactAllSelected}
someSelected={compactSomeSelected} someSelected={compactSomeSelected}
loading={messages.isLoading} loading={messages.isLoading}
hasMore={hasMoreMessages}
loadingMore={messages.isFetchingNextPage}
onLoadMore={() => messages.fetchNextPage()}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
selectedId={selectedId} selectedId={selectedId}
selected={selected} selected={selected}
@@ -491,7 +533,7 @@ export function MailPage() {
onReply={openReply} onReply={openReply}
onForward={openForward} onForward={openForward}
onArchive={(message) => move.mutate({ id: message.id, folder: message.folder === "Archive" ? "Inbox" : "Archive" })} onArchive={(message) => move.mutate({ id: message.id, folder: message.folder === "Archive" ? "Inbox" : "Archive" })}
onDelete={(message) => del.mutate(message.id)} onDelete={confirmDeleteMessage}
onToggleRead={(message) => markRead.mutate({ id: message.id, read: !message.isRead })} onToggleRead={(message) => markRead.mutate({ id: message.id, read: !message.isRead })}
onAddLabel={(message, label) => addLabel.mutate({ id: message.id, label })} onAddLabel={(message, label) => addLabel.mutate({ id: message.id, label })}
onRemoveLabel={(message, labelId) => removeLabel.mutate({ id: message.id, labelId })} onRemoveLabel={(message, labelId) => removeLabel.mutate({ id: message.id, labelId })}
@@ -516,6 +558,13 @@ export function MailPage() {
{messages.isLoading && <MessageSkeleton />} {messages.isLoading && <MessageSkeleton />}
{visibleMessages.map((m) => <MessageRow key={m.id} message={m} active={selectedId === m.id} checked={compactSelectedIds.includes(m.id)} onCheckedChange={(checked) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} />)} {visibleMessages.map((m) => <MessageRow key={m.id} message={m} active={selectedId === m.id} checked={compactSelectedIds.includes(m.id)} onCheckedChange={(checked) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} />)}
{!messages.isLoading && visibleMessages.length === 0 && <div className="p-8 text-center text-sm text-muted-foreground">{emptyMessage}</div>} {!messages.isLoading && visibleMessages.length === 0 && <div className="p-8 text-center text-sm text-muted-foreground">{emptyMessage}</div>}
{!messages.isLoading && hasMoreMessages && (
<div className="border-b p-4 text-center">
<Button variant="outline" size="sm" disabled={!canLoadMore} onClick={() => messages.fetchNextPage()}>
{messages.isFetchingNextPage ? "加载中..." : "加载更多"}
</Button>
</div>
)}
</ScrollArea> </ScrollArea>
</div> </div>
</ResizablePanel> </ResizablePanel>
@@ -537,7 +586,7 @@ export function MailPage() {
) : ( ) : (
<Button variant="outline" size="sm" onClick={() => move.mutate({ id: selected.id, folder: "Archive" })}></Button> <Button variant="outline" size="sm" onClick={() => move.mutate({ id: selected.id, folder: "Archive" })}></Button>
)} )}
<Button variant="destructive" size="sm" onClick={() => del.mutate(selected.id)}></Button> <Button variant="destructive" size="sm" onClick={() => confirmDeleteMessage(selected)}></Button>
</div> </div>
</div> </div>
<div className="text-sm text-muted-foreground"><span className="font-medium text-foreground">{selected.from}</span> {selected.to.join(", ")} · {formatDateTime(selected.receivedAt)}</div> <div className="text-sm text-muted-foreground"><span className="font-medium text-foreground">{selected.from}</span> {selected.to.join(", ")} · {formatDateTime(selected.receivedAt)}</div>
@@ -567,6 +616,16 @@ export function MailPage() {
</SidebarProvider> </SidebarProvider>
<ComposeDialog mailbox={selectedMailbox} open={composeOpen} draft={composeDraft} onOpenChange={(open) => { setComposeOpen(open); if (!open) setComposeDraft(undefined) }} onSent={() => { setComposeOpen(false); setComposeDraft(undefined); qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["mail-stats"] }); qc.invalidateQueries({ queryKey: ["labels"] }) }} /> <ComposeDialog mailbox={selectedMailbox} open={composeOpen} draft={composeDraft} onOpenChange={(open) => { setComposeOpen(open); if (!open) setComposeDraft(undefined) }} onSent={() => { setComposeOpen(false); setComposeDraft(undefined); qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["mail-stats"] }); qc.invalidateQueries({ queryKey: ["labels"] }) }} />
<ConfirmDialog
open={!!pendingConfirm}
title={pendingConfirm?.title || ""}
description={pendingConfirm?.description}
confirmText={pendingConfirm?.confirmText || "确认"}
destructive
pending={del.isPending || bulkPending}
onOpenChange={(open) => { if (!open) setPendingConfirm(null) }}
onConfirm={() => pendingConfirm?.onConfirm()}
/>
</div> </div>
) )
} }
@@ -589,6 +648,23 @@ function buildMailMenuItems(folders: MailFolder[], starredCount: number): MailMe
function FolderSkeleton() { return <div className="space-y-2 p-2"><Skeleton className="h-8 w-full" /><Skeleton className="h-8 w-4/5" /><Skeleton className="h-8 w-3/4" /></div> } function FolderSkeleton() { return <div className="space-y-2 p-2"><Skeleton className="h-8 w-full" /><Skeleton className="h-8 w-4/5" /><Skeleton className="h-8 w-3/4" /></div> }
function MessageSkeleton() { return <div className="space-y-0">{Array.from({ length: 6 }).map((_, i) => <div className="space-y-2 border-b p-4" key={i}><Skeleton className="h-4 w-1/2" /><Skeleton className="h-4 w-4/5" /><Skeleton className="h-3 w-full" /></div>)}</div> } function MessageSkeleton() { return <div className="space-y-0">{Array.from({ length: 6 }).map((_, i) => <div className="space-y-2 border-b p-4" key={i}><Skeleton className="h-4 w-1/2" /><Skeleton className="h-4 w-4/5" /><Skeleton className="h-3 w-full" /></div>)}</div> }
function NoMailboxState({ onOpenSettings }: { onOpenSettings: () => void }) {
return (
<div className="grid min-h-0 flex-1 place-items-center p-6">
<div className="w-full max-w-md rounded-lg border border-dashed p-8 text-center">
<div className="mx-auto mb-4 grid size-12 place-items-center rounded-full bg-muted">
<Mail className="h-5 w-5 text-muted-foreground" />
</div>
<div className="text-lg font-semibold"></div>
<div className="mt-2 text-sm text-muted-foreground"></div>
<Button className="mt-5" onClick={onOpenSettings}>
<Settings className="h-4 w-4" />
</Button>
</div>
</div>
)
}
type BulkAction = "read" | "unread" | "star" | "unstar" | "archive" | "trash" | "spam" | "delete" type BulkAction = "read" | "unread" | "star" | "unstar" | "archive" | "trash" | "spam" | "delete"
function BulkActionMenu({ pending, onAction }: { pending: boolean; onAction: (action: BulkAction) => void }) { function BulkActionMenu({ pending, onAction }: { pending: boolean; onAction: (action: BulkAction) => void }) {
@@ -622,6 +698,8 @@ function CompactMailView({
allSelected, allSelected,
someSelected, someSelected,
loading, loading,
hasMore,
loadingMore,
emptyMessage, emptyMessage,
selectedId, selectedId,
selected, selected,
@@ -631,6 +709,7 @@ function CompactMailView({
onSelect, onSelect,
onSelectAll, onSelectAll,
onToggleSelected, onToggleSelected,
onLoadMore,
onCloseReader, onCloseReader,
onStar, onStar,
onReply, onReply,
@@ -651,6 +730,8 @@ function CompactMailView({
allSelected: boolean allSelected: boolean
someSelected: boolean someSelected: boolean
loading: boolean loading: boolean
hasMore: boolean
loadingMore: boolean
emptyMessage: string emptyMessage: string
selectedId: string | null selectedId: string | null
selected?: MailMessage selected?: MailMessage
@@ -660,6 +741,7 @@ function CompactMailView({
onSelect: (id: string | null) => void onSelect: (id: string | null) => void
onSelectAll: (checked: boolean) => void onSelectAll: (checked: boolean) => void
onToggleSelected: (id: string, checked: boolean) => void onToggleSelected: (id: string, checked: boolean) => void
onLoadMore: () => void
onCloseReader: () => void onCloseReader: () => void
onStar: (message: MailMessage) => void onStar: (message: MailMessage) => void
onReply: (message: MailMessage) => void onReply: (message: MailMessage) => void
@@ -721,6 +803,13 @@ function CompactMailView({
{loading && <MessageSkeleton />} {loading && <MessageSkeleton />}
{messages.map((message) => <CompactMessageRow key={message.id} message={message} active={selectedId === message.id} checked={selectedIds.includes(message.id)} onCheckedChange={(checked) => onToggleSelected(message.id, checked)} onClick={() => onSelect(message.id)} onStar={() => onStar(message)} />)} {messages.map((message) => <CompactMessageRow key={message.id} message={message} active={selectedId === message.id} checked={selectedIds.includes(message.id)} onCheckedChange={(checked) => onToggleSelected(message.id, checked)} onClick={() => onSelect(message.id)} onStar={() => onStar(message)} />)}
{!loading && messages.length === 0 && <div className="p-8 text-center text-sm text-muted-foreground">{emptyMessage}</div>} {!loading && messages.length === 0 && <div className="p-8 text-center text-sm text-muted-foreground">{emptyMessage}</div>}
{!loading && hasMore && (
<div className="border-b p-4 text-center">
<Button variant="outline" size="sm" disabled={loadingMore} onClick={onLoadMore}>
{loadingMore ? "加载中..." : "加载更多"}
</Button>
</div>
)}
</ScrollArea> </ScrollArea>
</div> </div>
) )
+96 -4
View File
@@ -25,9 +25,11 @@ import { Separator } from "@/components/ui/separator"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable" import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"
import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider } from "@/components/ui/sidebar" import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider } from "@/components/ui/sidebar"
import { ConfirmDialog } from "@/components/confirm-dialog"
import { useToast } from "@/hooks/use-toast" import { useToast } from "@/hooks/use-toast"
type Tab = "profile" | "mailboxes" | "contacts" | "cleanup" | "rules" | "blocked" | "stats" type Tab = "profile" | "mailboxes" | "contacts" | "cleanup" | "rules" | "blocked" | "stats"
type PendingConfirm = { title: string; description?: string; confirmText: string; destructive?: boolean; onConfirm: () => void }
const tabs: Record<Tab, { label: string; icon: React.ReactNode }> = { const tabs: Record<Tab, { label: string; icon: React.ReactNode }> = {
profile: { label: "账户资料", icon: <Settings className="h-4 w-4" /> }, profile: { label: "账户资料", icon: <Settings className="h-4 w-4" /> },
mailboxes: { label: "邮箱管理", icon: <Mail className="h-4 w-4" /> }, mailboxes: { label: "邮箱管理", icon: <Mail className="h-4 w-4" /> },
@@ -436,11 +438,68 @@ function ApplyMailboxDialog({ options, pending, onApply }: { options: MailboxApp
} }
function ContactsSection({ items, loading, onCreate, onDelete, onCopy, pending }: { items: { id: string; name: string; email: string; note: string }[]; loading: boolean; onCreate: (form: FormData) => void; onDelete: (id: string) => void; onCopy: (text: string) => void; pending: boolean }) { function ContactsSection({ items, loading, onCreate, onDelete, onCopy, pending }: { items: { id: string; name: string; email: string; note: string }[]; loading: boolean; onCreate: (form: FormData) => void; onDelete: (id: string) => void; onCopy: (text: string) => void; pending: boolean }) {
return <div className="grid gap-6 lg:grid-cols-[380px_minmax(0,1fr)]"><Card><CardHeader><CardTitle></CardTitle></CardHeader><CardContent><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); onCreate(new FormData(e.currentTarget)); e.currentTarget.reset() }}><Field label="姓名"><Input name="name" placeholder="张三" /></Field><Field label="邮箱"><Input name="email" type="email" required /></Field><Field label="备注"><Input name="note" /></Field><Button className="w-full" disabled={pending}>{pending ? "保存中..." : "保存联系人"}</Button></form></CardContent></Card><Card><CardHeader><CardTitle></CardTitle></CardHeader><CardContent className="space-y-2">{items.map((item) => <div key={item.id} className="flex items-center justify-between gap-3 rounded-lg border p-3"><div className="min-w-0"><div className="truncate text-sm font-medium">{item.name}</div><div className="truncate text-xs text-muted-foreground">{item.email}{item.note ? ` · ${item.note}` : ""}</div></div><div className="flex shrink-0 gap-1"><Button variant="ghost" size="icon" className="size-8" onClick={() => onCopy(item.email)}><Copy className="h-4 w-4" /></Button><Button variant="ghost" size="icon" className="size-8 text-destructive" onClick={() => onDelete(item.id)}><Trash2 className="h-4 w-4" /></Button></div></div>)}{!loading && items.length === 0 && <EmptyState text="暂无联系人" />}</CardContent></Card></div> const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
return (
<div className="grid gap-6 lg:grid-cols-[380px_minmax(0,1fr)]">
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={(e) => { e.preventDefault(); onCreate(new FormData(e.currentTarget)); e.currentTarget.reset() }}>
<Field label="姓名"><Input name="name" placeholder="张三" /></Field>
<Field label="邮箱"><Input name="email" type="email" required /></Field>
<Field label="备注"><Input name="note" /></Field>
<Button className="w-full" disabled={pending}>{pending ? "保存中..." : "保存联系人"}</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent className="space-y-2">
{items.map((item) => (
<div key={item.id} className="flex items-center justify-between gap-3 rounded-lg border p-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{item.name}</div>
<div className="truncate text-xs text-muted-foreground">{item.email}{item.note ? ` · ${item.note}` : ""}</div>
</div>
<div className="flex shrink-0 gap-1">
<Button variant="ghost" size="icon" className="size-8" onClick={() => onCopy(item.email)}><Copy className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon" className="size-8 text-destructive" onClick={() => setPendingConfirm({ title: "删除联系人?", description: `${item.email} 将从联系人列表中移除。`, confirmText: "删除联系人", onConfirm: () => { onDelete(item.id); setPendingConfirm(null) } })}><Trash2 className="h-4 w-4" /></Button>
</div>
</div>
))}
{!loading && items.length === 0 && <EmptyState text="暂无联系人" />}
</CardContent>
</Card>
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
</div>
)
} }
function CleanupSection({ mailbox, stats, pending, onCleanup }: { mailbox?: Mailbox; stats?: MailStats; pending: boolean; onCleanup: (target: "empty-trash" | "empty-spam" | "archive-read-inbox") => void }) { function CleanupSection({ mailbox, stats, pending, onCleanup }: { mailbox?: Mailbox; stats?: MailStats; pending: boolean; onCleanup: (target: "empty-trash" | "empty-spam" | "archive-read-inbox") => void }) {
return <div className="space-y-6"><StatsSummary stats={stats} /><Card><CardHeader><CardTitle></CardTitle></CardHeader><CardContent className="grid gap-3 md:grid-cols-3"><CleanupButton icon={<MailCheck className="h-4 w-4" />} title="归档已读收件箱" disabled={!mailbox || pending} onClick={() => onCleanup("archive-read-inbox")} /><CleanupButton icon={<MailX className="h-4 w-4" />} title="清空垃圾邮件" disabled={!mailbox || pending} onClick={() => onCleanup("empty-spam")} /><CleanupButton icon={<Trash2 className="h-4 w-4" />} title="清空回收站" disabled={!mailbox || pending} onClick={() => onCleanup("empty-trash")} /></CardContent></Card></div> const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
function confirmCleanup(target: "empty-trash" | "empty-spam" | "archive-read-inbox", title: string, destructive = false) {
setPendingConfirm({
title,
description: mailbox ? `将对 ${mailbox.address} 执行此清理操作。` : "请先选择邮箱。",
confirmText: destructive ? "确认清空" : "确认处理",
destructive,
onConfirm: () => { onCleanup(target); setPendingConfirm(null) },
})
}
return (
<div className="space-y-6">
<StatsSummary stats={stats} />
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent className="grid gap-3 md:grid-cols-3">
<CleanupButton icon={<MailCheck className="h-4 w-4" />} title="归档已读收件箱" disabled={!mailbox || pending} onClick={() => confirmCleanup("archive-read-inbox", "归档已读收件箱?")} />
<CleanupButton icon={<MailX className="h-4 w-4" />} title="清空垃圾邮件" disabled={!mailbox || pending} onClick={() => confirmCleanup("empty-spam", "清空垃圾邮件?", true)} />
<CleanupButton icon={<Trash2 className="h-4 w-4" />} title="清空回收站" disabled={!mailbox || pending} onClick={() => confirmCleanup("empty-trash", "清空回收站?", true)} />
</CardContent>
</Card>
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "确认"} destructive={!!pendingConfirm?.destructive} pending={pending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
</div>
)
} }
type RuleCreatePayload = { type RuleCreatePayload = {
@@ -628,6 +687,7 @@ function RuleCheckbox({ checked, onCheckedChange, label }: { checked: boolean; o
function RuleListItem({ item, mailboxes, onDelete }: { item: MailRule; mailboxes: Mailbox[]; onDelete: (id: string) => void }) { function RuleListItem({ item, mailboxes, onDelete }: { item: MailRule; mailboxes: Mailbox[]; onDelete: (id: string) => void }) {
const mailbox = item.mailboxId ? mailboxes.find((m) => m.id === item.mailboxId)?.address : "全部邮箱" const mailbox = item.mailboxId ? mailboxes.find((m) => m.id === item.mailboxId)?.address : "全部邮箱"
const [confirmOpen, setConfirmOpen] = React.useState(false)
return ( return (
<div className="flex items-center justify-between gap-3 rounded-lg border p-3"> <div className="flex items-center justify-between gap-3 rounded-lg border p-3">
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-1">
@@ -638,7 +698,8 @@ function RuleListItem({ item, mailboxes, onDelete }: { item: MailRule; mailboxes
</div> </div>
<div className="truncate text-xs text-muted-foreground">{mailbox} · {item.matchMode === "any" ? "任一条件" : "所有条件"} · {conditionSummary(item.conditions, item.fromContains, item.subjectContains)}</div> <div className="truncate text-xs text-muted-foreground">{mailbox} · {item.matchMode === "any" ? "任一条件" : "所有条件"} · {conditionSummary(item.conditions, item.fromContains, item.subjectContains)}</div>
</div> </div>
<Button variant="ghost" size="icon" className="size-8 shrink-0 text-destructive" onClick={() => onDelete(item.id)}><Trash2 className="h-4 w-4" /></Button> <Button variant="ghost" size="icon" className="size-8 shrink-0 text-destructive" onClick={() => setConfirmOpen(true)}><Trash2 className="h-4 w-4" /></Button>
<ConfirmDialog open={confirmOpen} title="删除收件规则?" description={`规则“${item.name}”将不再处理后续邮件。`} confirmText="删除规则" destructive onOpenChange={setConfirmOpen} onConfirm={() => { onDelete(item.id); setConfirmOpen(false) }} />
</div> </div>
) )
} }
@@ -664,7 +725,38 @@ function actionSummary(action: MailRuleAction) {
} }
function BlockedSection({ items, mailboxes, mailboxId, spamCount, onMailboxChange, onCreate, onDelete, pending }: { items: any[]; mailboxes: Mailbox[]; mailboxId: string; spamCount: number; onMailboxChange: (value: string) => void; onCreate: (form: FormData) => void; onDelete: (id: string) => void; pending: boolean }) { function BlockedSection({ items, mailboxes, mailboxId, spamCount, onMailboxChange, onCreate, onDelete, pending }: { items: any[]; mailboxes: Mailbox[]; mailboxId: string; spamCount: number; onMailboxChange: (value: string) => void; onCreate: (form: FormData) => void; onDelete: (id: string) => void; pending: boolean }) {
return <div className="grid gap-6 lg:grid-cols-[420px_minmax(0,1fr)]"><Card><CardHeader><CardTitle></CardTitle></CardHeader><CardContent><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); onCreate(new FormData(e.currentTarget)); e.currentTarget.reset() }}><Field label="适用邮箱"><MailboxSelect value={mailboxId} mailboxes={mailboxes} onChange={onMailboxChange} /></Field><Field label="发件人邮箱"><Input name="email" type="email" required /></Field><Field label="原因"><Input name="reason" /></Field><Button className="w-full" disabled={pending}>{pending ? "保存中..." : "加入拦截"}</Button></form></CardContent></Card><Card><CardHeader><CardTitle></CardTitle></CardHeader><CardContent className="space-y-2">{items.map((item) => <div key={item.id} className="flex items-center justify-between gap-3 rounded-lg border p-3"><div className="min-w-0"><div className="truncate text-sm font-medium">{item.email}</div><div className="truncate text-xs text-muted-foreground">{item.mailboxId ? mailboxes.find((m) => m.id === item.mailboxId)?.address : "全部邮箱"}{item.reason ? ` · ${item.reason}` : ""}</div></div><Button variant="ghost" size="icon" className="size-8 text-destructive" onClick={() => onDelete(item.id)}><Trash2 className="h-4 w-4" /></Button></div>)}{items.length === 0 && <EmptyState text="暂无拦截发件人" />}</CardContent></Card></div> const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
return (
<div className="grid gap-6 lg:grid-cols-[420px_minmax(0,1fr)]">
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={(e) => { e.preventDefault(); onCreate(new FormData(e.currentTarget)); e.currentTarget.reset() }}>
<Field label="适用邮箱"><MailboxSelect value={mailboxId} mailboxes={mailboxes} onChange={onMailboxChange} /></Field>
<Field label="发件人邮箱"><Input name="email" type="email" required /></Field>
<Field label="原因"><Input name="reason" /></Field>
<Button className="w-full" disabled={pending}>{pending ? "保存中..." : "加入拦截"}</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent className="space-y-2">
{items.map((item) => (
<div key={item.id} className="flex items-center justify-between gap-3 rounded-lg border p-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{item.email}</div>
<div className="truncate text-xs text-muted-foreground">{item.mailboxId ? mailboxes.find((m) => m.id === item.mailboxId)?.address : "全部邮箱"}{item.reason ? ` · ${item.reason}` : ""}</div>
</div>
<Button variant="ghost" size="icon" className="size-8 text-destructive" onClick={() => setPendingConfirm({ title: "移除拦截规则?", description: `${item.email} 之后将不再被此规则拦截。`, confirmText: "移除规则", onConfirm: () => { onDelete(item.id); setPendingConfirm(null) } })}><Trash2 className="h-4 w-4" /></Button>
</div>
))}
{items.length === 0 && <EmptyState text="暂无拦截发件人" />}
</CardContent>
</Card>
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "移除"} destructive onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
</div>
)
} }
function StatsSection({ stats, mailbox, onRefresh }: { stats?: MailStats; mailbox?: Mailbox; onRefresh: () => void }) { function StatsSection({ stats, mailbox, onRefresh }: { stats?: MailStats; mailbox?: Mailbox; onRefresh: () => void }) {