feat(admin,mail,profile): 增强邮件管理与交互体验
- 新增统一确认弹窗,替换多个删除/清理操作的直接执行。 - 管理后台支持首次配置引导、域名/邮箱/别名创建入口,以及邮件列表分页加载更多。 - 邮箱页改为分页拉取邮件,补充无邮箱状态、批量删除确认和“写邮件”可用性控制。 - 个人中心为联系人、清理、规则、拦截操作增加二次确认。 - 将 DKIM 密钥长度提升为 2048 位,增强安全性。
This commit is contained in:
@@ -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
@@ -1,8 +1,8 @@
|
||||
import * as React from "react"
|
||||
import DOMPurify from "dompurify"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { CheckCircle2, Copy, Globe2, Mailbox, MoreHorizontal, Plus, RefreshCcw, Search, ShieldCheck, Trash2, Users } from "lucide-react"
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
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 { cn, formatBytes, formatDate } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -19,9 +19,11 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
|
||||
type Section = "overview" | "users" | "domains" | "mailboxes" | "aliases" | "messages" | "settings"
|
||||
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
|
||||
|
||||
const sectionLabels: Record<Section, string> = {
|
||||
overview: "概览",
|
||||
@@ -74,10 +76,10 @@ export function AdminPage() {
|
||||
</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 === "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 === "messages" && <AdminMessagesSection mailboxes={mailboxItems} />}
|
||||
{section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} />}
|
||||
@@ -85,7 +87,8 @@ export function AdminPage() {
|
||||
</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 (
|
||||
<div className="space-y-6">
|
||||
<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} />
|
||||
</CardContent>
|
||||
</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>
|
||||
<CardHeader><CardTitle>DNS 状态</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
@@ -105,17 +125,45 @@ function OverviewSection({ overview, domains }: { overview?: { activeUsers: numb
|
||||
{domains.length === 0 && <Empty text="暂无域名" />}
|
||||
</CardContent>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
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[] }) {
|
||||
const qc = useQueryClient()
|
||||
const { toast } = useToast()
|
||||
const [query, setQuery] = React.useState("")
|
||||
const [roleFilter, setRoleFilter] = React.useState("all")
|
||||
const [statusFilter, setStatusFilter] = React.useState("all")
|
||||
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||||
const filteredUsers = users.filter((user) => {
|
||||
const keyword = query.trim().toLowerCase()
|
||||
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)
|
||||
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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -168,13 +216,14 @@ function UsersSection({ users }: { users: AdminUser[] }) {
|
||||
<TableCell><UserMailboxCell user={user} /></TableCell>
|
||||
<TableCell><Badge variant={user.disabled ? "secondary" : "default"}>{user.disabled ? "停用" : "正常"}</Badge></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>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{filteredUsers.length === 0 && <Empty text="没有匹配的用户" />}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -182,11 +231,17 @@ function UsersSection({ users }: { users: AdminUser[] }) {
|
||||
function DomainsSection({ domains }: { domains: Domain[] }) {
|
||||
const qc = useQueryClient()
|
||||
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 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 (
|
||||
<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">
|
||||
{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">
|
||||
@@ -199,12 +254,13 @@ function DomainsSection({ domains }: { domains: Domain[] }) {
|
||||
<Badge variant={domain.dnsStatus === "ok" ? "default" : "secondary"}>{domain.dnsStatus === "ok" ? "DNS 正常" : domain.dnsStatus}</Badge>
|
||||
<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={() => 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>
|
||||
))}
|
||||
{domains.length === 0 && <Empty text="暂无域名" />}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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 { 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 (
|
||||
<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>
|
||||
<Table>
|
||||
<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.quotaMb} MB</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>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{mailboxes.length === 0 && <Empty text="暂无邮箱账号" />}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -254,11 +318,17 @@ function MailboxesSection({ mailboxes, users }: { mailboxes: MailboxType[]; user
|
||||
function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domain[] }) {
|
||||
const qc = useQueryClient()
|
||||
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 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 (
|
||||
<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>
|
||||
<Table>
|
||||
<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 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><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>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{aliases.length === 0 && <Empty text="暂无别名转发" />}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -285,16 +357,19 @@ function AdminMessagesSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
||||
const [mailboxId, setMailboxId] = React.useState("all")
|
||||
const [folder, setFolder] = React.useState("all")
|
||||
const [selectedId, setSelectedId] = React.useState<string | null>(null)
|
||||
const messages = useQuery({
|
||||
const messages = useInfiniteQuery({
|
||||
queryKey: ["admin", "messages", mailboxId, folder, query],
|
||||
queryFn: () => api.adminMessages({
|
||||
queryFn: ({ pageParam }) => api.adminMessages({
|
||||
mailboxId: mailboxId === "all" ? "" : mailboxId,
|
||||
folder: folder === "all" ? "" : folder,
|
||||
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 items = messages.data?.items || []
|
||||
const items = messages.data?.pages.flatMap((page) => page.items || []) || []
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -366,6 +441,13 @@ function AdminMessagesSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
||||
</Table>
|
||||
{messages.isLoading && <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>
|
||||
<AdminMessageDialog message={detail.data} loading={detail.isLoading} open={!!selectedId} onOpenChange={(open) => { if (!open) setSelectedId(null) }} />
|
||||
</Card>
|
||||
|
||||
+105
-16
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import DOMPurify from "dompurify"
|
||||
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 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"
|
||||
@@ -23,6 +23,7 @@ import { Separator } from "@/components/ui/separator"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog"
|
||||
import {
|
||||
Sidebar,
|
||||
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 MailFilter = "all" | "unread" | "starred" | "attachments"
|
||||
type MailView = "folder" | "starred" | "label"
|
||||
type MailListResponse = { items?: MailMessage[]; nextCursor?: string }
|
||||
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
|
||||
type MailMenuItem =
|
||||
| { type: "starred"; key: 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 [refreshing, setRefreshing] = React.useState(false)
|
||||
const [bulkPending, setBulkPending] = React.useState(false)
|
||||
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||||
const sidebarPanelRef = React.useRef<ImperativePanelHandle>(null)
|
||||
const themeMountedRef = React.useRef(false)
|
||||
|
||||
@@ -89,24 +93,34 @@ export function MailPage() {
|
||||
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 activeMailboxId = selectedMailbox?.id || ""
|
||||
const hasMailboxes = (mailboxList.data?.items.length || 0) > 0
|
||||
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 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],
|
||||
queryFn: () => {
|
||||
if (mailView === "starred") return api.starredMessages(query, "", activeMailboxId)
|
||||
if (mailView === "label") return api.labelMessages(selectedLabelId, query, "", activeMailboxId)
|
||||
return api.messages(folder, query, "", activeMailboxId)
|
||||
queryFn: ({ pageParam }) => {
|
||||
const cursor = typeof pageParam === "string" ? pageParam : ""
|
||||
if (mailView === "starred") return api.starredMessages(query, cursor, 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),
|
||||
})
|
||||
const detail = useQuery({ queryKey: ["message", selectedId], queryFn: () => api.message(selectedId!, { markRead: false }), enabled: !!selectedId })
|
||||
function updateCachedMessage(id: string, patch: Partial<MailMessage>) {
|
||||
qc.setQueryData(["message", id], (current: MailMessage | undefined) => current ? { ...current, ...patch } : current)
|
||||
qc.setQueriesData({ queryKey: ["messages"] }, (current: { items?: MailMessage[] } | undefined) => {
|
||||
if (!current?.items) return current
|
||||
return { ...current, items: current.items.map((message) => message.id === id ? { ...message, ...patch } : message) }
|
||||
qc.setQueriesData({ queryKey: ["messages"] }, (current: InfiniteData<MailListResponse> | undefined) => {
|
||||
if (!current?.pages) return current
|
||||
return {
|
||||
...current,
|
||||
pages: current.pages.map((page) => ({
|
||||
...page,
|
||||
items: (page.items || []).map((message) => message.id === id ? { ...message, ...patch } : message),
|
||||
})),
|
||||
}
|
||||
})
|
||||
}
|
||||
const star = useMutation({
|
||||
@@ -157,7 +171,7 @@ export function MailPage() {
|
||||
},
|
||||
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 markAllRead = useMutation({
|
||||
mutationFn: async (items: MailMessage[]) => {
|
||||
@@ -233,7 +247,7 @@ export function MailPage() {
|
||||
}, [publicSettings.data?.mailAutoRefresh, publicSettings.data?.mailRefreshMs, qc])
|
||||
|
||||
const selected = detail.data
|
||||
const allMessages = messages.data?.items || []
|
||||
const allMessages = messages.data?.pages.flatMap((page) => page.items || []) || []
|
||||
const visibleMessages = allMessages.filter((message) => {
|
||||
if (mailFilter === "unread") return !message.isRead
|
||||
if (mailFilter === "starred") return message.isStarred
|
||||
@@ -251,6 +265,8 @@ export function MailPage() {
|
||||
const selectedCountOnPage = compactSelectedIds.filter((id) => visibleMessageIds.includes(id)).length
|
||||
const compactAllSelected = visibleMessageIds.length > 0 && selectedCountOnPage === visibleMessageIds.length
|
||||
const compactSomeSelected = selectedCountOnPage > 0 && !compactAllSelected
|
||||
const hasMoreMessages = !!messages.hasNextPage
|
||||
const canLoadMore = !!messages.hasNextPage && !messages.isFetchingNextPage
|
||||
function toggleCompactSelectAll(checked: boolean) {
|
||||
setCompactSelectedIds(checked ? visibleMessageIds : [])
|
||||
}
|
||||
@@ -268,6 +284,18 @@ export function MailPage() {
|
||||
async function runBulkAction(action: BulkAction) {
|
||||
const ids = compactSelectedIds.filter((id) => visibleMessageIds.includes(id))
|
||||
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)
|
||||
try {
|
||||
if (action === "read" || action === "unread") {
|
||||
@@ -284,6 +312,7 @@ export function MailPage() {
|
||||
}
|
||||
if (selectedId && ids.includes(selectedId)) setSelectedId(null)
|
||||
setCompactSelectedIds([])
|
||||
setPendingConfirm(null)
|
||||
await refreshMailData()
|
||||
toast({ title: `已处理 ${ids.length} 封邮件` })
|
||||
} catch (error) {
|
||||
@@ -292,6 +321,14 @@ export function MailPage() {
|
||||
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 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) }) }
|
||||
@@ -383,7 +420,7 @@ export function MailPage() {
|
||||
</Button>
|
||||
)}
|
||||
</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" />
|
||||
{!sidebarCollapsed && <span>写邮件</span>}
|
||||
</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">
|
||||
<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 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>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm"><SlidersHorizontal className="h-4 w-4" />{filterLabels[mailFilter]}</Button>
|
||||
@@ -467,7 +504,9 @@ export function MailPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{displayMode === "compact" ? (
|
||||
{!mailboxList.isLoading && !hasMailboxes ? (
|
||||
<NoMailboxState onOpenSettings={openSettings} />
|
||||
) : displayMode === "compact" ? (
|
||||
<CompactMailView
|
||||
title={viewTitle}
|
||||
icon={mailView === "label" && selectedLabel ? <Tag className="h-4 w-4" style={{ color: selectedLabel.color }} /> : undefined}
|
||||
@@ -477,6 +516,9 @@ export function MailPage() {
|
||||
allSelected={compactAllSelected}
|
||||
someSelected={compactSomeSelected}
|
||||
loading={messages.isLoading}
|
||||
hasMore={hasMoreMessages}
|
||||
loadingMore={messages.isFetchingNextPage}
|
||||
onLoadMore={() => messages.fetchNextPage()}
|
||||
emptyMessage={emptyMessage}
|
||||
selectedId={selectedId}
|
||||
selected={selected}
|
||||
@@ -491,7 +533,7 @@ export function MailPage() {
|
||||
onReply={openReply}
|
||||
onForward={openForward}
|
||||
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 })}
|
||||
onAddLabel={(message, label) => addLabel.mutate({ id: message.id, label })}
|
||||
onRemoveLabel={(message, labelId) => removeLabel.mutate({ id: message.id, labelId })}
|
||||
@@ -516,6 +558,13 @@ export function MailPage() {
|
||||
{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 })} />)}
|
||||
{!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>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
@@ -537,7 +586,7 @@ export function MailPage() {
|
||||
) : (
|
||||
<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 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>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -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 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"
|
||||
|
||||
function BulkActionMenu({ pending, onAction }: { pending: boolean; onAction: (action: BulkAction) => void }) {
|
||||
@@ -622,6 +698,8 @@ function CompactMailView({
|
||||
allSelected,
|
||||
someSelected,
|
||||
loading,
|
||||
hasMore,
|
||||
loadingMore,
|
||||
emptyMessage,
|
||||
selectedId,
|
||||
selected,
|
||||
@@ -631,6 +709,7 @@ function CompactMailView({
|
||||
onSelect,
|
||||
onSelectAll,
|
||||
onToggleSelected,
|
||||
onLoadMore,
|
||||
onCloseReader,
|
||||
onStar,
|
||||
onReply,
|
||||
@@ -651,6 +730,8 @@ function CompactMailView({
|
||||
allSelected: boolean
|
||||
someSelected: boolean
|
||||
loading: boolean
|
||||
hasMore: boolean
|
||||
loadingMore: boolean
|
||||
emptyMessage: string
|
||||
selectedId: string | null
|
||||
selected?: MailMessage
|
||||
@@ -660,6 +741,7 @@ function CompactMailView({
|
||||
onSelect: (id: string | null) => void
|
||||
onSelectAll: (checked: boolean) => void
|
||||
onToggleSelected: (id: string, checked: boolean) => void
|
||||
onLoadMore: () => void
|
||||
onCloseReader: () => void
|
||||
onStar: (message: MailMessage) => void
|
||||
onReply: (message: MailMessage) => void
|
||||
@@ -721,6 +803,13 @@ function CompactMailView({
|
||||
{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)} />)}
|
||||
{!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>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -25,9 +25,11 @@ import { Separator } from "@/components/ui/separator"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"
|
||||
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"
|
||||
|
||||
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 }> = {
|
||||
profile: { label: "账户资料", icon: <Settings 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 }) {
|
||||
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 }) {
|
||||
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 = {
|
||||
@@ -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 }) {
|
||||
const mailbox = item.mailboxId ? mailboxes.find((m) => m.id === item.mailboxId)?.address : "全部邮箱"
|
||||
const [confirmOpen, setConfirmOpen] = React.useState(false)
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border p-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
@@ -638,7 +698,8 @@ function RuleListItem({ item, mailboxes, onDelete }: { item: MailRule; mailboxes
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{mailbox} · {item.matchMode === "any" ? "任一条件" : "所有条件"} · {conditionSummary(item.conditions, item.fromContains, item.subjectContains)}</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>
|
||||
)
|
||||
}
|
||||
@@ -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 }) {
|
||||
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 }) {
|
||||
|
||||
Reference in New Issue
Block a user