fix: restore mail folder and export workflows
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions

This commit is contained in:
zxyszx
2026-08-04 19:07:21 +08:00
parent 06388465a2
commit cefbee4f7f
7 changed files with 342 additions and 93 deletions
+9 -13
View File
@@ -69,16 +69,6 @@ async function request<T>(path: string, init: RequestInit & { timeoutMs?: number
}
}
async function requestFile(path: string): Promise<Blob> {
const res = await fetch(path, { credentials: "include" })
if (!res.ok) {
let message = `${res.status} ${res.statusText}`
try { const body = await res.json(); message = body.error || message } catch {}
throw new Error(message)
}
return res.blob()
}
async function uploadForm<T>(path: string, form: FormData): Promise<T> {
const controller = new AbortController()
const timeout = window.setTimeout(() => controller.abort(), 5 * 60_000)
@@ -230,7 +220,13 @@ export const api = {
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
return request<{ ok: boolean }>(`/api/mail/folders/reorder${query}`, { method: "POST", body: JSON.stringify(payload.folders ? { folders: payload.folders } : { folderIds: payload.folderIds }) })
},
deleteFolder: (id: string, mailboxId?: string) => request<{ ok: boolean; moved: number }>(`/api/mail/folders/${id}${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`, { method: "DELETE" }),
deleteFolder: (id: string, mailboxId?: string, folderName?: string) => {
const query = new URLSearchParams()
if (mailboxId) query.set("mailboxId", mailboxId)
if (folderName) query.set("folderName", folderName)
const suffix = query.toString()
return request<{ ok: boolean; moved: number }>(`/api/mail/folders/${id}${suffix ? `?${suffix}` : ""}`, { method: "DELETE" })
},
labels: (mailboxId?: string) => request<ListResponse<MailLabel>>(`/api/mail/labels${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
createLabel: (payload: { mailboxId?: string; name: string; color?: string }) => {
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
@@ -255,12 +251,12 @@ export const api = {
if (mailboxId) params.set("mailboxId", mailboxId)
return request<ListResponse<MailMessage>>(`/api/mail/starred?${params.toString()}`)
},
exportMail: (params: { view: "folder" | "starred" | "label" | "unknown"; mailboxId?: string; folder?: string; labelId?: string }) => {
exportMailUrl: (params: { view: "folder" | "starred" | "label" | "unknown"; mailboxId?: string; folder?: string; labelId?: string }) => {
const query = new URLSearchParams({ view: params.view })
if (params.mailboxId) query.set("mailboxId", params.mailboxId)
if (params.folder) query.set("folder", params.folder)
if (params.labelId) query.set("labelId", params.labelId)
return requestFile(`/api/mail/export?${query.toString()}`)
return `/api/mail/export?${query.toString()}`
},
importMail: (files: File[], payload: { mailboxId: string; folder: string }) => {
const form = new FormData()
+64 -49
View File
@@ -192,6 +192,15 @@ export function MailPage() {
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
const externalImapEnabled = publicSettings.data?.externalImapEnabled ?? false
React.useEffect(() => {
if (!user) return
const timer = window.setTimeout(() => {
void import("@/pages/profile")
if (user.role === "admin") void import("@/pages/admin")
}, 400)
return () => window.clearTimeout(timer)
}, [user])
const mailboxList = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes, enabled: canAccessMail })
const externalMailAccounts = useQuery({ queryKey: ["mail-external-accounts"], queryFn: api.externalMailAccounts, enabled: canAccessMail && canReadMail && externalImapEnabled })
const selectedExternalAccount = React.useMemo(() => externalImapEnabled ? externalMailAccounts.data?.items.find((item) => item.id === selectedExternalAccountId) : undefined, [externalImapEnabled, externalMailAccounts.data?.items, selectedExternalAccountId])
@@ -205,6 +214,7 @@ export function MailPage() {
const activeMailboxId = selectedMailboxId === "all" ? "all" : selectedMailbox?.id || ""
const selectedComposeMailbox = selectedMailbox || (isAllMailboxSelected ? mailboxList.data?.items?.[0] : undefined)
const hasMailboxes = (mailboxList.data?.items.length || 0) > 0
const canManageFolders = canOrganizeMail && hasMailboxes
const showMailboxCopy = !!selectedMailbox && !isAllMailboxSelected
const folders = useQuery({ queryKey: ["folders", activeMailboxId], queryFn: () => api.folders(activeMailboxId), enabled: !!activeMailboxId && canReadMail })
const labels = useQuery({ queryKey: ["labels", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && (canReadMail || canManageLabels) })
@@ -460,7 +470,7 @@ export function MailPage() {
onSettled: () => qc.invalidateQueries({ queryKey: ["folders", activeMailboxId] }),
})
const deleteFolder = useMutation({
mutationFn: (item: Extract<MailMenuItem, { type: "folder" }>) => api.deleteFolder(item.folderId, activeMailboxId),
mutationFn: (item: Extract<MailMenuItem, { type: "folder" }>) => api.deleteFolder(item.folderId, activeMailboxId, item.folderName),
onSuccess: async (result, item) => {
setPendingConfirm(null)
if (mailView === "folder" && folder === item.folderName) {
@@ -967,7 +977,9 @@ export function MailPage() {
if (item.type !== "folder" || !item.custom) return
setPendingConfirm({
title: `删除文件夹“${item.label}”?`,
description: "文件夹内的邮件会移回收件箱,不会被删除。",
description: isAllMailboxSelected
? "所有邮箱中的同名文件夹都会删除,文件夹内邮件会移回各自的收件箱。"
: "文件夹内的邮件会移回收件箱,不会被删除。",
confirmText: "删除文件夹",
onConfirm: () => deleteFolder.mutate(item),
})
@@ -1096,28 +1108,20 @@ export function MailPage() {
async function exportCurrentMail() {
if (!canExportCurrentView || exportingMail) return
setExportingMail(true)
try {
const exportView = mailView === "unknown" ? "unknown" : mailView === "starred" ? "starred" : mailView === "label" ? "label" : "folder"
const blob = await api.exportMail({
view: exportView,
mailboxId: mailView === "unknown" ? undefined : activeMailboxId,
folder: exportView === "folder" ? folder : undefined,
labelId: exportView === "label" ? selectedLabelId : undefined,
})
const href = URL.createObjectURL(blob)
const anchor = document.createElement("a")
anchor.href = href
anchor.download = `${viewTitle.replace(/[\\/:*?"<>|]+/g, "-") || "邮件"}-${new Date().toISOString().slice(0, 10)}.zip`
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
window.setTimeout(() => URL.revokeObjectURL(href), 1000)
toast({ title: "邮件已导出", description: `${viewTitle} 已打包为 ZIP` })
} catch (error) {
toast({ title: "导出失败", description: error instanceof Error ? error.message : "请稍后重试" })
} finally {
setExportingMail(false)
}
const exportView = mailView === "unknown" ? "unknown" : mailView === "starred" ? "starred" : mailView === "label" ? "label" : "folder"
const anchor = document.createElement("a")
anchor.href = api.exportMailUrl({
view: exportView,
mailboxId: mailView === "unknown" ? undefined : activeMailboxId,
folder: exportView === "folder" ? folder : undefined,
labelId: exportView === "label" ? selectedLabelId : undefined,
})
anchor.download = `${viewTitle.replace(/[\\/:*?"<>|]+/g, "-") || "邮件"}-${new Date().toISOString().slice(0, 10)}.zip`
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
toast({ title: "已开始下载", description: "邮件将打包为 ZIP,压缩包内为标准 EML 文件;邮件较多时请查看浏览器下载进度。" })
window.setTimeout(() => setExportingMail(false), 1000)
}
function chooseMailImport() {
if (!canImportCurrentView || importingMail) {
@@ -1166,6 +1170,9 @@ export function MailPage() {
function openSettings() {
navigate("/profile")
}
function openAdmin() {
navigate("/admin")
}
function toggleAdvancedSearch() {
setAdvancedSearchDraft(advancedSearch)
setAdvancedSearchOpen((open) => !open)
@@ -1216,6 +1223,7 @@ export function MailPage() {
language={language}
onLanguageChange={setLanguage}
onSettings={openSettings}
onAdmin={user?.role === "admin" ? openAdmin : undefined}
/>
<div className={cn("mt-2 gap-1.5", sidebarCollapsed ? "flex justify-center" : showMailboxCopy ? "grid grid-cols-[minmax(0,1fr)_2rem]" : "grid grid-cols-1")}>
<MailboxSwitcher
@@ -1352,12 +1360,12 @@ export function MailPage() {
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>}
{(customMailMenuItems.length > 0 || canOrganizeMail) && <SidebarGroup>
{(customMailMenuItems.length > 0 || canManageFolders) && <SidebarGroup>
{!sidebarCollapsed && (
<div className="flex items-center justify-between px-2 py-1">
<SidebarGroupLabel className="m-0 h-auto gap-1 p-0 text-xs font-semibold text-muted-foreground"><ChevronDown className="h-3 w-3" /></SidebarGroupLabel>
{canOrganizeCurrentMailbox && (
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={() => setFolderDialogOpen(true)} disabled={!selectedMailbox}>
{canManageFolders && (
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={() => setFolderDialogOpen(true)}>
<Plus className="h-3.5 w-3.5" />
</Button>
)}
@@ -1784,8 +1792,10 @@ export function MailPage() {
/>
<SidebarContextMenu
state={sidebarContextMenu}
canOrganize={canOrganizeMail}
pending={reorderFolders.isPending}
canCreate={canManageFolders}
canReorder={canOrganizeCurrentMailbox}
canDelete={canManageFolders}
pending={reorderFolders.isPending || deleteFolder.isPending}
onClose={closeSidebarContextMenu}
onOpen={(item) => {
closeSidebarContextMenu()
@@ -2455,7 +2465,7 @@ function BulkActionToolbar({ pending, currentFolder, folders = [], readAction =
)
}
function SidebarContextMenu({ state, canOrganize, pending, onClose, onOpen, onRefresh, onCreateFolder, onMove, onDelete }: { state: SidebarContextMenuState | null; canOrganize: boolean; pending: boolean; onClose: () => void; onOpen: (item: MailMenuItem) => void; onRefresh: () => void; onCreateFolder: () => void; onMove: (item: MailMenuItem, action: "top" | "up" | "down" | "bottom") => void; onDelete: (item: MailMenuItem) => void }) {
function SidebarContextMenu({ state, canCreate, canReorder, canDelete, pending, onClose, onOpen, onRefresh, onCreateFolder, onMove, onDelete }: { state: SidebarContextMenuState | null; canCreate: boolean; canReorder: boolean; canDelete: boolean; pending: boolean; onClose: () => void; onOpen: (item: MailMenuItem) => void; onRefresh: () => void; onCreateFolder: () => void; onMove: (item: MailMenuItem, action: "top" | "up" | "down" | "bottom") => void; onDelete: (item: MailMenuItem) => void }) {
React.useEffect(() => {
if (!state) return
const close = () => onClose()
@@ -2493,30 +2503,32 @@ function SidebarContextMenu({ state, canOrganize, pending, onClose, onOpen, onRe
<Button type="button" variant="ghost" className={itemClass} onClick={onRefresh}>
<RefreshCcw className="h-4 w-4" />
</Button>
{canOrganize && (
{canCreate && (
<Button type="button" variant="ghost" className={itemClass} onClick={onCreateFolder}>
<Plus className="h-4 w-4" />
</Button>
)}
{canOrganize && customFolder && (
{customFolder && (canReorder || canDelete) && (
<>
<div className="my-1 h-px bg-border" />
<Button type="button" variant="ghost" className={itemClass} disabled={pending} onClick={() => onMove(item, "top")}>
<ArrowLeft className="h-4 w-4 rotate-90" />
</Button>
<Button type="button" variant="ghost" className={itemClass} disabled={pending} onClick={() => onMove(item, "up")}>
<ChevronDown className="h-4 w-4 rotate-180" />
</Button>
<Button type="button" variant="ghost" className={itemClass} disabled={pending} onClick={() => onMove(item, "down")}>
<ChevronDown className="h-4 w-4" />
</Button>
<Button type="button" variant="ghost" className={itemClass} disabled={pending} onClick={() => onMove(item, "bottom")}>
<ArrowLeft className="h-4 w-4 -rotate-90" />
</Button>
<div className="my-1 h-px bg-border" />
<Button type="button" variant="ghost" className={cn(itemClass, "text-destructive hover:bg-destructive/10 hover:text-destructive")} onClick={() => onDelete(item)}>
<Trash2 className="h-4 w-4" />
</Button>
{canReorder && <>
<Button type="button" variant="ghost" className={itemClass} disabled={pending} onClick={() => onMove(item, "top")}>
<ArrowLeft className="h-4 w-4 rotate-90" />
</Button>
<Button type="button" variant="ghost" className={itemClass} disabled={pending} onClick={() => onMove(item, "up")}>
<ChevronDown className="h-4 w-4 rotate-180" />
</Button>
<Button type="button" variant="ghost" className={itemClass} disabled={pending} onClick={() => onMove(item, "down")}>
<ChevronDown className="h-4 w-4" />
</Button>
<Button type="button" variant="ghost" className={itemClass} disabled={pending} onClick={() => onMove(item, "bottom")}>
<ArrowLeft className="h-4 w-4 -rotate-90" />
</Button>
</>}
{canReorder && canDelete && <div className="my-1 h-px bg-border" />}
{canDelete && <Button type="button" variant="ghost" className={cn(itemClass, "text-destructive hover:bg-destructive/10 hover:text-destructive")} disabled={pending} onClick={() => onDelete(item)}>
<Trash2 className="h-4 w-4" />
</Button>}
</>
)}
</div>
@@ -3187,7 +3199,7 @@ function NewLabelButton({ collapsed, pending, onCreate, editing, onEditingChange
)
}
function AccountHeader({ collapsed, name, email, darkMode, language, onToggleTheme, onLanguageChange, onSettings }: { collapsed: boolean; name: string; email?: string; darkMode: boolean; language: Language; onToggleTheme: () => void; onLanguageChange: (language: Language) => void; onSettings: () => void }) {
function AccountHeader({ collapsed, name, email, darkMode, language, onToggleTheme, onLanguageChange, onSettings, onAdmin }: { collapsed: boolean; name: string; email?: string; darkMode: boolean; language: Language; onToggleTheme: () => void; onLanguageChange: (language: Language) => void; onSettings: () => void; onAdmin?: () => void }) {
const displayName = cleanAccountName(name, email)
const currentLanguage = languageOptions.find((item) => item.value === language) || languageOptions[0]
if (collapsed) {
@@ -3210,6 +3222,9 @@ function AccountHeader({ collapsed, name, email, darkMode, language, onToggleThe
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
{onAdmin && <Button type="button" variant="ghost" size="icon" className="size-7 rounded-md text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={onAdmin} title="后台管理" aria-label="后台管理">
<ShieldCheck className="h-3.5 w-3.5" />
</Button>}
<Button type="button" variant="ghost" size="icon" className="size-7 rounded-md text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={onToggleTheme}>
{darkMode ? <Sun className="h-3.5 w-3.5" /> : <Moon className="h-3.5 w-3.5" />}
</Button>
+37 -20
View File
@@ -59,6 +59,7 @@ export function ProfilePage() {
const passwordFormRef = React.useRef<HTMLFormElement>(null)
const twoFactorFormRef = React.useRef<HTMLFormElement>(null)
const [mailboxId, setMailboxId] = React.useState(() => localStorage.getItem("lanqin:selected-mailbox") || "")
const [statsMailboxId, setStatsMailboxId] = React.useState("all")
const [statsRangeDays, setStatsRangeDays] = React.useState(30)
const [darkMode, setDarkMode] = React.useState(getInitialTheme)
const [displayMode, setDisplayMode] = useDisplayMode()
@@ -98,29 +99,32 @@ export function ProfilePage() {
const tab: Tab = rawTab && visibleTabKeys.includes(rawTab) ? rawTab : "profile"
const accountTab: AccountSettingsTab = rawAccountTab && accountSettingTabs.some((item) => item.key === rawAccountTab) ? rawAccountTab : "account"
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 apiTokens = useQuery({ queryKey: ["api-tokens"], queryFn: api.apiTokens })
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 ruleForwarding = useQuery({ queryKey: ["forwarding-settings"], queryFn: api.forwardingSettings, enabled: canManageRules && canAccessMail })
const mailboxApplyOptions = useQuery({ queryKey: ["mailbox-apply-options"], queryFn: api.mailboxApplyOptions, enabled: canApplyMailbox && tab === "mailboxes" })
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings, enabled: tab === "mailboxes" || (tab === "profile" && accountTab === "clients") })
const apiTokens = useQuery({ queryKey: ["api-tokens"], queryFn: api.apiTokens, enabled: tab === "apiTokens" })
const contacts = useQuery({ queryKey: ["contacts"], queryFn: api.contacts, enabled: canManageContacts && tab === "contacts" })
const signatures = useQuery({ queryKey: ["signatures"], queryFn: api.signatures, enabled: canManageSignatures && tab === "profile" && accountTab === "mail" })
const rules = useQuery({ queryKey: ["rules"], queryFn: api.rules, enabled: canManageRules && tab === "rules" })
const ruleForwarding = useQuery({ queryKey: ["forwarding-settings"], queryFn: api.forwardingSettings, enabled: canManageRules && canAccessMail && tab === "rules" })
const ruleVerifiedEmails = React.useMemo(() => ruleForwarding.data?.verifiedEmails.filter((item) => item.verified).map((item) => item.email) || [], [ruleForwarding.data?.verifiedEmails])
const blocked = useQuery({ queryKey: ["blocked-senders"], queryFn: api.blockedSenders, enabled: canManageBlocked })
const blocked = useQuery({ queryKey: ["blocked-senders"], queryFn: api.blockedSenders, enabled: canManageBlocked && tab === "blocked" })
const selectedMailbox = React.useMemo(() => mailboxes.data?.items.find((m) => m.id === mailboxId), [mailboxes.data?.items, mailboxId])
const activeMailboxId = selectedMailbox?.id || ""
const externalImapEnabled = publicSettings.data?.externalImapEnabled ?? false
const externalImapAccounts = useQuery({ queryKey: ["external-imap-accounts", activeMailboxId], queryFn: () => api.externalImapAccounts(activeMailboxId), enabled: !!activeMailboxId && canAccessMail && externalImapEnabled })
const externalImapAccounts = useQuery({ queryKey: ["external-imap-accounts", activeMailboxId], queryFn: () => api.externalImapAccounts(activeMailboxId), enabled: tab === "mailboxes" && !!activeMailboxId && canAccessMail && externalImapEnabled })
React.useEffect(() => {
if (!externalRunAccountId) return
if (externalImapAccounts.data?.items.some((item) => item.id === externalRunAccountId)) return
setExternalRunAccountId("")
}, [externalImapAccounts.data?.items, externalRunAccountId])
const selectedExternalRunAccount = externalImapAccounts.data?.items.find((item) => item.id === externalRunAccountId)
const externalRunFolders = useQuery({ queryKey: ["external-imap-run-folders", externalRunAccountId], queryFn: () => api.externalFolders(externalRunAccountId), enabled: !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail && externalImapEnabled })
const externalSyncRuns = useQuery({ queryKey: ["external-imap-sync-runs", externalRunAccountId], queryFn: () => api.externalImapSyncRuns(externalRunAccountId), enabled: !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail && externalImapEnabled })
const labels = useQuery({ queryKey: ["labels", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && (canReadMail || canManageLabels || canManageRules) })
const stats = useQuery({ queryKey: ["mail-stats", activeMailboxId, statsRangeDays], queryFn: () => api.mailStats(activeMailboxId, statsRangeDays), enabled: !!activeMailboxId && canViewStats })
const externalRunFolders = useQuery({ queryKey: ["external-imap-run-folders", externalRunAccountId], queryFn: () => api.externalFolders(externalRunAccountId), enabled: tab === "mailboxes" && !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail && externalImapEnabled })
const externalSyncRuns = useQuery({ queryKey: ["external-imap-sync-runs", externalRunAccountId], queryFn: () => api.externalImapSyncRuns(externalRunAccountId), enabled: tab === "mailboxes" && !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail && externalImapEnabled })
const labels = useQuery({ queryKey: ["labels", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && ((tab === "profile" && accountTab === "mail" && (canReadMail || canManageLabels)) || (tab === "rules" && canManageRules)) })
const accountStats = useQuery({ queryKey: ["mail-stats", "all", 30], queryFn: () => api.mailStats("all", 30), enabled: canViewStats && tab === "profile" && accountTab === "account" })
const mailboxStats = useQuery({ queryKey: ["mail-stats", activeMailboxId, 30], queryFn: () => api.mailStats(activeMailboxId, 30), enabled: !!activeMailboxId && canViewStats && (tab === "cleanup" || tab === "cleanupQueue") })
const blockedStats = useQuery({ queryKey: ["mail-stats", blockedMailboxId, 30], queryFn: () => api.mailStats(blockedMailboxId, 30), enabled: canViewStats && tab === "blocked" })
const dashboardStats = useQuery({ queryKey: ["mail-stats", statsMailboxId, statsRangeDays], queryFn: () => api.mailStats(statsMailboxId, statsRangeDays), enabled: canViewStats && tab === "stats" })
const profile = useMutation({
mutationFn: (form: FormData) => api.updateProfile({ displayName: String(form.get("displayName") || "") }),
@@ -356,6 +360,16 @@ export function ProfilePage() {
<span className="truncate">{tabs[key].label}</span>
</button>
))}
{user.role === "admin" && (
<button
type="button"
className="flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-sm text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground"
onClick={() => navigate("/admin")}
>
<ShieldCheck className="h-4 w-4 stroke-[1.8]" />
<span className="truncate"></span>
</button>
)}
</div>
</nav>
<div className="border-t p-2">
@@ -370,7 +384,10 @@ export function ProfilePage() {
const pageTitle = tabs[tab].label
const pageSubtitle = tab === "stats" ? "查看邮件收发趋势、分布情况和常用联系人。" : undefined
const pageAction = tab === "stats"
? <StatsRangeTabs rangeDays={statsRangeDays} onRangeChange={setStatsRangeDays} />
? <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="w-full sm:w-[220px]"><MailboxSelect value={statsMailboxId} mailboxes={mailboxes.data?.items || []} onChange={setStatsMailboxId} /></div>
<StatsRangeTabs rangeDays={statsRangeDays} onRangeChange={setStatsRangeDays} />
</div>
: tab === "apiTokens"
? <Button asChild variant="outline" size="sm" className="h-8 px-3 text-xs"><a href="https://github.com/zxyszx/NewSzxcn-Email/blob/main/docs/API.md" target="_blank" rel="noreferrer"><BookOpen className="h-4 w-4" />API </a></Button>
: undefined
@@ -420,7 +437,7 @@ export function ProfilePage() {
profile={profile}
password={password}
passwordFormRef={passwordFormRef}
stats={canViewStats ? stats.data : undefined}
stats={canViewStats ? accountStats.data : undefined}
showStats={canViewStats}
displayMode={displayMode}
onDisplayModeChange={setDisplayMode}
@@ -477,11 +494,11 @@ export function ProfilePage() {
/>
)
if (tab === "contacts") return <ContactsSection items={contacts.data?.items || []} loading={contacts.isLoading} pending={createContact.isPending} onCreate={(form) => createContact.mutate(form)} onDelete={(id) => deleteContact.mutate(id)} onCopy={copy} />
if (tab === "cleanup") return <CleanupSection mailbox={selectedMailbox} stats={canViewStats ? stats.data : undefined} showStats={canViewStats} pending={cleanup.isPending} onCleanup={(target) => cleanup.mutate(target)} />
if (tab === "cleanupQueue") return <CleanupQueueSection mailbox={selectedMailbox} stats={canViewStats ? stats.data : undefined} />
if (tab === "cleanup") return <CleanupSection mailbox={selectedMailbox} stats={canViewStats ? mailboxStats.data : undefined} showStats={canViewStats} pending={cleanup.isPending} onCleanup={(target) => cleanup.mutate(target)} />
if (tab === "cleanupQueue") return <CleanupQueueSection mailbox={selectedMailbox} stats={canViewStats ? mailboxStats.data : undefined} />
if (tab === "rules") return <RulesSection items={rules.data?.items || []} mailboxes={mailboxes.data?.items || []} labels={labels.data?.items || []} verifiedEmails={ruleVerifiedEmails} open={ruleDialogOpen} onOpenChange={setRuleDialogOpen} onCreate={(payload) => createRule.mutate(payload)} onUpdate={(id, payload) => updateRule.mutate({ id, payload })} onToggle={(item) => updateRule.mutate({ id: item.id, payload: { enabled: !item.enabled } })} onMove={(id, direction) => moveRule.mutate({ id, direction })} onApply={(id) => applyRule.mutate(id)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending || updateRule.isPending || moveRule.isPending || applyRule.isPending} />
if (tab === "blocked") return <BlockedSection items={blocked.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={blockedMailboxId} spamCount={canViewStats ? stats.data?.byFolder.find((f) => 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 <StatsSection stats={stats.data} mailbox={selectedMailbox} rangeDays={statsRangeDays} onRangeChange={setStatsRangeDays} onRefresh={() => stats.refetch()} />
if (tab === "blocked") return <BlockedSection items={blocked.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={blockedMailboxId} spamCount={canViewStats ? blockedStats.data?.byFolder.find((f) => 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 <StatsSection stats={dashboardStats.data} />
if (tab === "apiTokens") return <ApiTokensSection items={apiTokens.data?.items || []} loading={apiTokens.isLoading} pending={createApiToken.isPending || updateApiToken.isPending || deleteApiToken.isPending} onCreate={(payload) => createApiToken.mutateAsync(payload)} onUpdate={(id, payload) => updateApiToken.mutate({ id, payload })} onDelete={(id) => deleteApiToken.mutate(id)} onCopy={copy} />
return null
}
@@ -2394,7 +2411,7 @@ function BlockedSection({ items, mailboxes, mailboxId, spamCount, onMailboxChang
)
}
function StatsSection({ stats }: { stats?: MailStats; mailbox?: Mailbox; rangeDays: number; onRangeChange: (days: number) => void; onRefresh: () => void }) {
function StatsSection({ stats }: { stats?: MailStats }) {
const quotaLabel = stats?.quotaBytes ? `${formatBytes(stats.storageBytes || 0)} / ${formatBytes(stats.quotaBytes)}` : formatBytes(stats?.storageBytes || 0)
const quotaPct = Math.min(stats?.quotaUsedPct || 0, 100)
const primaryCards = [