import * as React from "react" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import type { ImperativePanelHandle } from "react-resizable-panels" import { useNavigate, useSearchParams } from "react-router-dom" import { ArrowLeft, BarChart3, Ban, Contact, Copy, Info, KeyRound, Laptop, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftClose, PanelLeftOpen, PencilLine, Plus, RefreshCcw, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, X } from "lucide-react" import { QRCodeSVG } from "qrcode.react" import { api, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats, PermissionLimits } from "@/lib/api" import { cn, formatBytes } from "@/lib/utils" import { applyTheme, getInitialTheme } from "@/lib/theme" import { DisplayMode, useDisplayMode } from "@/lib/display-mode" import { useMe } from "@/hooks/use-me" import { useLogout } from "@/hooks/use-logout" import { useIsMobile } from "@/hooks/use-mobile" import { validatePasswordConfirm } from "@/lib/validation" import { hasPermission } from "@/lib/permissions" import { Button } from "@/components/ui/button" import { PasswordInput } from "@/components/ui/password-input" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Textarea } from "@/components/ui/textarea" import { Badge } from "@/components/ui/badge" import { Avatar, AvatarFallback } from "@/components/ui/avatar" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Checkbox } from "@/components/ui/checkbox" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" 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" | "clients" | "signatures" | "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: }, clients: { label: "第三方客户端", icon: }, signatures: { label: "签名管理", icon: }, contacts: { label: "联系人管理", icon: }, cleanup: { label: "邮件清理", icon: }, rules: { label: "收件规则", icon: }, blocked: { label: "被拦截邮件", icon: }, stats: { label: "数据统计", icon: }, } const tabKeys = Object.keys(tabs) as Tab[] const actionLabels: Record = { archive: "移入归档", trash: "移入回收站", star: "添加星标", "mark-read": "标记已读" } export function ProfilePage() { const me = useMe() const qc = useQueryClient() const navigate = useNavigate() const [params, setParams] = useSearchParams() const { toast } = useToast() const passwordFormRef = React.useRef(null) const twoFactorFormRef = React.useRef(null) const sidebarPanelRef = React.useRef(null) const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false) const [mailboxId, setMailboxId] = React.useState(() => localStorage.getItem("lanqin:selected-mailbox") || "") const [darkMode, setDarkMode] = React.useState(getInitialTheme) const [displayMode, setDisplayMode] = useDisplayMode() const [blockedMailboxId, setBlockedMailboxId] = React.useState("all") const [ruleDialogOpen, setRuleDialogOpen] = React.useState(false) const [mobileSidebarOpen, setMobileSidebarOpen] = React.useState(false) const isMobile = useIsMobile() const themeMountedRef = React.useRef(false) const rawTab = params.get("tab") as Tab | null const user = me.data?.user const canAccessMail = hasPermission(user, "mail.access") const canReadMail = hasPermission(user, "mail.messages.read") const canOrganizeMail = hasPermission(user, "mail.messages.organize") const canManageLabels = hasPermission(user, "mail.labels.manage") const canManageContacts = hasPermission(user, "mail.contacts.manage") const canManageSignatures = hasPermission(user, "mail.signatures.manage") const canManageRules = hasPermission(user, "mail.rules.manage") const canManageBlocked = hasPermission(user, "mail.blocked_senders.manage") const canViewStats = hasPermission(user, "mail.stats.view") const canApplyMailbox = hasPermission(user, "mail.mailboxes.apply") const visibleTabKeys = tabKeys.filter((key) => { if (key === "profile") return true if (key === "mailboxes") return canAccessMail || canApplyMailbox if (key === "clients") return canAccessMail if (key === "signatures") return canManageSignatures if (key === "contacts") return canManageContacts if (key === "cleanup") return canOrganizeMail if (key === "rules") return canManageRules if (key === "blocked") return canManageBlocked if (key === "stats") return canViewStats return false }) const tab: Tab = rawTab && visibleTabKeys.includes(rawTab) ? rawTab : "profile" const mailboxes = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes, enabled: canAccessMail }) const mailboxApplyOptions = useQuery({ queryKey: ["mailbox-apply-options"], queryFn: api.mailboxApplyOptions, enabled: canApplyMailbox }) const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings }) const contacts = useQuery({ queryKey: ["contacts"], queryFn: api.contacts, enabled: canManageContacts }) const signatures = useQuery({ queryKey: ["signatures"], queryFn: api.signatures, enabled: canManageSignatures }) const rules = useQuery({ queryKey: ["rules"], queryFn: api.rules, enabled: canManageRules }) const blocked = useQuery({ queryKey: ["blocked-senders"], queryFn: api.blockedSenders, enabled: canManageBlocked }) const selectedMailbox = React.useMemo(() => mailboxes.data?.items.find((m) => m.id === mailboxId), [mailboxes.data?.items, mailboxId]) const activeMailboxId = selectedMailbox?.id || "" const ruleLabels = useQuery({ queryKey: ["labels", "rules", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && canManageRules && (canReadMail || canManageLabels) }) const stats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId && canViewStats }) const profile = useMutation({ mutationFn: (form: FormData) => api.updateProfile({ displayName: String(form.get("displayName") || "") }), onSuccess: (data) => { qc.setQueryData(["me"], data); toast({ title: "个人资料已保存" }) }, onError: (error) => toast({ title: "保存失败", description: error.message }), }) const password = useMutation({ mutationFn: (form: FormData) => { const newPassword = String(form.get("newPassword") || "") validatePasswordConfirm(newPassword, String(form.get("confirmPassword") || ""), "两次输入的新密码不一致") return api.changePassword({ currentPassword: String(form.get("currentPassword") || ""), newPassword }) }, onSuccess: () => { passwordFormRef.current?.reset(); toast({ title: "密码已更新" }) }, onError: (error) => toast({ title: "修改失败", description: error.message }), }) const setupTwoFactor = useMutation({ mutationFn: api.setupTwoFactor, onSuccess: () => toast({ title: "双因素密钥已生成" }), onError: (error) => toast({ title: "生成失败", description: error.message }), }) const enableTwoFactor = useMutation({ mutationFn: (form: FormData) => api.enableTwoFactor(String(form.get("code") || "")), onSuccess: (data) => { qc.setQueryData(["me"], data); setupTwoFactor.reset(); twoFactorFormRef.current?.reset(); toast({ title: "双因素认证已启用" }) }, onError: (error) => toast({ title: "启用失败", description: error.message }), }) const disableTwoFactor = useMutation({ mutationFn: (form: FormData) => api.disableTwoFactor(String(form.get("code") || "")), onSuccess: (data) => { qc.setQueryData(["me"], data); twoFactorFormRef.current?.reset(); toast({ title: "双因素认证已关闭" }) }, onError: (error) => toast({ title: "关闭失败", description: error.message }), }) const createContact = useMutation({ mutationFn: (form: FormData) => api.createContact({ name: String(form.get("name") || ""), email: String(form.get("email") || ""), note: String(form.get("note") || "") }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["contacts"] }); toast({ title: "联系人已保存" }) }, onError: (error) => toast({ title: "保存失败", description: error.message }), }) const deleteContact = useMutation({ mutationFn: api.deleteContact, onSuccess: () => { qc.invalidateQueries({ queryKey: ["contacts"] }); toast({ title: "联系人已删除" }) } }) const createSignature = useMutation({ mutationFn: (form: FormData) => api.createSignature({ mailboxId: String(form.get("mailboxId") || ""), name: String(form.get("name") || ""), content: String(form.get("content") || ""), isDefault: form.get("isDefault") === "on" }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["signatures"] }); qc.invalidateQueries({ queryKey: ["signature"] }); toast({ title: "签名已保存" }) }, onError: (error) => toast({ title: "保存失败", description: error.message }), }) const updateSignature = useMutation({ mutationFn: ({ id, form }: { id: string; form: FormData }) => api.updateSignature(id, { mailboxId: String(form.get("mailboxId") || ""), name: String(form.get("name") || ""), content: String(form.get("content") || ""), isDefault: form.get("isDefault") === "on" }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["signatures"] }); qc.invalidateQueries({ queryKey: ["signature"] }); toast({ title: "签名已更新" }) }, onError: (error) => toast({ title: "保存失败", description: error.message }), }) const setDefaultSignature = useMutation({ mutationFn: api.setDefaultSignature, onSuccess: () => { qc.invalidateQueries({ queryKey: ["signatures"] }); qc.invalidateQueries({ queryKey: ["signature"] }); toast({ title: "默认签名已更新" }) }, onError: (error) => toast({ title: "设置失败", description: error.message }), }) const deleteSignature = useMutation({ mutationFn: api.deleteSignature, onSuccess: () => { qc.invalidateQueries({ queryKey: ["signatures"] }); qc.invalidateQueries({ queryKey: ["signature"] }); toast({ title: "签名已删除" }) } }) const createRule = useMutation({ mutationFn: (payload: { mailboxId: string name: string matchMode: "all" | "any" conditions: MailRuleCondition[] actions: MailRuleAction[] applyToExisting: boolean stopProcessing: boolean enabled: boolean }) => api.createRule(payload), onSuccess: (rule) => { qc.invalidateQueries({ queryKey: ["rules"] }) qc.invalidateQueries({ queryKey: ["messages"] }) qc.invalidateQueries({ queryKey: ["mail-stats"] }) qc.invalidateQueries({ queryKey: ["labels"] }) setRuleDialogOpen(false) toast({ title: rule.appliedExistingCount ? `收件规则已保存,已应用 ${rule.appliedExistingCount} 封邮件` : "收件规则已保存" }) }, onError: (error) => toast({ title: "保存失败", description: error.message }), }) const deleteRule = useMutation({ mutationFn: api.deleteRule, onSuccess: () => { qc.invalidateQueries({ queryKey: ["rules"] }); toast({ title: "规则已删除" }) } }) const createBlocked = useMutation({ mutationFn: (form: FormData) => api.createBlockedSender({ mailboxId: blockedMailboxId === "all" ? "" : blockedMailboxId, email: String(form.get("email") || ""), reason: String(form.get("reason") || "") }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["blocked-senders"] }); toast({ title: "拦截规则已保存" }) }, onError: (error) => toast({ title: "保存失败", description: error.message }), }) const deleteBlocked = useMutation({ mutationFn: api.deleteBlockedSender, onSuccess: () => { qc.invalidateQueries({ queryKey: ["blocked-senders"] }); toast({ title: "拦截规则已删除" }) } }) const cleanup = useMutation({ mutationFn: (target: "empty-trash" | "empty-spam" | "archive-read-inbox") => api.cleanupMail({ mailboxId, target }), onSuccess: (res) => { qc.invalidateQueries({ queryKey: ["mail-stats"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["messages"] }); toast({ title: `已处理 ${res.affected} 封邮件` }) }, onError: (error) => toast({ title: "清理失败", description: error.message }), }) const applyMailbox = useMutation({ mutationFn: api.applyMailbox, onSuccess: (mailbox) => { qc.invalidateQueries({ queryKey: ["mailboxes", "mine"] }) qc.invalidateQueries({ queryKey: ["mailbox-apply-options"] }) setMailboxId(mailbox.id) toast({ title: "邮箱已申请" }) }, onError: (error) => toast({ title: "申请失败", description: error.message }), }) React.useEffect(() => { if (!mailboxes.isSuccess) return const items = mailboxes.data?.items || [] if (items.length === 0) { if (mailboxId) setMailboxId("") localStorage.removeItem("lanqin:selected-mailbox") return } if (!mailboxId || !items.some((m) => m.id === mailboxId)) setMailboxId(items[0].id) }, [mailboxId, mailboxes.isSuccess, mailboxes.data?.items]) React.useEffect(() => { if (mailboxId) localStorage.setItem("lanqin:selected-mailbox", mailboxId); else localStorage.removeItem("lanqin:selected-mailbox") }, [mailboxId]) React.useEffect(() => { applyTheme(darkMode, themeMountedRef.current); themeMountedRef.current = true }, [darkMode]) const logout = useLogout() async function copy(text: string) { await navigator.clipboard.writeText(text); toast({ title: "已复制" }) } function setTab(next: Tab) { const visibleNext = visibleTabKeys.includes(next) ? next : "profile" setParams(visibleNext === "profile" ? {} : { tab: visibleNext }) setMobileSidebarOpen(false) } function toggleSidebar() { sidebarCollapsed ? (sidebarPanelRef.current?.expand(14), setSidebarCollapsed(false)) : (sidebarPanelRef.current?.collapse(), setSidebarCollapsed(true)) } if (me.isLoading) return
加载中...
if (me.isError || !user) return
登录状态已失效
const sidebarContent = ( setDarkMode((v) => !v)} onBack={() => navigate("/")} /> {!sidebarCollapsed && 个人中心} {visibleTabKeys.map((key) => setTab(key)}>{tabs[key].icon}{!sidebarCollapsed && {tabs[key].label}})}
{!isMobile && ( <> )}
) return (
{isMobile ? (
个人中心导航
{sidebarContent}
{tabs[tab].label}
{renderTab()}
) : ( setSidebarCollapsed(true)} onExpand={() => setSidebarCollapsed(false)}> {sidebarContent}
{tabs[tab].label}
{renderTab()}
)}
) function renderTab() { if (tab === "mailboxes") return { if (!canAccessMail) return; setMailboxId(id); navigate("/") }} onApply={(payload) => applyMailbox.mutateAsync(payload).then(() => undefined)} /> if (tab === "clients") return if (tab === "signatures") return createSignature.mutate(form)} onUpdate={(id, form) => updateSignature.mutate({ id, form })} onSetDefault={(id) => setDefaultSignature.mutate(id)} onDelete={(id) => deleteSignature.mutate(id)} /> if (tab === "contacts") return createContact.mutate(form)} onDelete={(id) => deleteContact.mutate(id)} onCopy={copy} /> if (tab === "cleanup") return cleanup.mutate(target)} /> if (tab === "rules") return createRule.mutate(payload)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending} /> if (tab === "blocked") return f.role === "spam")?.count || 0 : 0} onMailboxChange={setBlockedMailboxId} onCreate={(form) => createBlocked.mutate(form)} onDelete={(id) => deleteBlocked.mutate(id)} pending={createBlocked.isPending} /> if (tab === "stats") return stats.refetch()} /> return } } function ProfileOverview({ user, profile, password, passwordFormRef, stats, showStats, displayMode, onDisplayModeChange, twoFactorFormRef, setupTwoFactor, enableTwoFactor, disableTwoFactor, onCopy }: { user: { email: string; displayName: string; role: string; disabled: boolean; twoFactorEnabled: boolean; createdAt: string; limits?: PermissionLimits }; profile: { mutate: (form: FormData) => void; isPending: boolean }; password: { mutate: (form: FormData) => void; isPending: boolean }; passwordFormRef: React.RefObject; stats?: MailStats; showStats: boolean; displayMode: DisplayMode; onDisplayModeChange: (mode: DisplayMode) => void; twoFactorFormRef: React.RefObject; setupTwoFactor: { data?: { secret: string; otpauthUrl: string }; mutate: () => void; reset: () => void; isPending: boolean }; enableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; disableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; onCopy: (text: string) => void }) { return (
账号配额
{showStats && } 账户信息
{ e.preventDefault(); profile.mutate(new FormData(e.currentTarget)) }}>
角色
{user.role === "admin" ? "超级管理员" : "普通用户"}
账号状态 {user.disabled ? "已停用" : "正常"}
创建时间 {new Date(user.createdAt).toLocaleString()}
界面设置 双因素认证
认证状态
{user.twoFactorEnabled ? "已启用" : "未启用"}
{!user.twoFactorEnabled && !setupTwoFactor.data && ( )} {!user.twoFactorEnabled && setupTwoFactor.data && (
{ e.preventDefault(); enableTwoFactor.mutate(new FormData(e.currentTarget)) }}>
)} {user.twoFactorEnabled && (
{ e.preventDefault(); disableTwoFactor.mutate(new FormData(e.currentTarget)) }}>
)}
修改密码
{ e.preventDefault(); password.mutate(new FormData(e.currentTarget)) }}>
) } function LimitBadge({ label, value, unit }: { label: string; value?: number; unit: string }) { return (
{label}
{value !== undefined && value > 0 ? value : "不限"}
{value !== undefined && value > 0 &&
{unit}
}
) } function MailboxManagement({ mailboxes, applyOptions, applyPending, selectedMailboxId, onSelect, onCopy, onOpen, onApply }: { mailboxes: Mailbox[]; applyOptions?: MailboxApplyOptions; applyPending: boolean; selectedMailboxId: string; onSelect: (id: string) => void; onCopy: (text: string) => void; onOpen: (id: string) => void; onApply: (payload: { domainId: string; localPart: string; displayName: string }) => Promise }) { const canApply = !!applyOptions?.enabled && (applyOptions.domains || []).length > 0 return (
{canApply && }
{mailboxes.map((m) =>
{m.address}
{selectedMailboxId === m.id && 当前}
)} {mailboxes.length === 0 && }
) } function ApplyMailboxDialog({ options, pending, onApply }: { options: MailboxApplyOptions; pending: boolean; onApply: (payload: { domainId: string; localPart: string; displayName: string }) => Promise }) { const [open, setOpen] = React.useState(false) const [domainId, setDomainId] = React.useState(options.domains[0]?.id || "") React.useEffect(() => { if (!open) return setDomainId((current) => options.domains.some((domain) => domain.id === current) ? current : options.domains[0]?.id || "") }, [open, options.domains]) async function submit(event: React.FormEvent) { event.preventDefault() const form = new FormData(event.currentTarget) try { await onApply({ domainId, localPart: String(form.get("localPart") || ""), displayName: String(form.get("displayName") || ""), }) event.currentTarget.reset() setOpen(false) } catch {} } return ( 申请邮箱
) } function ClientSettingsSection({ mailboxes, selectedMailboxId, hostname, onSelectMailbox, onCopy }: { mailboxes: Mailbox[]; selectedMailboxId: string; hostname?: string; onSelectMailbox: (id: string) => void; onCopy: (text: string) => void }) { const selected = mailboxes.find((item) => item.id === selectedMailboxId) || mailboxes[0] const server = clientServerHost(hostname, selected?.address) const rows = [ { label: "IMAP 服务器", value: `${server}:993`, security: "SSL" }, { label: "POP3 服务器", value: `${server}:995`, security: "SSL" }, { label: "SMTP 服务器", value: `${server}:465`, security: "SSL" }, ] return (
第三方客户端
IMAP / POP3 / SMTP 配置用于 Thunderbird、Apple Mail、手机邮件客户端等。
{!!selected && {selected.address}}
{selected ? ( <>
{selected.address}
● IMAP ● POP3 ● SMTP
已启用
客户端配置
{rows.map((row) => ( ))}
用户名
{selected.address}
密码
邮箱登录密码
) : ( )}
) } function ClientConfigRow({ label, value, security, onCopy }: { label: string; value: string; security: string; onCopy: (text: string) => void }) { return (
{label}
{value}
{security}
) } function SignaturesSection({ items, mailboxes, loading, pending, onCreate, onUpdate, onSetDefault, onDelete }: { items: MailSignature[]; mailboxes: Mailbox[]; loading: boolean; pending: boolean; onCreate: (form: FormData) => void; onUpdate: (id: string, form: FormData) => void; onSetDefault: (id: string) => void; onDelete: (id: string) => void }) { const [mailboxId, setMailboxId] = React.useState("all") const [isDefault, setIsDefault] = React.useState(false) const [editing, setEditing] = React.useState(null) const [pendingConfirm, setPendingConfirm] = React.useState(null) const editingMailboxId = editing?.mailboxId || "all" const editingIsDefault = editing?.isDefault || false function resetCreateForm(form: HTMLFormElement) { form.reset() setMailboxId("all") setIsDefault(false) } return (
签名管理
支持全局签名和按发件邮箱绑定的默认签名。
共 {items.length} 个签名
{ e.preventDefault(); const form = new FormData(e.currentTarget); form.set("mailboxId", mailboxId === "all" ? "" : mailboxId); form.set("isDefault", isDefault ? "on" : ""); onCreate(form); resetCreateForm(e.currentTarget) }}>