From d87f63589964ef459c27c56cc9586bc7853245a5 Mon Sep 17 00:00:00 2001 From: LanQin_ Date: Tue, 16 Jun 2026 15:40:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin,mail,profile):=20=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=E9=82=AE=E4=BB=B6=E7=AE=A1=E7=90=86=E4=B8=8E=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增统一确认弹窗,替换多个删除/清理操作的直接执行。 - 管理后台支持首次配置引导、域名/邮箱/别名创建入口,以及邮件列表分页加载更多。 - 邮箱页改为分页拉取邮件,补充无邮箱状态、批量删除确认和“写邮件”可用性控制。 - 个人中心为联系人、清理、规则、拦截操作增加二次确认。 - 将 DKIM 密钥长度提升为 2048 位,增强安全性。 --- apps/api/internal/app/app.go | 2 +- apps/web/src/components/confirm-dialog.tsx | 46 ++++++++ apps/web/src/pages/admin.tsx | 122 +++++++++++++++++---- apps/web/src/pages/mail.tsx | 121 +++++++++++++++++--- apps/web/src/pages/profile.tsx | 100 ++++++++++++++++- 5 files changed, 350 insertions(+), 41 deletions(-) create mode 100644 apps/web/src/components/confirm-dialog.tsx diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go index 2d83454..9da86b2 100644 --- a/apps/api/internal/app/app.go +++ b/apps/api/internal/app/app.go @@ -695,7 +695,7 @@ func (a *App) createDomainTx(ctx context.Context, tx *sql.Tx, name string) (stri } func generateDKIMMaterial() (string, string, error) { - key, err := rsa.GenerateKey(rand.Reader, 1024) + key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { return "", "", err } diff --git a/apps/web/src/components/confirm-dialog.tsx b/apps/web/src/components/confirm-dialog.tsx new file mode 100644 index 0000000..6da7eea --- /dev/null +++ b/apps/web/src/components/confirm-dialog.tsx @@ -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 ( + + + + {title} + + {description &&
{description}
} + + + + +
+
+ ) +} diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx index 0e6efa9..4996b07 100644 --- a/apps/web/src/pages/admin.tsx +++ b/apps/web/src/pages/admin.tsx @@ -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 = { overview: "概览", @@ -74,10 +76,10 @@ export function AdminPage() { )} - {section === "overview" && } + {section === "overview" && setParams(next === "overview" ? {} : { section: next })} />} {section === "users" && } {section === "domains" && } - {section === "mailboxes" && } + {section === "mailboxes" && } {section === "aliases" && } {section === "messages" && } {section === "settings" && } @@ -85,7 +87,8 @@ export function AdminPage() { ) } -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 (
@@ -98,6 +101,23 @@ function OverviewSection({ overview, domains }: { overview?: { activeUsers: numb + + 首次配置 + + {checklist.map((item) => ( + + ))} + + +
+
DNS 状态 @@ -105,17 +125,45 @@ function OverviewSection({ overview, domains }: { overview?: { activeUsers: numb {domains.length === 0 && } + + 运行提示 + + + + + + +
) } +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
{label}{value}
+} + 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(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 ( @@ -168,13 +216,14 @@ function UsersSection({ users }: { users: AdminUser[] }) { {user.disabled ? "停用" : "正常"} {new Date(user.createdAt).toLocaleDateString()} - remove.mutate(user.id)} /> + setPendingConfirm({ title: "删除用户?", description: `将删除 ${user.email} 及其关联数据。`, confirmText: "删除用户", onConfirm: () => remove.mutate(user.id) })} /> ))} {filteredUsers.length === 0 && } + { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} /> ) } @@ -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(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 ( - 域名管理 + +
+ 域名管理 + +
+
{domains.map((domain) => (
@@ -199,12 +254,13 @@ function DomainsSection({ domains }: { domains: Domain[] }) { {domain.dnsStatus === "ok" ? "DNS 正常" : domain.dnsStatus} - +
))} {domains.length === 0 && }
+ { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
) } @@ -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(null) + const remove = useMutation({ mutationFn: api.deleteMailbox, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "邮箱已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) }) return ( - 邮箱账号管理 + +
+ 邮箱账号管理 + +
+
地址归属用户名称配额状态 @@ -241,12 +303,14 @@ function MailboxesSection({ mailboxes, users }: { mailboxes: MailboxType[]; user {mailbox.displayName}{mailbox.quotaMb} MB{mailbox.status === "active" ? "启用" : "停用"} - remove.mutate(mailbox.id)} /> + setPendingConfirm({ title: "删除邮箱?", description: `将删除 ${mailbox.address} 和其中邮件。`, confirmText: "删除邮箱", onConfirm: () => remove.mutate(mailbox.id) })} /> ))}
+ {mailboxes.length === 0 && }
+ { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
) } @@ -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(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 ( - 别名/转发管理 + +
+ 别名/转发管理 + +
+
来源目标域名状态 @@ -269,12 +339,14 @@ function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domai {alias.destination}{domains.find((d) => d.id === alias.domainId)?.name || alias.domainId}{alias.enabled ? "启用" : "停用"} - update.mutate({ id: alias.id, payload: { source: alias.source, destination: alias.destination, enabled: !alias.enabled } })} onDelete={() => remove.mutate(alias.id)} /> + 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) })} /> ))}
+ {aliases.length === 0 && }
+ { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
) } @@ -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(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 ( @@ -366,6 +441,13 @@ function AdminMessagesSection({ mailboxes }: { mailboxes: MailboxType[] }) { {messages.isLoading && } {!messages.isLoading && items.length === 0 && } + {!messages.isLoading && messages.hasNextPage && ( +
+ +
+ )} { if (!open) setSelectedId(null) }} />
diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index 4a504ad..c6c7053 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -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 = { 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(null) const sidebarPanelRef = React.useRef(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) { 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 | 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() { )} - @@ -447,7 +484,7 @@ export function MailPage() {
- + @@ -467,7 +504,9 @@ export function MailPage() {
- {displayMode === "compact" ? ( + {!mailboxList.isLoading && !hasMailboxes ? ( + + ) : displayMode === "compact" ? ( : 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 && } {visibleMessages.map((m) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} />)} {!messages.isLoading && visibleMessages.length === 0 &&
{emptyMessage}
} + {!messages.isLoading && hasMoreMessages && ( +
+ +
+ )} @@ -537,7 +586,7 @@ export function MailPage() { ) : ( )} - +
{selected.from} 发给 {selected.to.join(", ")} · {formatDateTime(selected.receivedAt)}
@@ -567,6 +616,16 @@ export function MailPage() { { 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"] }) }} /> + { if (!open) setPendingConfirm(null) }} + onConfirm={() => pendingConfirm?.onConfirm()} + /> ) } @@ -589,6 +648,23 @@ function buildMailMenuItems(folders: MailFolder[], starredCount: number): MailMe function FolderSkeleton() { return
} function MessageSkeleton() { return
{Array.from({ length: 6 }).map((_, i) =>
)}
} +function NoMailboxState({ onOpenSettings }: { onOpenSettings: () => void }) { + return ( +
+
+
+ +
+
还没有可用邮箱
+
请在个人中心申请邮箱,或联系管理员为当前账号分配邮箱。
+ +
+
+ ) +} + 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 && } {messages.map((message) => onToggleSelected(message.id, checked)} onClick={() => onSelect(message.id)} onStar={() => onStar(message)} />)} {!loading && messages.length === 0 &&
{emptyMessage}
} + {!loading && hasMore && ( +
+ +
+ )} ) diff --git a/apps/web/src/pages/profile.tsx b/apps/web/src/pages/profile.tsx index 161d268..cdbc4b9 100644 --- a/apps/web/src/pages/profile.tsx +++ b/apps/web/src/pages/profile.tsx @@ -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 = { profile: { label: "账户资料", icon: }, mailboxes: { label: "邮箱管理", icon: }, @@ -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
新增联系人
{ e.preventDefault(); onCreate(new FormData(e.currentTarget)); e.currentTarget.reset() }}>
联系人列表{items.map((item) =>
{item.name}
{item.email}{item.note ? ` · ${item.note}` : ""}
)}{!loading && items.length === 0 && }
+ const [pendingConfirm, setPendingConfirm] = React.useState(null) + return ( +
+ + 新增联系人 + +
{ e.preventDefault(); onCreate(new FormData(e.currentTarget)); e.currentTarget.reset() }}> + + + + +
+
+
+ + 联系人列表 + + {items.map((item) => ( +
+
+
{item.name}
+
{item.email}{item.note ? ` · ${item.note}` : ""}
+
+
+ + +
+
+ ))} + {!loading && items.length === 0 && } +
+
+ { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} /> +
+ ) } function CleanupSection({ mailbox, stats, pending, onCleanup }: { mailbox?: Mailbox; stats?: MailStats; pending: boolean; onCleanup: (target: "empty-trash" | "empty-spam" | "archive-read-inbox") => void }) { - return
清理当前邮箱} title="归档已读收件箱" disabled={!mailbox || pending} onClick={() => onCleanup("archive-read-inbox")} />} title="清空垃圾邮件" disabled={!mailbox || pending} onClick={() => onCleanup("empty-spam")} />} title="清空回收站" disabled={!mailbox || pending} onClick={() => onCleanup("empty-trash")} />
+ const [pendingConfirm, setPendingConfirm] = React.useState(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 ( +
+ + + 清理当前邮箱 + + } title="归档已读收件箱" disabled={!mailbox || pending} onClick={() => confirmCleanup("archive-read-inbox", "归档已读收件箱?")} /> + } title="清空垃圾邮件" disabled={!mailbox || pending} onClick={() => confirmCleanup("empty-spam", "清空垃圾邮件?", true)} /> + } title="清空回收站" disabled={!mailbox || pending} onClick={() => confirmCleanup("empty-trash", "清空回收站?", true)} /> + + + { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} /> +
+ ) } 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 (
@@ -638,7 +698,8 @@ function RuleListItem({ item, mailboxes, onDelete }: { item: MailRule; mailboxes
{mailbox} · {item.matchMode === "any" ? "任一条件" : "所有条件"} · {conditionSummary(item.conditions, item.fromContains, item.subjectContains)}
- + + { onDelete(item.id); setConfirmOpen(false) }} /> ) } @@ -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
新增拦截发件人
{ e.preventDefault(); onCreate(new FormData(e.currentTarget)); e.currentTarget.reset() }}>
被拦截邮件{items.map((item) =>
{item.email}
{item.mailboxId ? mailboxes.find((m) => m.id === item.mailboxId)?.address : "全部邮箱"}{item.reason ? ` · ${item.reason}` : ""}
)}{items.length === 0 && }
+ const [pendingConfirm, setPendingConfirm] = React.useState(null) + return ( +
+ + 新增拦截发件人 + +
{ e.preventDefault(); onCreate(new FormData(e.currentTarget)); e.currentTarget.reset() }}> + + + + +
+
+
+ + 被拦截邮件 + + {items.map((item) => ( +
+
+
{item.email}
+
{item.mailboxId ? mailboxes.find((m) => m.id === item.mailboxId)?.address : "全部邮箱"}{item.reason ? ` · ${item.reason}` : ""}
+
+ +
+ ))} + {items.length === 0 && } +
+
+ { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} /> +
+ ) } function StatsSection({ stats, mailbox, onRefresh }: { stats?: MailStats; mailbox?: Mailbox; onRefresh: () => void }) {