feat: add managed installation and system updates
This commit is contained in:
@@ -7,6 +7,7 @@ import { AuthGuard } from "@/components/auth-guard"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import { SystemVersionDialog } from "@/components/system-version-dialog"
|
||||
import { hasAnyPermission } from "@/lib/permissions"
|
||||
import type { PermissionKey } from "@/lib/api-types"
|
||||
import {
|
||||
@@ -66,20 +67,23 @@ function ProtectedContent() {
|
||||
<SidebarProvider>
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader className="border-b">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" asChild>
|
||||
<Link to="/">
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<Mail className="size-4" />
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">NewSzxcn 邮箱</span>
|
||||
</div>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
<div className="space-y-1 group-data-[collapsible=icon]:space-y-0">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" asChild>
|
||||
<Link to="/">
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<Mail className="size-4" />
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">NewSzxcn 邮箱</span>
|
||||
</div>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
{isAdminRoute && <SystemVersionDialog className="ml-10" />}
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{isAdminRoute && visibleAdminSections.length > 0 && (
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import * as React from "react"
|
||||
import { useMutation, useQuery } from "@tanstack/react-query"
|
||||
import { CheckCircle2, Download, ExternalLink, Loader2, RefreshCcw, TriangleAlert } from "lucide-react"
|
||||
import { api } from "@/lib/api"
|
||||
import { cn, formatDate } from "@/lib/utils"
|
||||
import { useMe } from "@/hooks/use-me"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
|
||||
const frontendVersion = import.meta.env.VITE_APP_VERSION || "dev"
|
||||
|
||||
export function SystemVersionDialog({ mode = "sidebar", className }: { mode?: "sidebar" | "inline"; className?: string }) {
|
||||
const me = useMe()
|
||||
const { toast } = useToast()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [updatePhase, setUpdatePhase] = React.useState<"idle" | "starting" | "restarting">("idle")
|
||||
const version = useQuery({
|
||||
queryKey: ["admin", "system-version"],
|
||||
queryFn: api.systemVersion,
|
||||
staleTime: 5 * 60_000,
|
||||
retry: 1,
|
||||
})
|
||||
const currentVersion = version.data?.currentVersion || frontendVersion
|
||||
const isSystemAdmin = me.data?.user.role === "admin"
|
||||
const update = useMutation({
|
||||
mutationFn: async () => {
|
||||
setUpdatePhase("starting")
|
||||
const result = await api.updateSystem()
|
||||
setUpdatePhase("restarting")
|
||||
await waitForUpdatedService(result.targetVersion)
|
||||
return result
|
||||
},
|
||||
onError: (error) => {
|
||||
setUpdatePhase("idle")
|
||||
toast({ title: "更新失败", description: error.message })
|
||||
},
|
||||
})
|
||||
|
||||
const trigger = mode === "inline" ? (
|
||||
<Button type="button" variant="outline" className={cn("h-11 justify-start gap-2 px-4 text-base font-normal", className)}>
|
||||
<RefreshCcw className="h-5 w-5 text-primary" />
|
||||
{currentVersion}
|
||||
{version.data?.updateAvailable && <Badge className="ml-1">可更新</Badge>}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant={version.data?.updateAvailable ? "secondary" : "ghost"}
|
||||
className={cn("h-8 w-fit max-w-full justify-start gap-2 rounded-md px-2 text-xs font-medium group-data-[collapsible=icon]:hidden", className)}
|
||||
aria-label={`系统版本 ${currentVersion}`}
|
||||
>
|
||||
<span className="truncate">{currentVersion}</span>
|
||||
<span className={cn("h-2 w-2 shrink-0 rounded-full", version.data?.updateAvailable ? "bg-amber-500" : "bg-emerald-500")} aria-hidden="true" />
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogContent className="max-h-[88svh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between gap-3 pr-7">
|
||||
<DialogTitle>系统版本</DialogTitle>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-8 w-8" onClick={() => version.refetch()} disabled={version.isFetching || update.isPending} aria-label="重新检查更新" title="重新检查更新">
|
||||
<RefreshCcw className={cn("h-4 w-4", version.isFetching && "animate-spin")} />
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="border-b pb-4 text-center">
|
||||
<div className="text-sm text-muted-foreground">当前版本</div>
|
||||
<div className="mt-2 text-4xl font-semibold tabular-nums">{currentVersion}</div>
|
||||
{version.data?.latestVersion && <div className="mt-2 text-sm text-muted-foreground">最新版本:{version.data.latestVersion}</div>}
|
||||
</div>
|
||||
|
||||
{version.isLoading && <VersionState icon={<Loader2 className="animate-spin" />} title="正在检查更新" description="正在连接 GitHub Release。" />}
|
||||
{version.data?.checkError && <VersionState icon={<TriangleAlert />} title="暂时无法检查更新" description={version.data.checkError} tone="warning" />}
|
||||
{version.data && !version.data.checkError && !version.data.updateAvailable && <VersionState icon={<CheckCircle2 />} title="已是最新版本" description="当前无需更新。" tone="success" />}
|
||||
{version.data?.updateAvailable && (
|
||||
<VersionState
|
||||
icon={<Download />}
|
||||
title="发现新版本"
|
||||
description={`${version.data.latestVersion} 已发布${version.data.publishedAt ? ` · ${formatDate(version.data.publishedAt)}` : ""}`}
|
||||
tone="warning"
|
||||
/>
|
||||
)}
|
||||
|
||||
{version.data?.releaseNotes && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">更新日志</div>
|
||||
<div className="max-h-40 overflow-y-auto whitespace-pre-wrap rounded-md border bg-muted/30 p-3 text-sm leading-6 text-muted-foreground">
|
||||
{version.data.releaseNotes}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{update.isPending && (
|
||||
<div className="rounded-md border bg-muted/30 p-4">
|
||||
<div className="flex items-center gap-3 font-medium">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
{updatePhase === "starting" ? "正在准备更新" : "正在重启服务"}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">请保持页面打开,服务恢复后会自动刷新。</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{version.data?.updateAvailable && !version.data.updateEnabled && (
|
||||
<div className="rounded-md border p-3 text-sm text-muted-foreground">
|
||||
当前部署未启用页面更新,请在服务器执行 <code className="rounded bg-muted px-1.5 py-0.5 text-foreground">sudo newszxcn-email update</code>。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:justify-between">
|
||||
<div>
|
||||
{version.data?.releaseUrl && (
|
||||
<Button type="button" variant="ghost" asChild>
|
||||
<a href={version.data.releaseUrl} target="_blank" rel="noreferrer">
|
||||
更新详情<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{version.data?.updateAvailable && version.data.updateEnabled && (
|
||||
<Button type="button" disabled={!isSystemAdmin || update.isPending} onClick={() => update.mutate()}>
|
||||
{update.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
|
||||
{isSystemAdmin ? "立即更新" : "仅超级管理员可更新"}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function VersionState({ icon, title, description, tone = "neutral" }: { icon: React.ReactNode; title: string; description: string; tone?: "neutral" | "success" | "warning" }) {
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex items-start gap-3 rounded-md border p-4",
|
||||
tone === "success" && "border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950/30 dark:text-emerald-100",
|
||||
tone === "warning" && "border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-100",
|
||||
)}>
|
||||
<span className="mt-0.5 [&>svg]:h-5 [&>svg]:w-5">{icon}</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-medium">{title}</span>
|
||||
<span className="mt-1 block text-sm opacity-75">{description}</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForUpdatedService(targetVersion: string) {
|
||||
const deadline = Date.now() + 8 * 60_000
|
||||
while (Date.now() < deadline) {
|
||||
await delay(3000)
|
||||
try {
|
||||
const health = await fetch(`/healthz?update=${Date.now()}`, { cache: "no-store" })
|
||||
if (!health.ok) {
|
||||
continue
|
||||
}
|
||||
const response = await fetch(`/api/admin/system/version?update=${Date.now()}`, { credentials: "include", cache: "no-store" })
|
||||
if (!response.ok) continue
|
||||
const body = await response.json() as { currentVersion?: string }
|
||||
if (body.currentVersion === targetVersion) {
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
throw new Error("更新等待超时,请稍后手动刷新页面检查服务状态")
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms))
|
||||
}
|
||||
+18
-18
@@ -5,44 +5,44 @@
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222 47% 11%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222 47% 11%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222 47% 11%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 224 44% 12%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 213 37% 96%;
|
||||
--secondary-foreground: 222 47% 11%;
|
||||
--muted: 213 37% 96%;
|
||||
--muted-foreground: 216 22% 42%;
|
||||
--accent: 213 37% 94%;
|
||||
--accent-foreground: 222 47% 11%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 84% 4.9%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 84% 4.9%;
|
||||
--destructive: 358 88% 61%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 214 32% 90%;
|
||||
--input: 214 32% 86%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 216 22% 42%;
|
||||
--radius: 0.5rem;
|
||||
--sidebar-background: 0 0% 100%;
|
||||
--sidebar-foreground: 222 47% 11%;
|
||||
--sidebar-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-primary: 224 44% 12%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 213 37% 94%;
|
||||
--sidebar-accent-foreground: 222 47% 11%;
|
||||
--sidebar-border: 214 32% 90%;
|
||||
--sidebar-ring: 216 22% 42%;
|
||||
--sidebar-accent: 210 40% 96.1%;
|
||||
--sidebar-accent-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-border: 214.3 31.8% 91.4%;
|
||||
--sidebar-ring: 215.4 16.3% 46.9%;
|
||||
}
|
||||
|
||||
* { @apply border-border; }
|
||||
html {
|
||||
color-scheme: light;
|
||||
font-size: 15px;
|
||||
font-size: 16px;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 13px;
|
||||
font-size: 16px;
|
||||
}
|
||||
html, body, #root {
|
||||
min-height: 100%;
|
||||
|
||||
@@ -183,6 +183,25 @@ export type MaildirSyncHealth = {
|
||||
recentErrors: string[]
|
||||
summary: MaildirSyncCounts
|
||||
}
|
||||
export type SystemVersion = {
|
||||
currentVersion: string
|
||||
currentCommit?: string
|
||||
buildDate?: string
|
||||
latestVersion?: string
|
||||
latestName?: string
|
||||
releaseUrl?: string
|
||||
releaseNotes?: string
|
||||
publishedAt?: string
|
||||
updateAvailable: boolean
|
||||
updateEnabled: boolean
|
||||
checkError?: string
|
||||
}
|
||||
export type SystemUpdateResult = {
|
||||
ok: boolean
|
||||
currentVersion: string
|
||||
targetVersion: string
|
||||
message: string
|
||||
}
|
||||
export type SystemSettings = {
|
||||
publicHostname: string
|
||||
publicBaseUrl: string
|
||||
|
||||
+49
-1
@@ -1,4 +1,4 @@
|
||||
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken } from "./api-types"
|
||||
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, SystemVersion, SystemUpdateResult, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken } from "./api-types"
|
||||
export * from "./api-types"
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 15_000
|
||||
@@ -69,6 +69,35 @@ 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)
|
||||
try {
|
||||
const res = await fetch(path, { method: "POST", credentials: "include", body: form, signal: controller.signal })
|
||||
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.json() as Promise<T>
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") throw new Error("导入超时,请缩小文件后重试")
|
||||
throw error instanceof Error ? error : new Error("网络请求失败")
|
||||
} finally {
|
||||
window.clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
publicSettings: () => request<PublicSettings>("/api/public/settings"),
|
||||
register: (payload: RegisterPayload) => request<{ user: User }>("/api/auth/register", { method: "POST", body: JSON.stringify(payload) }),
|
||||
@@ -95,6 +124,9 @@ export const api = {
|
||||
defaultSignature: (mailboxId?: string) => request<{ signature: MailSignature | null }>(`/api/me/signatures/default${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
|
||||
rules: () => request<ListResponse<MailRule>>("/api/me/rules"),
|
||||
createRule: (payload: { mailboxId: string; name: string; matchMode: "all" | "any"; conditions: MailRuleCondition[]; actions: MailRuleAction[]; applyToExisting: boolean; stopProcessing: boolean; enabled: boolean }) => request<MailRule>("/api/me/rules", { method: "POST", body: JSON.stringify(payload) }),
|
||||
updateRule: (id: string, payload: Partial<{ mailboxId: string; name: string; matchMode: "all" | "any"; conditions: MailRuleCondition[]; actions: MailRuleAction[]; applyToExisting: boolean; stopProcessing: boolean; enabled: boolean }>) => request<MailRule>(`/api/me/rules/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
moveRule: (id: string, direction: "up" | "down") => request<{ ok: boolean }>(`/api/me/rules/${id}/move`, { method: "POST", body: JSON.stringify({ direction }) }),
|
||||
applyRule: (id: string) => request<{ ok: boolean; affected: number }>(`/api/me/rules/${id}/apply`, { method: "POST" }),
|
||||
deleteRule: (id: string) => request<{ ok: boolean }>(`/api/me/rules/${id}`, { method: "DELETE" }),
|
||||
blockedSenders: () => request<ListResponse<BlockedSender>>("/api/me/blocked-senders"),
|
||||
createBlockedSender: (payload: { mailboxId: string; email: string; reason: string }) => request<BlockedSender>("/api/me/blocked-senders", { method: "POST", body: JSON.stringify(payload) }),
|
||||
@@ -168,6 +200,8 @@ export const api = {
|
||||
const suffix = query.toString()
|
||||
return request<ListResponse<SendQueueAuditEvent>>(`/api/admin/send-audit${suffix ? `?${suffix}` : ""}`)
|
||||
},
|
||||
systemVersion: () => request<SystemVersion>("/api/admin/system/version"),
|
||||
updateSystem: () => request<SystemUpdateResult>("/api/admin/system/update", { method: "POST", timeoutMs: 45_000 }),
|
||||
systemSettings: () => request<SystemSettings>("/api/admin/settings"),
|
||||
maildirSyncHealth: () => request<MaildirSyncHealth>("/api/admin/maildir-sync/health"),
|
||||
updateSystemSettings: (payload: SystemSettingsPayload) => request<SystemSettings>("/api/admin/settings", { method: "POST", body: JSON.stringify(payload) }),
|
||||
@@ -220,6 +254,20 @@ 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 }) => {
|
||||
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()}`)
|
||||
},
|
||||
importMail: (files: File[], payload: { mailboxId: string; folder: string }) => {
|
||||
const form = new FormData()
|
||||
form.set("mailboxId", payload.mailboxId)
|
||||
form.set("folder", payload.folder)
|
||||
files.forEach((file) => form.append("files", file))
|
||||
return uploadForm<{ ok: boolean; imported: number; skipped: number; errors: string[] }>("/api/mail/import", form)
|
||||
},
|
||||
message: (id: string, options: { markRead?: boolean } = {}) => request<MailMessage>(`/api/mail/messages/${id}${options.markRead === false ? "?markRead=0" : ""}`),
|
||||
translateMessage: (id: string, targetLanguage: string) => request<MailTranslation>(`/api/mail/messages/${id}/translate`, { method: "POST", body: JSON.stringify({ targetLanguage }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
||||
translateExternalMessage: (id: string, remoteId: string, targetLanguage: string) => request<MailTranslation>(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}/translate`, { method: "POST", body: JSON.stringify({ targetLanguage }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from "react"
|
||||
import DOMPurify from "dompurify"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { ArrowRight, BookOpen, CheckCircle2, ChevronDown, Circle, ClipboardList, Copy, ExternalLink, GitBranch, Github, Globe2, Mail, Mailbox, MoreHorizontal, Plus, RefreshCcw, Scale, Search, ShieldCheck, Star, Trash2, Users } from "lucide-react"
|
||||
import { ArrowRight, BookOpen, CheckCircle2, ChevronDown, Circle, ClipboardList, Copy, ExternalLink, Github, Globe2, Mail, Mailbox, MoreHorizontal, Plus, RefreshCcw, Scale, Search, ShieldCheck, Star, Trash2, Users } from "lucide-react"
|
||||
import { api, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, MaildirSyncHealth, PermissionGroup, PermissionInfo, PermissionLimits, SystemSettings } from "@/lib/api"
|
||||
import { cn, decodeMimeHeader, formatBytes, formatDate } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -20,6 +20,7 @@ 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 { SystemVersionDialog } from "@/components/system-version-dialog"
|
||||
import { useMe } from "@/hooks/use-me"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import { hasAnyPermission, hasPermission } from "@/lib/permissions"
|
||||
@@ -54,13 +55,13 @@ const sectionPermissions: Record<Section, PermissionKey[]> = {
|
||||
}
|
||||
const projectRepositoryUrl = "https://github.com/zxyszx/NewSzxcn-Email"
|
||||
const projectTelegramUrl = "https://t.me/+EhII7MSyi3QwNDQ5"
|
||||
const projectTag = import.meta.env.VITE_APP_VERSION || ""
|
||||
const projectReleaseUrl = import.meta.env.VITE_RELEASE_URL || (projectTag ? `${projectRepositoryUrl}/releases/tag/${projectTag}` : "")
|
||||
const defaultPermissionLimits: PermissionLimits = { maxAttachmentMb: 25, maxMailboxCount: 9, smtpDailyLimit: 200, smtpMinuteLimit: 20, imapMinuteLimit: 200, pop3MinuteLimit: 150 }
|
||||
const defaultMailboxLimitOverride = 9
|
||||
const accountLoginName = (user: Pick<AdminUser, "email" | "loginName">) => user.loginName || user.email
|
||||
|
||||
export function AdminPage() {
|
||||
const qc = useQueryClient()
|
||||
const { toast } = useToast()
|
||||
const me = useMe()
|
||||
const user = me.data?.user
|
||||
const canOverview = hasPermission(user, "admin.overview.view")
|
||||
@@ -81,6 +82,7 @@ export function AdminPage() {
|
||||
const aliases = useQuery({ queryKey: ["admin", "aliases"], queryFn: api.aliases, enabled: !!user && canAliasesView })
|
||||
const settings = useQuery({ queryKey: ["admin", "settings"], queryFn: api.systemSettings, enabled: !!user && canSettingsView })
|
||||
const [params, setParams] = useSearchParams()
|
||||
const [refreshing, setRefreshing] = React.useState(false)
|
||||
|
||||
const domainItems = domains.data?.items || []
|
||||
const mailboxItems = mailboxes.data?.items || []
|
||||
@@ -91,10 +93,27 @@ export function AdminPage() {
|
||||
const rawSection = params.get("section") as Section | null
|
||||
const section: Section = rawSection && visibleSections.includes(rawSection) ? rawSection : visibleSections[0] || "overview"
|
||||
|
||||
async function refreshAdminPage() {
|
||||
if (refreshing) return
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await Promise.all([
|
||||
qc.invalidateQueries({ queryKey: ["admin"] }),
|
||||
qc.invalidateQueries({ queryKey: ["mailboxes"] }),
|
||||
qc.invalidateQueries({ queryKey: ["me"] }),
|
||||
])
|
||||
toast({ title: "后台数据已刷新" })
|
||||
} catch (error) {
|
||||
toast({ title: "刷新失败", description: error instanceof Error ? error.message : "请稍后重试" })
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-[calc(100svh-3rem)] md:h-svh">
|
||||
<main className="mx-auto w-full max-w-[1180px] px-3 pb-10 pt-3 sm:px-4 sm:pt-4">
|
||||
<AdminPageHeader section={section} />
|
||||
<AdminPageHeader section={section} refreshing={refreshing} onRefresh={refreshAdminPage} />
|
||||
|
||||
{section === "overview" && canOverview && (
|
||||
<div className="mb-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
@@ -111,7 +130,7 @@ export function AdminPage() {
|
||||
{section === "domains" && <DomainsSection domains={domainItems} />}
|
||||
{section === "mailboxes" && <MailboxesSection mailboxes={mailboxItems} users={userItems} domains={domainItems} />}
|
||||
{section === "aliases" && <AliasesSection aliases={aliasItems} domains={domainItems} />}
|
||||
{section === "messages" && <AdminMessagesSection mailboxes={mailboxItems} />}
|
||||
{section === "messages" && <AdminMessagesSection mailboxes={mailboxItems} systemAdmin={user?.role === "admin"} />}
|
||||
{section === "sendAudit" && <AdminSendAuditSection mailboxes={mailboxItems} />}
|
||||
{section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} />}
|
||||
</main>
|
||||
@@ -119,7 +138,7 @@ export function AdminPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function AdminPageHeader({ section }: { section: Section }) {
|
||||
function AdminPageHeader({ section, refreshing, onRefresh }: { section: Section; refreshing: boolean; onRefresh: () => void }) {
|
||||
const meta = sectionMeta[section]
|
||||
return (
|
||||
<div className="mb-4 border-b pb-3">
|
||||
@@ -133,7 +152,12 @@ function AdminPageHeader({ section }: { section: Section }) {
|
||||
<h1 className="text-[20px] font-semibold leading-7 tracking-tight">{meta.label}</h1>
|
||||
<p className="mt-1 text-sm leading-5 text-muted-foreground">{meta.description}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="h-7 rounded-md px-2.5 font-normal">NewSzxcn</Badge>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="icon" className="h-8 w-8 shadow-none" onClick={onRefresh} disabled={refreshing} aria-label="刷新后台数据" title="刷新后台数据">
|
||||
<RefreshCcw className={cn("h-4 w-4", refreshing && "animate-spin")} />
|
||||
</Button>
|
||||
<Badge variant="outline" className="h-7 rounded-md px-2.5 font-normal">NewSzxcn</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -756,8 +780,7 @@ function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domai
|
||||
)
|
||||
}
|
||||
|
||||
function AdminMessagesSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
||||
const qc = useQueryClient()
|
||||
function AdminMessagesSection({ mailboxes, systemAdmin }: { mailboxes: MailboxType[]; systemAdmin: boolean }) {
|
||||
const [query, setQuery] = React.useState("")
|
||||
const [mailboxId, setMailboxId] = React.useState("all")
|
||||
const [folder, setFolder] = React.useState("all")
|
||||
@@ -780,8 +803,8 @@ function AdminMessagesSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<CardTitle>全部邮件</CardTitle>
|
||||
<Button variant="outline" size="sm" onClick={() => qc.invalidateQueries({ queryKey: ["admin", "messages"] })}>
|
||||
<RefreshCcw className="h-4 w-4" />刷新
|
||||
<Button variant="outline" size="sm" onClick={() => messages.refetch()} disabled={messages.isFetching}>
|
||||
<RefreshCcw className={cn("h-4 w-4", messages.isFetching && "animate-spin")} />{messages.isFetching ? "刷新中" : "刷新"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -795,7 +818,7 @@ function AdminMessagesSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
||||
<SelectTrigger className="xl:w-72"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部邮箱</SelectItem>
|
||||
<SelectItem value="unregistered">未注册收件</SelectItem>
|
||||
{systemAdmin && <SelectItem value="unregistered">未知收件</SelectItem>}
|
||||
{mailboxes.map((mailbox) => <SelectItem key={mailbox.id} value={mailbox.id}>{mailbox.address}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -808,7 +831,7 @@ function AdminMessagesSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
||||
<SelectItem value="Archive">归档</SelectItem>
|
||||
<SelectItem value="Spam">垃圾邮件</SelectItem>
|
||||
<SelectItem value="Trash">回收站</SelectItem>
|
||||
<SelectItem value="Unregistered">未注册收件</SelectItem>
|
||||
{systemAdmin && <SelectItem value="Unregistered">未知收件</SelectItem>}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -884,7 +907,6 @@ function AdminMessagesSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
||||
}
|
||||
|
||||
function AdminSendAuditSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
||||
const qc = useQueryClient()
|
||||
const [mailboxId, setMailboxId] = React.useState("all")
|
||||
const [event, setEvent] = React.useState("all")
|
||||
const [messageId, setMessageId] = React.useState("")
|
||||
@@ -909,8 +931,8 @@ function AdminSendAuditSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<CardTitle className="flex items-center gap-2"><ClipboardList className="h-5 w-5" />发送队列</CardTitle>
|
||||
<Button variant="outline" size="sm" onClick={() => qc.invalidateQueries({ queryKey: ["admin", "send-audit"] })}>
|
||||
<RefreshCcw className="h-4 w-4" />刷新
|
||||
<Button variant="outline" size="sm" onClick={() => audit.refetch()} disabled={audit.isFetching}>
|
||||
<RefreshCcw className={cn("h-4 w-4", audit.isFetching && "animate-spin")} />{audit.isFetching ? "刷新中" : "刷新"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -1391,47 +1413,7 @@ function queryErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : "读取 Maildir 同步健康失败"
|
||||
}
|
||||
|
||||
function parseSemver(tag: string): number[] {
|
||||
return (tag.startsWith("v") ? tag.slice(1) : tag).split(".").map(Number)
|
||||
}
|
||||
|
||||
function AboutProjectCard() {
|
||||
const { toast } = useToast()
|
||||
const latestRelease = useQuery({
|
||||
queryKey: ["github", "latest-release"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("https://api.github.com/repos/zxyszx/NewSzxcn-Email/releases/latest")
|
||||
if (!res.ok) throw new Error("rate limited or unavailable")
|
||||
return res.json() as Promise<{ tag_name: string; html_url: string }>
|
||||
},
|
||||
enabled: !!projectTag,
|
||||
staleTime: 1000 * 60 * 60, // 1 hour
|
||||
retry: 1,
|
||||
})
|
||||
const updateAvailable = React.useMemo(() => {
|
||||
if (!projectTag || !latestRelease.data) return false
|
||||
const current = parseSemver(projectTag)
|
||||
const latest = parseSemver(latestRelease.data.tag_name)
|
||||
for (let i = 0; i < Math.max(current.length, latest.length); i++) {
|
||||
const a = current[i] ?? 0
|
||||
const b = latest[i] ?? 0
|
||||
if (b > a) return true
|
||||
if (a > b) return false
|
||||
}
|
||||
return false
|
||||
}, [projectTag, latestRelease.data])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (updateAvailable && latestRelease.data) {
|
||||
toast({
|
||||
title: "发现新版本",
|
||||
description: `${latestRelease.data.tag_name} 已可用,点击版本号查看详情。`,
|
||||
})
|
||||
}
|
||||
// Only toast once on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [updateAvailable])
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -1439,32 +1421,7 @@ function AboutProjectCard() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm">
|
||||
<AboutRow label="版本">
|
||||
{projectTag ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" asChild>
|
||||
<a href={projectReleaseUrl} target="_blank" rel="noreferrer">
|
||||
<GitBranch className="h-5 w-5 text-primary" />
|
||||
{projectTag}
|
||||
</a>
|
||||
</Button>
|
||||
{updateAvailable && latestRelease.data && (
|
||||
<Button type="button" variant="default" className="h-11 px-4 text-base font-normal" asChild>
|
||||
<a href={latestRelease.data.html_url} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="h-5 w-5" />
|
||||
新版本 {latestRelease.data.tag_name}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{latestRelease.isLoading && (
|
||||
<span className="text-xs text-muted-foreground">检查更新中...</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" disabled>
|
||||
<GitBranch className="h-5 w-5 text-muted-foreground" />
|
||||
未发布版本
|
||||
</Button>
|
||||
)}
|
||||
<SystemVersionDialog mode="inline" />
|
||||
</AboutRow>
|
||||
<AboutRow label="交流">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
|
||||
+173
-54
@@ -11,7 +11,7 @@ import TextAlign from "@tiptap/extension-text-align"
|
||||
import Placeholder from "@tiptap/extension-placeholder"
|
||||
import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Ban, Bold, Calendar, Check, ChevronDown, Clock3, Code2, Copy, Ellipsis, Eraser, Eye, FileText, Folder, Forward, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, Moon, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, X } from "lucide-react"
|
||||
import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Ban, Bold, Calendar, Check, ChevronDown, Clock3, Code2, Copy, Download, Ellipsis, Eraser, Eye, FileText, Folder, Forward, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, MailQuestion, Moon, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, Upload, X } from "lucide-react"
|
||||
import { api, ExternalImapAccount, ExternalImapFolder, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, MailSearchParams, SendPayload, DraftPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, PermissionLimits } from "@/lib/api"
|
||||
import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils"
|
||||
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
||||
@@ -60,7 +60,7 @@ const folderLabels: Record<string, string> = {
|
||||
|
||||
type ComposeDraft = { key: string; id?: string; mailboxId?: string; to?: string; cc?: string; bcc?: string; subject?: string; text?: string; html?: string; files?: File[]; isDraft?: boolean }
|
||||
type MailFilter = "all" | "unread" | "starred" | "attachments" | "recent7"
|
||||
type MailView = "folder" | "starred" | "label" | "scheduled" | "sendQueue" | "external"
|
||||
type MailView = "folder" | "starred" | "label" | "scheduled" | "sendQueue" | "external" | "unknown"
|
||||
type MailListResponse = { items?: MailMessage[]; nextCursor?: string }
|
||||
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
|
||||
type MailNotificationState = { latestId: string; latestReceivedAt: string }
|
||||
@@ -75,6 +75,7 @@ type MailMenuItem =
|
||||
| { type: "starred"; key: string; label: string; icon: React.ReactNode; count: number; order: number }
|
||||
| { type: "scheduled"; key: string; label: string; icon: React.ReactNode; count: number; order: number }
|
||||
| { type: "sendQueue"; key: string; label: string; icon: React.ReactNode; count: number; order: number }
|
||||
| { type: "unknown"; key: string; label: string; icon: React.ReactNode; count: number; order: number }
|
||||
| { type: "folder"; key: string; folderId: string; folderName: string; label: string; icon: React.ReactNode; count: number; custom: boolean; order: number }
|
||||
|
||||
const filterLabels: Record<MailFilter, string> = {
|
||||
@@ -137,6 +138,8 @@ export function MailPage() {
|
||||
const compactMailLayout = isMobile || isNarrowMailViewport || displayMode === "compact"
|
||||
const [refreshing, setRefreshing] = React.useState(false)
|
||||
const [autoRefreshing, setAutoRefreshing] = React.useState(false)
|
||||
const [exportingMail, setExportingMail] = React.useState(false)
|
||||
const [importingMail, setImportingMail] = React.useState(false)
|
||||
const [lastAutoRefreshAt, setLastAutoRefreshAt] = React.useState<Date | null>(null)
|
||||
const [bulkPending, setBulkPending] = React.useState(false)
|
||||
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||||
@@ -159,6 +162,7 @@ export function MailPage() {
|
||||
const themeMountedRef = React.useRef(false)
|
||||
const mailNotifyStateRef = React.useRef<Record<string, MailNotificationState>>({})
|
||||
const mailAudioContextRef = React.useRef<AudioContext | null>(null)
|
||||
const mailImportInputRef = React.useRef<HTMLInputElement | null>(null)
|
||||
const user = me.data?.user
|
||||
const canAccessMail = hasPermission(user, "mail.access")
|
||||
const canReadMail = hasPermission(user, "mail.messages.read")
|
||||
@@ -169,6 +173,7 @@ export function MailPage() {
|
||||
const canManageLabels = hasPermission(user, "mail.labels.manage")
|
||||
const canDownloadAttachments = hasPermission(user, "mail.attachments.download")
|
||||
const canManageSignatures = hasPermission(user, "mail.signatures.manage")
|
||||
const canViewUnknownMail = user?.role === "admin"
|
||||
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
|
||||
const externalImapEnabled = publicSettings.data?.externalImapEnabled ?? false
|
||||
|
||||
@@ -234,7 +239,18 @@ export function MailPage() {
|
||||
},
|
||||
initialPageParam: "",
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor || undefined,
|
||||
enabled: !!activeMailboxId && canReadMail && mailView !== "scheduled" && mailView !== "sendQueue" && (mailView !== "label" || !!selectedLabelId),
|
||||
enabled: !!activeMailboxId && canReadMail && mailView !== "scheduled" && mailView !== "sendQueue" && mailView !== "unknown" && (mailView !== "label" || !!selectedLabelId),
|
||||
})
|
||||
const unknownMessages = useInfiniteQuery({
|
||||
queryKey: ["admin", "unknown-messages", query],
|
||||
queryFn: ({ pageParam }) => api.adminMessages({
|
||||
mailboxId: "unregistered",
|
||||
q: query.trim(),
|
||||
cursor: typeof pageParam === "string" ? pageParam : "",
|
||||
}),
|
||||
initialPageParam: "",
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor || undefined,
|
||||
enabled: canViewUnknownMail && mailView === "unknown",
|
||||
})
|
||||
const externalMessages = useInfiniteQuery({
|
||||
queryKey: ["external-messages", selectedExternalAccountId, externalFolder, query],
|
||||
@@ -243,7 +259,11 @@ export function MailPage() {
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor || undefined,
|
||||
enabled: !!selectedExternalAccountId && canReadMail && mailView === "external" && externalImapEnabled,
|
||||
})
|
||||
const detail = useQuery({ queryKey: ["message", selectedId, mailView, selectedExternalAccountId], queryFn: () => mailView === "external" ? api.externalMessage(selectedExternalAccountId, selectedId!) : api.message(selectedId!, { markRead: false }), enabled: !!selectedId && canReadMail && (mailView !== "external" || (!!selectedExternalAccountId && externalImapEnabled)) })
|
||||
const detail = useQuery({
|
||||
queryKey: ["message", selectedId, mailView, selectedExternalAccountId],
|
||||
queryFn: () => mailView === "external" ? api.externalMessage(selectedExternalAccountId, selectedId!) : mailView === "unknown" ? api.adminMessage(selectedId!) : api.message(selectedId!, { markRead: false }),
|
||||
enabled: !!selectedId && canReadMail && (mailView !== "external" || (!!selectedExternalAccountId && externalImapEnabled)) && (mailView !== "unknown" || canViewUnknownMail),
|
||||
})
|
||||
function updateCachedMessage(id: string, patch: Partial<MailMessage>) {
|
||||
qc.setQueryData(["message", id], (current: MailMessage | undefined) => current ? { ...current, ...patch } : current)
|
||||
qc.setQueriesData({ queryKey: ["messages"] }, (current: InfiniteData<MailListResponse> | undefined) => {
|
||||
@@ -567,6 +587,8 @@ export function MailPage() {
|
||||
React.useEffect(() => {
|
||||
const events = new EventSource("/api/events", { withCredentials: true })
|
||||
events.addEventListener("sync", () => {
|
||||
qc.invalidateQueries({ queryKey: ["messages"] })
|
||||
qc.invalidateQueries({ queryKey: ["admin", "unknown-messages"] })
|
||||
qc.invalidateQueries({ queryKey: ["folders"] })
|
||||
qc.invalidateQueries({ queryKey: ["mail-stats"] })
|
||||
qc.invalidateQueries({ queryKey: ["labels"] })
|
||||
@@ -581,6 +603,9 @@ export function MailPage() {
|
||||
setAutoRefreshing(true)
|
||||
Promise.all([
|
||||
qc.invalidateQueries({ queryKey: ["messages"] }),
|
||||
qc.invalidateQueries({ queryKey: ["admin", "unknown-messages"] }),
|
||||
qc.invalidateQueries({ queryKey: ["external-messages"] }),
|
||||
qc.invalidateQueries({ queryKey: ["mail-external-folders"] }),
|
||||
qc.invalidateQueries({ queryKey: ["folders"] }),
|
||||
qc.invalidateQueries({ queryKey: ["mail-stats"] }),
|
||||
qc.invalidateQueries({ queryKey: ["labels"] }),
|
||||
@@ -596,7 +621,7 @@ export function MailPage() {
|
||||
}, [mailRefreshInterval, publicSettings.data?.mailAutoRefresh, qc])
|
||||
|
||||
const selected = detail.data
|
||||
const allMessages = (mailView === "external" ? externalMessages.data?.pages : messages.data?.pages)?.flatMap((page) => page.items || []) || []
|
||||
const allMessages = (mailView === "external" ? externalMessages.data?.pages : mailView === "unknown" ? unknownMessages.data?.pages : messages.data?.pages)?.flatMap((page) => page.items || []) || []
|
||||
const visibleMessages = allMessages.filter((message) => {
|
||||
if (!messageMatchesAdvancedSearch(message, advancedSearch)) return false
|
||||
if (mailFilter === "unread") return !message.isRead
|
||||
@@ -621,7 +646,7 @@ export function MailPage() {
|
||||
const sendQueueItems = sendQueue.data?.items || []
|
||||
const sendQueueCount = sendQueueItems.filter((item) => item.status === "failed" || item.status === "queued" || item.status === "sending").length
|
||||
const visibleSendQueueItems = sendQueueItems
|
||||
const mailMenuItems = buildMailMenuItems(folders.data?.items || [], starredCount, canScheduleMail ? scheduledCount : 0, canScheduleMail, canViewSendQueue ? sendQueueCount : 0, canViewSendQueue)
|
||||
const mailMenuItems = buildMailMenuItems(folders.data?.items || [], starredCount, canScheduleMail ? scheduledCount : 0, canScheduleMail, canViewSendQueue ? sendQueueCount : 0, canViewSendQueue, canViewUnknownMail)
|
||||
const primaryMailMenuItems = mailMenuItems.filter((item) => !isCustomMenuFolder(item))
|
||||
const customMailMenuItems = mailMenuItems.filter(isCustomMenuFolder)
|
||||
const canOrganizeCurrentMailbox = canOrganizeMail && !isAllMailboxSelected
|
||||
@@ -630,7 +655,10 @@ export function MailPage() {
|
||||
const externalFolderItems = externalImapEnabled ? externalFolders.data?.items || [] : []
|
||||
const labelItems = labels.data?.items || []
|
||||
const selectedLabel = labelItems.find((item) => item.id === selectedLabelId)
|
||||
const viewTitle = mailView === "external" ? `${selectedExternalAccount?.name || "外部邮箱"} · ${folderLabels[externalFolder] || externalFolder}` : mailView === "sendQueue" ? "发送队列" : mailView === "scheduled" ? "待发送" : mailView === "starred" ? "星标邮件" : mailView === "label" ? selectedLabel?.name || "标签" : folderLabels[folder] || folder
|
||||
const viewTitle = mailView === "external" ? `${selectedExternalAccount?.name || "外部邮箱"} · ${folderLabels[externalFolder] || externalFolder}` : mailView === "unknown" ? "未知收件" : mailView === "sendQueue" ? "发送队列" : mailView === "scheduled" ? "待发送" : mailView === "starred" ? "星标邮件" : mailView === "label" ? selectedLabel?.name || "标签" : folderLabels[folder] || folder
|
||||
const isTransferView = mailView === "folder" || mailView === "starred" || mailView === "label" || mailView === "unknown"
|
||||
const canExportCurrentView = canReadMail && isTransferView
|
||||
const canImportCurrentView = canOrganizeMail && isTransferView && mailView !== "unknown" && !!selectedMailbox
|
||||
const emptyMessage = getEmptyMessage(mailView, mailView === "external" ? externalFolder : folder, allMessages.length)
|
||||
const visibleMessageIds = visibleMessages.map((message) => message.id)
|
||||
const selectedCountOnPage = compactSelectedIds.filter((id) => visibleMessageIds.includes(id)).length
|
||||
@@ -638,8 +666,11 @@ export function MailPage() {
|
||||
const bulkReadAction: BulkAction = selectedMessagesOnPage.some((message) => !message.isRead) ? "read" : "unread"
|
||||
const compactAllSelected = visibleMessageIds.length > 0 && selectedCountOnPage === visibleMessageIds.length
|
||||
const compactSomeSelected = selectedCountOnPage > 0 && !compactAllSelected
|
||||
const hasMoreMessages = mailView === "external" ? !!externalMessages.hasNextPage : !!messages.hasNextPage
|
||||
const canLoadMore = mailView === "external" ? !!externalMessages.hasNextPage && !externalMessages.isFetchingNextPage : !!messages.hasNextPage && !messages.isFetchingNextPage
|
||||
const mailMessagesLoading = mailView === "external" ? externalMessages.isLoading : mailView === "unknown" ? unknownMessages.isLoading : messages.isLoading
|
||||
const mailMessagesLoadingMore = mailView === "external" ? externalMessages.isFetchingNextPage : mailView === "unknown" ? unknownMessages.isFetchingNextPage : messages.isFetchingNextPage
|
||||
const hasMoreMessages = mailView === "external" ? !!externalMessages.hasNextPage : mailView === "unknown" ? !!unknownMessages.hasNextPage : !!messages.hasNextPage
|
||||
const canLoadMore = hasMoreMessages && !mailMessagesLoadingMore
|
||||
const loadMoreMessages = () => mailView === "external" ? externalMessages.fetchNextPage() : mailView === "unknown" ? unknownMessages.fetchNextPage() : messages.fetchNextPage()
|
||||
function toggleCompactSelectAll(checked: boolean) {
|
||||
setCompactSelectedIds(checked ? visibleMessageIds : [])
|
||||
}
|
||||
@@ -649,6 +680,7 @@ export function MailPage() {
|
||||
async function refreshMailData() {
|
||||
await Promise.all([
|
||||
qc.invalidateQueries({ queryKey: ["messages"] }),
|
||||
qc.invalidateQueries({ queryKey: ["admin", "unknown-messages"] }),
|
||||
qc.invalidateQueries({ queryKey: ["external-messages"] }),
|
||||
qc.invalidateQueries({ queryKey: ["mail-external-folders"] }),
|
||||
qc.invalidateQueries({ queryKey: ["mail-external-accounts"] }),
|
||||
@@ -806,8 +838,17 @@ export function MailPage() {
|
||||
setMailFilter("all")
|
||||
setMobileSidebarOpen(false)
|
||||
}
|
||||
function openUnknownMail() {
|
||||
if (!canViewUnknownMail) return
|
||||
setSelectedExternalAccountId("")
|
||||
setMailView("unknown")
|
||||
setSelectedLabelId("")
|
||||
setSelectedId(null)
|
||||
setMailFilter("all")
|
||||
setMobileSidebarOpen(false)
|
||||
}
|
||||
function openMessageContextMenu(event: React.MouseEvent, message: MailMessage) {
|
||||
if (mailView === "external") return
|
||||
if (mailView === "external" || mailView === "unknown") return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (message.folder !== "Drafts") setSelectedId(message.id)
|
||||
@@ -828,6 +869,7 @@ export function MailPage() {
|
||||
if (item.type === "starred") openStarred()
|
||||
else if (item.type === "scheduled") openScheduled()
|
||||
else if (item.type === "sendQueue") openSendQueue()
|
||||
else if (item.type === "unknown") openUnknownMail()
|
||||
else openFolder(item.folderName)
|
||||
}
|
||||
function openExternalFolder(account: ExternalImapAccount, folderName = "INBOX") {
|
||||
@@ -1011,20 +1053,76 @@ export function MailPage() {
|
||||
return
|
||||
}
|
||||
setSelectedId(messageId)
|
||||
if (message && !message.isRead && canOrganizeMail) {
|
||||
if (message && !message.isRead && canOrganizeMail && mailView !== "unknown") {
|
||||
if (mailView === "external" && selectedExternalAccountId) markExternalRead.mutate({ id: selectedExternalAccountId, remoteId: message.id, read: true })
|
||||
else markRead.mutate({ id: message.id, read: true })
|
||||
}
|
||||
}
|
||||
async function refreshMail() {
|
||||
if (refreshing || autoRefreshing) return
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await refreshMailData()
|
||||
setLastAutoRefreshAt(new Date())
|
||||
const refreshedAt = new Date()
|
||||
setLastAutoRefreshAt(refreshedAt)
|
||||
toast({ title: "邮件已刷新", description: `更新于 ${refreshedAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })}` })
|
||||
} catch (error) {
|
||||
toast({ title: "刷新失败", description: error instanceof Error ? error.message : "请稍后重试" })
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
function chooseMailImport() {
|
||||
if (!canImportCurrentView || importingMail) {
|
||||
if (isAllMailboxSelected) toast({ title: "请先选择一个邮箱", description: "导入邮件需要明确目标邮箱。" })
|
||||
return
|
||||
}
|
||||
mailImportInputRef.current?.click()
|
||||
}
|
||||
async function importSelectedMailFiles(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = Array.from(event.target.files || [])
|
||||
event.target.value = ""
|
||||
if (files.length === 0 || !selectedMailbox) return
|
||||
setImportingMail(true)
|
||||
try {
|
||||
const result = await api.importMail(files, { mailboxId: selectedMailbox.id, folder: mailView === "folder" ? folder : "Inbox" })
|
||||
await refreshMailData()
|
||||
toast({
|
||||
title: `已导入 ${result.imported} 封邮件`,
|
||||
description: result.skipped > 0 ? `${result.skipped} 封未能导入${result.errors[0] ? `:${result.errors[0]}` : ""}` : `已保存到 ${mailView === "folder" ? viewTitle : "收件箱"}`,
|
||||
})
|
||||
} catch (error) {
|
||||
toast({ title: "导入失败", description: error instanceof Error ? error.message : "请检查 EML/MBOX 文件" })
|
||||
} finally {
|
||||
setImportingMail(false)
|
||||
}
|
||||
}
|
||||
async function copyCurrentMailbox() {
|
||||
if (!selectedMailbox?.address) return
|
||||
await navigator.clipboard.writeText(selectedMailbox.address)
|
||||
@@ -1139,7 +1237,7 @@ export function MailPage() {
|
||||
onContextMenu={(event) => openSidebarContextMenu(event, item)}
|
||||
>
|
||||
<SidebarMenuButton
|
||||
isActive={item.type === "starred" ? mailView === "starred" : item.type === "scheduled" ? mailView === "scheduled" : item.type === "sendQueue" ? mailView === "sendQueue" : mailView === "folder" && folder === item.folderName}
|
||||
isActive={item.type === "starred" ? mailView === "starred" : item.type === "scheduled" ? mailView === "scheduled" : item.type === "sendQueue" ? mailView === "sendQueue" : item.type === "unknown" ? mailView === "unknown" : mailView === "folder" && folder === item.folderName}
|
||||
className={cn(
|
||||
"h-8 rounded-md px-2 text-[13px] font-normal",
|
||||
sidebarCollapsed && "justify-center px-0",
|
||||
@@ -1349,11 +1447,27 @@ export function MailPage() {
|
||||
</Sidebar>
|
||||
)
|
||||
|
||||
const mailTransferTools = isTransferView ? (
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<Button type="button" size="icon" variant="ghost" onClick={() => void exportCurrentMail()} disabled={!canExportCurrentView || exportingMail} className="h-8 w-8 text-muted-foreground hover:text-foreground" title="导出当前邮箱邮件为 ZIP" aria-label="导出当前邮箱邮件为 ZIP">
|
||||
<Download className={cn("h-4 w-4", exportingMail && "animate-pulse")} />
|
||||
</Button>
|
||||
{mailView !== "unknown" && (
|
||||
<Button type="button" size="icon" variant="ghost" onClick={chooseMailImport} disabled={importingMail} className="h-8 w-8 text-muted-foreground hover:text-foreground disabled:pointer-events-auto" title={isAllMailboxSelected ? "请先选择一个邮箱后导入" : "导入邮件(EML/MBOX)"} aria-label="导入邮件(EML/MBOX)">
|
||||
<Upload className={cn("h-4 w-4", importingMail && "animate-pulse")} />
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" size="icon" variant="ghost" onClick={() => void refreshMail()} disabled={refreshing || autoRefreshing} className={cn("h-8 w-8 text-muted-foreground hover:text-foreground", (refreshing || autoRefreshing) && "text-primary")} title={autoRefreshing ? "自动刷新中" : "刷新当前视图"} aria-label="刷新当前视图">
|
||||
<RefreshCcw className={cn("h-4 w-4", (refreshing || autoRefreshing) && "animate-spin")} />
|
||||
</Button>
|
||||
</div>
|
||||
) : null
|
||||
|
||||
const contentView = !canAccessMail ? (
|
||||
<PermissionEmptyState title="无邮箱前台权限" description="当前账号未开启邮箱前台访问权限。" onOpenSettings={openSettings} />
|
||||
) : !canReadMail ? (
|
||||
<PermissionEmptyState title="无邮件查看权限" description="当前账号可以访问邮箱前台,但未开启邮件查看权限。" onOpenSettings={openSettings} />
|
||||
) : !mailboxList.isLoading && !hasMailboxes ? (
|
||||
) : !mailboxList.isLoading && !hasMailboxes && mailView !== "unknown" ? (
|
||||
<NoMailboxState onOpenSettings={openSettings} />
|
||||
) : mailView === "scheduled" && canScheduleMail ? (
|
||||
<ScheduledSendView
|
||||
@@ -1401,10 +1515,10 @@ export function MailPage() {
|
||||
selectedIds={compactSelectedIds}
|
||||
allSelected={compactAllSelected}
|
||||
someSelected={compactSomeSelected}
|
||||
loading={mailView === "external" ? externalMessages.isLoading : messages.isLoading}
|
||||
loading={mailMessagesLoading}
|
||||
hasMore={hasMoreMessages}
|
||||
loadingMore={mailView === "external" ? externalMessages.isFetchingNextPage : messages.isFetchingNextPage}
|
||||
onLoadMore={() => mailView === "external" ? externalMessages.fetchNextPage() : messages.fetchNextPage()}
|
||||
loadingMore={mailMessagesLoadingMore}
|
||||
onLoadMore={loadMoreMessages}
|
||||
emptyMessage={emptyMessage}
|
||||
selectedId={selectedId}
|
||||
selected={selected}
|
||||
@@ -1429,10 +1543,11 @@ export function MailPage() {
|
||||
onBulkAction={runBulkAction}
|
||||
onContextMenu={openMessageContextMenu}
|
||||
canSend={canSendMail}
|
||||
canOrganize={canOrganizeMail && mailView !== "external"}
|
||||
canManageLabels={canManageLabels && mailView !== "external"}
|
||||
canOrganize={canOrganizeMail && mailView !== "external" && mailView !== "unknown"}
|
||||
canManageLabels={canManageLabels && mailView !== "external" && mailView !== "unknown"}
|
||||
canDownloadAttachments={canDownloadAttachments}
|
||||
language={language}
|
||||
tools={!isMobile ? mailTransferTools : undefined}
|
||||
/>
|
||||
) : (
|
||||
<div className={cn("mail-content-grid min-h-0 flex-1 bg-background", selectedId && "is-reading")}>
|
||||
@@ -1488,12 +1603,8 @@ export function MailPage() {
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h1 className="min-w-0 truncate text-xl font-bold leading-none text-foreground">{mailView === "label" && selectedLabel ? selectedLabel.name : viewTitle}</h1>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="icon" variant="ghost" onClick={refreshMail} disabled={refreshing || autoRefreshing} className={cn("h-7 w-7 text-muted-foreground hover:bg-transparent hover:text-foreground", (refreshing || autoRefreshing) && "text-primary")} title={autoRefreshing ? "自动刷新中" : "刷新邮件"}>
|
||||
<RefreshCcw className={cn("h-4 w-4", (refreshing || autoRefreshing) && "animate-spin")} />
|
||||
</Button>
|
||||
</div>
|
||||
<h1 key={viewTitle} className="min-w-0 truncate text-xl font-bold leading-none text-foreground">{mailView === "label" && selectedLabel ? selectedLabel.name : viewTitle}</h1>
|
||||
{mailTransferTools}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
|
||||
@@ -1501,20 +1612,20 @@ export function MailPage() {
|
||||
<Button type="button" variant={mailFilter === "unread" ? "secondary" : "outline"} size="sm" className="h-8 rounded-md px-3 text-[13px] font-normal shadow-none" onClick={() => setMailFilter("unread")}>未读</Button>
|
||||
</div>
|
||||
</div>
|
||||
{selectedCountOnPage > 0 && canOrganizeMail && (
|
||||
{selectedCountOnPage > 0 && canOrganizeMail && mailView !== "unknown" && (
|
||||
<div className="flex min-h-10 shrink-0 items-center gap-3 border-b px-3 py-1.5">
|
||||
<span className="shrink-0 text-[13px] text-muted-foreground">已选 {selectedCountOnPage} 封</span>
|
||||
<BulkActionToolbar pending={bulkPending} currentFolder={folder} folders={folders.data?.items || []} readAction={bulkReadAction} onAction={runBulkAction} onMoveToFolder={runBulkMoveToFolder} />
|
||||
</div>
|
||||
)}
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
{(mailView === "external" ? externalMessages.isLoading : messages.isLoading) && <MessageSkeleton />}
|
||||
{visibleMessages.map((m) => <MessageRow key={m.id} message={m} active={selectedId === m.id} checked={compactSelectedIds.includes(m.id)} scheduled={scheduledDraftIds.has(m.id)} onCheckedChange={(checked) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onContextMenu={(event) => openMessageContextMenu(event, m)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} onArchive={() => move.mutate({ id: m.id, folder: m.folder === "Archive" ? "Inbox" : "Archive" })} onTrash={() => move.mutate({ id: m.id, folder: "Trash" })} onToggleRead={() => markRead.mutate({ id: m.id, read: !m.isRead })} canOrganize={canOrganizeMail} />)}
|
||||
{!(mailView === "external" ? externalMessages.isLoading : messages.isLoading) && visibleMessages.length === 0 && <div className="grid min-h-[170px] place-items-center px-8 py-12 text-center text-base text-muted-foreground">{emptyMessage}</div>}
|
||||
{!(mailView === "external" ? externalMessages.isLoading : messages.isLoading) && hasMoreMessages && (
|
||||
{mailMessagesLoading && <MessageSkeleton />}
|
||||
{visibleMessages.map((m) => <MessageRow key={m.id} message={m} active={selectedId === m.id} checked={compactSelectedIds.includes(m.id)} scheduled={scheduledDraftIds.has(m.id)} onCheckedChange={(checked) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onContextMenu={(event) => openMessageContextMenu(event, m)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} onArchive={() => move.mutate({ id: m.id, folder: m.folder === "Archive" ? "Inbox" : "Archive" })} onTrash={() => move.mutate({ id: m.id, folder: "Trash" })} onToggleRead={() => markRead.mutate({ id: m.id, read: !m.isRead })} canOrganize={canOrganizeMail && mailView !== "unknown"} />)}
|
||||
{!mailMessagesLoading && visibleMessages.length === 0 && <div className="grid min-h-[170px] place-items-center px-8 py-12 text-center text-base text-muted-foreground">{emptyMessage}</div>}
|
||||
{!mailMessagesLoading && hasMoreMessages && (
|
||||
<div className="border-b p-4 text-center">
|
||||
<Button variant="outline" size="sm" disabled={!canLoadMore} onClick={() => mailView === "external" ? externalMessages.fetchNextPage() : messages.fetchNextPage()}>
|
||||
{(mailView === "external" ? externalMessages.isFetchingNextPage : messages.isFetchingNextPage) ? "加载中..." : "加载更多"}
|
||||
<Button variant="outline" size="sm" disabled={!canLoadMore} onClick={loadMoreMessages}>
|
||||
{mailMessagesLoadingMore ? "加载中..." : "加载更多"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1546,18 +1657,18 @@ export function MailPage() {
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{canSendMail && <Button variant="outline" size="sm" onClick={() => openReply(selected)}><Reply className="h-4 w-4" />回复</Button>}
|
||||
{canSendMail && <Button variant="outline" size="sm" onClick={() => openForward(selected)}><Forward className="h-4 w-4" />转发</Button>}
|
||||
{mailView !== "external" && selected.sendQueueId && <Button variant="outline" size="sm" onClick={() => openMessageSendTimeline(selected)}><History className="h-4 w-4" />投递时间线</Button>}
|
||||
{mailView !== "external" && canOrganizeMail && (selected.folder === "Archive" ? (
|
||||
{mailView !== "external" && mailView !== "unknown" && selected.sendQueueId && <Button variant="outline" size="sm" onClick={() => openMessageSendTimeline(selected)}><History className="h-4 w-4" />投递时间线</Button>}
|
||||
{mailView !== "external" && mailView !== "unknown" && canOrganizeMail && (selected.folder === "Archive" ? (
|
||||
<Button variant="outline" size="sm" onClick={() => move.mutate({ id: selected.id, folder: "Inbox" })}>取消归档</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => move.mutate({ id: selected.id, folder: "Archive" })}>归档</Button>
|
||||
))}
|
||||
{mailView !== "external" && canOrganizeMail && <Button variant="destructive" size="sm" onClick={() => confirmDeleteMessage(selected)}>删除</Button>}
|
||||
{mailView !== "external" && mailView !== "unknown" && canOrganizeMail && <Button variant="destructive" size="sm" onClick={() => confirmDeleteMessage(selected)}>删除</Button>}
|
||||
</div>
|
||||
</div>
|
||||
<MessageMetaPanel
|
||||
message={selected}
|
||||
{...(canManageLabels ? { availableLabels: labelItems, onAddLabel: (label: MailLabel) => addLabel.mutate({ id: selected.id, label }), onRemoveLabel: (labelId: string) => removeLabel.mutate({ id: selected.id, labelId }), labelPending: addLabel.isPending || removeLabel.isPending } : {})}
|
||||
{...(canManageLabels && mailView !== "unknown" ? { availableLabels: labelItems, onAddLabel: (label: MailLabel) => addLabel.mutate({ id: selected.id, label }), onRemoveLabel: (labelId: string) => removeLabel.mutate({ id: selected.id, labelId }), labelPending: addLabel.isPending || removeLabel.isPending } : {})}
|
||||
/>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
@@ -1588,10 +1699,8 @@ export function MailPage() {
|
||||
<div className="h-svh">{sidebarContent}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<Button size="icon" variant="ghost" onClick={refreshMail} disabled={refreshing || autoRefreshing} className={cn("transition-all", (refreshing || autoRefreshing) && "bg-primary/5 text-primary")} title={autoRefreshing ? "自动刷新中" : "刷新邮件"}>
|
||||
<RefreshCcw className={cn("h-4 w-4", (refreshing || autoRefreshing) && "animate-spin")} />
|
||||
</Button>
|
||||
<div className="min-w-0 flex-1 text-sm font-semibold">{mailView === "label" && selectedLabel ? <Badge variant="outline" className="gap-1.5 rounded-md font-normal"><span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: generateLabelColor(selectedLabel.name).backgroundColor }} />{selectedLabel.name}</Badge> : viewTitle}</div>
|
||||
{mailTransferTools}
|
||||
{canSendMail && <Button type="button" size="icon" onClick={() => openCompose()} disabled={!selectedComposeMailbox} aria-label="写邮件"><PencilLine className="h-4 w-4" /></Button>}
|
||||
<div className="relative basis-full">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
@@ -1603,9 +1712,9 @@ export function MailPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="mail-shell-grid h-full min-h-0 w-full min-w-0 overflow-hidden">
|
||||
<aside className="min-w-0">
|
||||
<div className="min-w-0">
|
||||
{sidebarContent}
|
||||
</aside>
|
||||
</div>
|
||||
<section className="flex h-full min-h-0 min-w-0 flex-col">
|
||||
{contentView}
|
||||
</section>
|
||||
@@ -1614,6 +1723,7 @@ export function MailPage() {
|
||||
</SidebarProvider>
|
||||
|
||||
<ComposeDialog mailbox={selectedComposeMailbox} open={composeOpen} draft={composeDraft} limits={user?.limits} canSend={canSendMail} canManageDrafts={canManageDrafts} canSchedule={canScheduleMail} canManageSignatures={canManageSignatures} 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"] }); qc.invalidateQueries({ queryKey: ["scheduled-sends"] }); qc.invalidateQueries({ queryKey: ["send-queue"] }) }} />
|
||||
<Input ref={mailImportInputRef} type="file" accept=".eml,.mbox,message/rfc822,application/mbox" multiple className="hidden" onChange={importSelectedMailFiles} />
|
||||
<SendQueueAuditDialog
|
||||
open={!!sendQueueAuditId}
|
||||
loading={sendQueueAudit.isLoading}
|
||||
@@ -1685,7 +1795,7 @@ export function MailPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function buildMailMenuItems(folders: MailFolder[], starredCount: number, scheduledCount: number, includeScheduled: boolean, sendQueueCount: number, includeSendQueue: boolean): MailMenuItem[] {
|
||||
function buildMailMenuItems(folders: MailFolder[], starredCount: number, scheduledCount: number, includeScheduled: boolean, sendQueueCount: number, includeSendQueue: boolean, includeUnknown: boolean): MailMenuItem[] {
|
||||
const byName = new Map(folders.map((item) => [item.name, item]))
|
||||
const normalizedFolders = ["Inbox", "Drafts", "Sent", "Archive", "Spam", "Trash"].map((name) => byName.get(name) || { id: `virtual-${name}`, name, role: name.toLowerCase(), sortOrder: 0, unreadCount: 0, totalCount: 0, uidValidity: 0, uidNext: 1, highestModseq: 1 })
|
||||
for (const item of folders) {
|
||||
@@ -1702,11 +1812,13 @@ function buildMailMenuItems(folders: MailFolder[], starredCount: number, schedul
|
||||
custom: isCustomMailFolder(item),
|
||||
order: isCustomMailFolder(item) ? item.sortOrder || 100000 : menuAnchorOrder(item.name),
|
||||
}))
|
||||
const starredItem: MailMenuItem = { type: "starred", key: "starred", label: "星标邮件", icon: <Star className="h-4 w-4" />, count: starredCount, order: 2000 }
|
||||
const scheduledItem: MailMenuItem = { type: "scheduled", key: "scheduled", label: "稍后提醒", icon: <Clock3 className="h-4 w-4" />, count: scheduledCount, order: 6000 }
|
||||
const sendQueueItem: MailMenuItem = { type: "sendQueue", key: "send-queue", label: "发送队列", icon: <History className="h-4 w-4" />, count: sendQueueCount, order: 9000 }
|
||||
const starredItem: MailMenuItem = { type: "starred", key: "starred", label: "星标邮件", icon: <Star className="h-4 w-4" />, count: starredCount, order: 6000 }
|
||||
const scheduledItem: MailMenuItem = { type: "scheduled", key: "scheduled", label: "稍后提醒", icon: <Clock3 className="h-4 w-4" />, count: scheduledCount, order: 7000 }
|
||||
const unknownItem: MailMenuItem = { type: "unknown", key: "unknown", label: "未知收件", icon: <MailQuestion className="h-4 w-4" />, count: 0, order: 8000 }
|
||||
const sendQueueItem: MailMenuItem = { type: "sendQueue", key: "send-queue", label: "发送队列", icon: <History className="h-4 w-4" />, count: sendQueueCount, order: 10000 }
|
||||
const specialItems: MailMenuItem[] = [starredItem]
|
||||
if (includeScheduled) specialItems.push(scheduledItem)
|
||||
if (includeUnknown) specialItems.push(unknownItem)
|
||||
if (includeSendQueue && sendQueueCount > 0) specialItems.push(sendQueueItem)
|
||||
return [...folderItems, ...specialItems].sort((a, b) => a.order - b.order || a.label.localeCompare(b.label))
|
||||
}
|
||||
@@ -1840,11 +1952,11 @@ function searchTextMatches(values: string[], needle: string) {
|
||||
function menuAnchorOrder(name: string) {
|
||||
switch (name) {
|
||||
case "Inbox": return 1000
|
||||
case "Drafts": return 3000
|
||||
case "Sent": return 4000
|
||||
case "Archive": return 5000
|
||||
case "Trash": return 7000
|
||||
case "Spam": return 8000
|
||||
case "Drafts": return 2000
|
||||
case "Sent": return 3000
|
||||
case "Archive": return 4000
|
||||
case "Trash": return 5000
|
||||
case "Spam": return 9000
|
||||
default: return 100000
|
||||
}
|
||||
}
|
||||
@@ -1912,9 +2024,9 @@ function SearchFilterChip({ label, onRemove }: { label: string; onRemove: () =>
|
||||
return (
|
||||
<span className="inline-flex h-6 max-w-full items-center gap-1 rounded-full border bg-accent px-2 text-xs text-accent-foreground">
|
||||
<span className="truncate">{label}</span>
|
||||
<button type="button" className="grid h-4 w-4 shrink-0 place-items-center rounded-full text-muted-foreground hover:bg-background hover:text-foreground" onClick={onRemove} aria-label={`移除筛选 ${label}`}>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-4 w-4 shrink-0 rounded-full p-0 text-muted-foreground shadow-none hover:bg-background hover:text-foreground" onClick={onRemove} aria-label={`移除筛选 ${label}`}>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Button>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1924,6 +2036,7 @@ function MessageSkeleton() { return <div className="space-y-0">{Array.from({ len
|
||||
|
||||
function getEmptyMessage(mailView: MailView, folder: string, total: number) {
|
||||
if (mailView === "external") return total === 0 ? "远端文件夹没有邮件" : "当前筛选条件下没有远端邮件"
|
||||
if (mailView === "unknown") return total === 0 ? "暂无未知收件" : "当前筛选条件下没有邮件"
|
||||
if (mailView === "scheduled") return total === 0 ? "没有待发送邮件" : "当前搜索没有匹配的定时邮件"
|
||||
if (mailView === "sendQueue") return total === 0 ? "发送队列为空" : "当前搜索没有匹配的发送任务"
|
||||
if (total > 0) return "当前筛选条件下没有邮件"
|
||||
@@ -2059,6 +2172,7 @@ function attachmentHref(message: MailMessage, attachmentId: string) {
|
||||
if (message.externalAccountId) {
|
||||
return `/api/mail/external-accounts/${encodeURIComponent(message.externalAccountId)}/attachments/${encodeURIComponent(message.id)}/${encodeURIComponent(attachmentId)}`
|
||||
}
|
||||
if (!message.mailboxId) return `/api/admin/attachments/${encodeURIComponent(attachmentId)}`
|
||||
return `/api/mail/attachments/${attachmentId}`
|
||||
}
|
||||
|
||||
@@ -2571,6 +2685,7 @@ function CompactMailView({
|
||||
canManageLabels,
|
||||
canDownloadAttachments,
|
||||
language,
|
||||
tools,
|
||||
}: {
|
||||
title: string
|
||||
icon?: React.ReactNode
|
||||
@@ -2611,6 +2726,7 @@ function CompactMailView({
|
||||
canManageLabels: boolean
|
||||
canDownloadAttachments: boolean
|
||||
language: Language
|
||||
tools?: React.ReactNode
|
||||
}) {
|
||||
const selectedIndex = selectedId ? messages.findIndex((message) => message.id === selectedId) : -1
|
||||
const previousMessage = selectedIndex > 0 ? messages[selectedIndex - 1] : undefined
|
||||
@@ -2662,7 +2778,10 @@ function CompactMailView({
|
||||
{canOrganize && <BulkActionToolbar pending={bulkPending} onAction={onBulkAction} />}
|
||||
</>
|
||||
) : (
|
||||
<div className="ml-auto text-xs text-muted-foreground">{messages.length} / {total} 封</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="text-xs text-muted-foreground">{messages.length} / {total} 封</div>
|
||||
{tools}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -2829,7 +2948,7 @@ function TranslatableMailBody({ message, language }: { message: MailMessage; lan
|
||||
const { toast } = useToast()
|
||||
const targetLanguage = normalizeTranslationLanguage(language)
|
||||
const sourceText = React.useMemo(() => (message.bodyText || stripHtml(message.bodyHtml || message.snippet || "")).trim(), [message.bodyHtml, message.bodyText, message.snippet])
|
||||
const shouldShow = targetLanguage && shouldOfferMessageTranslation(sourceText, language)
|
||||
const shouldShow = targetLanguage && (message.externalAccountId || message.mailboxId) && shouldOfferMessageTranslation(sourceText, language)
|
||||
const translatedMessage = React.useMemo<MailMessage>(() => ({ ...message, bodyText: translatedText, bodyHtml: translatedHtml }), [message, translatedHtml, translatedText])
|
||||
const translate = useMutation({
|
||||
mutationFn: () => message.externalAccountId ? api.translateExternalMessage(message.externalAccountId, message.id, targetLanguage!) : api.translateMessage(message.id, targetLanguage!),
|
||||
|
||||
+210
-215
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useNavigate, useSearchParams } from "react-router-dom"
|
||||
import { ArrowLeft, BarChart3, Ban, BookOpen, ChevronDown, Clock3, Code2, Contact, Copy, ExternalLink, HardDrive, Image, Info, KeyRound, Laptop, Link2, LogOut, Mail, MailCheck, MailX, MessageSquare, Moon, PanelLeftOpen, PencilLine, Plus, RefreshCcw, Search, SendHorizontal, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, Users, X } from "lucide-react"
|
||||
import { ArrowLeft, BarChart3, Ban, Bell, BellOff, BookOpen, ChevronDown, ChevronUp, Clock3, Code2, Contact, Copy, HardDrive, Image, Info, KeyRound, Laptop, Link2, LogOut, Mail, MailCheck, MailX, MessageSquare, Moon, PanelLeftOpen, PencilLine, PlayCircle, Plus, RefreshCcw, Search, SendHorizontal, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, Users, X } from "lucide-react"
|
||||
import { QRCodeSVG } from "qrcode.react"
|
||||
import { api, APIToken, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapStorageMode, ExternalImapSyncRun, ExternalImapTlsMode, ForwardingSettings, ForwardingVerifiedEmail, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats, PermissionLimits } from "@/lib/api"
|
||||
import { cn, formatBytes } from "@/lib/utils"
|
||||
@@ -24,6 +24,7 @@ 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 { Switch } from "@/components/ui/switch"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog"
|
||||
@@ -52,7 +53,6 @@ const accountSettingTabs: { key: AccountSettingsTab; label: string }[] = [
|
||||
{ key: "security", label: "安全" },
|
||||
]
|
||||
const actionLabels: Record<string, string> = { archive: "移入归档", trash: "移入回收站", star: "添加星标", "mark-read": "标记已读", label: "添加标签", move: "移动到", forward: "邮件转发" }
|
||||
|
||||
export function ProfilePage() {
|
||||
const me = useMe()
|
||||
const qc = useQueryClient()
|
||||
@@ -107,6 +107,8 @@ export function ProfilePage() {
|
||||
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 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 selectedMailbox = React.useMemo(() => mailboxes.data?.items.find((m) => m.id === mailboxId), [mailboxes.data?.items, mailboxId])
|
||||
const activeMailboxId = selectedMailbox?.id || ""
|
||||
@@ -211,6 +213,27 @@ export function ProfilePage() {
|
||||
onError: (error) => toast({ title: "保存失败", description: error.message }),
|
||||
})
|
||||
const deleteRule = useMutation({ mutationFn: api.deleteRule, onSuccess: () => { qc.invalidateQueries({ queryKey: ["rules"] }); toast({ title: "规则已删除" }) } })
|
||||
const updateRule = useMutation({
|
||||
mutationFn: ({ id, payload }: { id: string; payload: Partial<RuleCreatePayload> }) => api.updateRule(id, payload),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["rules"] }); setRuleDialogOpen(false); toast({ title: "收件规则已更新" }) },
|
||||
onError: (error) => toast({ title: "更新失败", description: error.message }),
|
||||
})
|
||||
const moveRule = useMutation({
|
||||
mutationFn: ({ id, direction }: { id: string; direction: "up" | "down" }) => api.moveRule(id, direction),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["rules"] }),
|
||||
onError: (error) => toast({ title: "排序失败", description: error.message }),
|
||||
})
|
||||
const applyRule = useMutation({
|
||||
mutationFn: api.applyRule,
|
||||
onSuccess: (res) => {
|
||||
qc.invalidateQueries({ queryKey: ["messages"] })
|
||||
qc.invalidateQueries({ queryKey: ["folders"] })
|
||||
qc.invalidateQueries({ queryKey: ["mail-stats"] })
|
||||
qc.invalidateQueries({ queryKey: ["labels"] })
|
||||
toast({ title: `规则已应用到 ${res.affected} 封现有邮件` })
|
||||
},
|
||||
onError: (error) => toast({ title: "应用失败", description: error.message }),
|
||||
})
|
||||
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: "拦截规则已保存" }) },
|
||||
@@ -315,7 +338,7 @@ export function ProfilePage() {
|
||||
if (me.isError || !user) return <div className="grid h-svh place-items-center text-muted-foreground">登录状态已失效</div>
|
||||
|
||||
const sidebarContent = (
|
||||
<aside className="flex h-full w-[256px] shrink-0 flex-col border-r border-border bg-card">
|
||||
<div className="flex h-full w-[256px] shrink-0 flex-col border-r border-border bg-card">
|
||||
<div className="h-[64px] border-b">
|
||||
<AccountHeader name={user.displayName || selectedMailbox?.address || "NewSzxcn"} email={user.email || selectedMailbox?.address} darkMode={darkMode} onToggleTheme={() => setDarkMode((v) => !v)} onBack={() => navigate("/")} />
|
||||
</div>
|
||||
@@ -327,7 +350,7 @@ export function ProfilePage() {
|
||||
key={key}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-[36px] w-full items-center gap-2 rounded-md px-3 text-left text-sm transition-colors",
|
||||
"flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-sm transition-colors",
|
||||
tab === key ? "bg-muted font-semibold text-foreground" : "text-muted-foreground hover:bg-muted/70 hover:text-foreground",
|
||||
)}
|
||||
onClick={() => setTab(key)}
|
||||
@@ -344,10 +367,16 @@ export function ProfilePage() {
|
||||
<span>退出登录</span>
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
|
||||
const pageTitle = tab === "feedback" ? "反馈与工单" : tabs[tab].label
|
||||
const pageSubtitle = tab === "stats" ? "查看邮件收发趋势、分布情况和常用联系人。" : undefined
|
||||
const pageAction = tab === "stats"
|
||||
? <StatsRangeTabs rangeDays={statsRangeDays} onRangeChange={setStatsRangeDays} />
|
||||
: 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
|
||||
|
||||
return (
|
||||
<div className="h-svh overflow-hidden bg-background">
|
||||
@@ -367,9 +396,9 @@ export function ProfilePage() {
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/")} aria-label="返回邮箱"><ArrowLeft className="h-4 w-4" /></Button>
|
||||
</header>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<main className="w-full px-4 pb-10 pt-4">
|
||||
<SettingsPageHeader title={pageTitle} activeTab={tab === "profile" ? accountTab : undefined} onAccountTabChange={setAccountTab} />
|
||||
<div className={cn("w-full", tab === "rules" ? "mr-auto" : "mx-auto", tab === "mailboxes" ? "pt-[34px]" : "pt-6", tab === "profile" || tab === "mailboxes" ? "max-w-[896px]" : tab === "stats" ? "max-w-[854px]" : tab === "rules" ? "max-w-[1320px]" : "max-w-[1024px]")}>{renderTab()}</div>
|
||||
<main className="w-full pb-10">
|
||||
<SettingsPageHeader title={pageTitle} subtitle={pageSubtitle} action={pageAction} activeTab={tab === "profile" ? accountTab : undefined} onAccountTabChange={setAccountTab} />
|
||||
<div className={contentFrameClass(tab)}>{renderTab()}</div>
|
||||
</main>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
@@ -377,9 +406,9 @@ export function ProfilePage() {
|
||||
<div className="flex h-full min-h-0 w-full">
|
||||
{sidebarContent}
|
||||
<section className="min-w-0 flex-1 overflow-y-auto">
|
||||
<main className="px-[24px] pb-12 pt-4">
|
||||
<SettingsPageHeader title={pageTitle} activeTab={tab === "profile" ? accountTab : undefined} onAccountTabChange={setAccountTab} />
|
||||
<div className={cn("w-full", tab === "rules" ? "mr-auto" : "mx-auto", tab === "mailboxes" ? "pt-[34px]" : "pt-6", tab === "profile" || tab === "mailboxes" ? "max-w-[896px]" : tab === "stats" ? "max-w-[854px]" : tab === "rules" ? "max-w-[1320px]" : "max-w-[1024px]")}>{renderTab()}</div>
|
||||
<main className="pb-12">
|
||||
<SettingsPageHeader title={pageTitle} subtitle={pageSubtitle} action={pageAction} activeTab={tab === "profile" ? accountTab : undefined} onAccountTabChange={setAccountTab} />
|
||||
<div className={contentFrameClass(tab)}>{renderTab()}</div>
|
||||
</main>
|
||||
</section>
|
||||
</div>
|
||||
@@ -449,11 +478,10 @@ export function ProfilePage() {
|
||||
onSyncExternalFolder={(id, folder) => syncExternalImapFolder.mutate({ id, folder })}
|
||||
/>
|
||||
)
|
||||
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} />
|
||||
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 === "rules") return <RulesSection items={rules.data?.items || []} mailboxes={mailboxes.data?.items || []} labels={labels.data?.items || []} open={ruleDialogOpen} onOpenChange={setRuleDialogOpen} onCreate={(payload) => createRule.mutate(payload)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending} />
|
||||
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 === "feedback") return <FeedbackSection />
|
||||
@@ -462,12 +490,29 @@ export function ProfilePage() {
|
||||
}
|
||||
}
|
||||
|
||||
function SettingsPageHeader({ title, activeTab, onAccountTabChange }: { title: string; activeTab?: AccountSettingsTab; onAccountTabChange: (tab: AccountSettingsTab) => void }) {
|
||||
function contentFrameClass(tab: Tab) {
|
||||
return cn(
|
||||
"w-full",
|
||||
tab === "mailboxes" ? "pt-[34px]" : "pt-6",
|
||||
tab === "profile" || tab === "mailboxes" ? "mx-auto max-w-[896px]" :
|
||||
tab === "stats" ? "px-4 sm:px-6" :
|
||||
tab === "rules" || tab === "apiTokens" ? "mx-auto max-w-[896px] px-4 sm:px-0" :
|
||||
"mx-auto max-w-[1024px] px-4 sm:px-0",
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsPageHeader({ title, subtitle, action, activeTab, onAccountTabChange }: { title: string; subtitle?: string; action?: React.ReactNode; activeTab?: AccountSettingsTab; onAccountTabChange: (tab: AccountSettingsTab) => void }) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-3 text-[20px] font-semibold leading-7">{title}</h1>
|
||||
<div className="border-b px-4 py-4 sm:px-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-[20px] font-semibold leading-7">{title}</h1>
|
||||
{subtitle && <p className="text-sm leading-5 text-muted-foreground">{subtitle}</p>}
|
||||
</div>
|
||||
{action && <div className="shrink-0">{action}</div>}
|
||||
</div>
|
||||
{activeTab && (
|
||||
<div className="flex overflow-x-auto border-b">
|
||||
<div className="mt-3 flex overflow-x-auto border-b">
|
||||
{accountSettingTabs.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
@@ -487,6 +532,28 @@ function SettingsPageHeader({ title, activeTab, onAccountTabChange }: { title: s
|
||||
)
|
||||
}
|
||||
|
||||
function StatsRangeTabs({ rangeDays, onRangeChange }: { rangeDays: number; onRangeChange: (days: number) => void }) {
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-1 sm:flex">
|
||||
{[
|
||||
[7, "7天"],
|
||||
[30, "30天"],
|
||||
[90, "90天"],
|
||||
[365, "365天"],
|
||||
].map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={cn("h-[30px] rounded-md border px-3 text-xs font-normal transition-colors", rangeDays === value ? "border-primary bg-primary text-primary-foreground" : "bg-background text-foreground hover:bg-muted")}
|
||||
onClick={() => onRangeChange(Number(value))}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type AccountSettingsSectionProps = {
|
||||
activeTab: AccountSettingsTab
|
||||
user: { id: string; email: string; displayName: string; role: string; disabled: boolean; twoFactorEnabled: boolean; createdAt: string; limits?: PermissionLimits }
|
||||
@@ -602,12 +669,17 @@ function AccountTabSection({ user, stats, selectedMailbox, mailboxes, onOpenClea
|
||||
<InfoLine label="用户名" value={accountName} />
|
||||
<div className="grid gap-2 sm:grid-cols-[10rem_minmax(0,1fr)] sm:items-center">
|
||||
<Label className="text-base font-normal text-muted-foreground">时区</Label>
|
||||
<select className="h-[29px] rounded-md border border-input bg-background px-2 text-sm outline-none focus:ring-1 focus:ring-ring sm:ml-auto sm:w-[236px]" defaultValue="Asia/Shanghai">
|
||||
<option value="Asia/Shanghai">Asia/Shanghai (UTC+8)</option>
|
||||
<option value="Asia/Tokyo">Asia/Tokyo (UTC+9)</option>
|
||||
<option value="Asia/Singapore">Asia/Singapore (UTC+8)</option>
|
||||
<option value="UTC">UTC (UTC+0)</option>
|
||||
</select>
|
||||
<Select defaultValue="Asia/Shanghai">
|
||||
<SelectTrigger className="h-[29px] sm:ml-auto sm:w-[236px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Asia/Shanghai">Asia/Shanghai (UTC+8)</SelectItem>
|
||||
<SelectItem value="Asia/Tokyo">Asia/Tokyo (UTC+9)</SelectItem>
|
||||
<SelectItem value="Asia/Singapore">Asia/Singapore (UTC+8)</SelectItem>
|
||||
<SelectItem value="UTC">UTC (UTC+0)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
@@ -754,7 +826,7 @@ function MailPreferencesSection({
|
||||
<SettingsCard title="标签管理">
|
||||
<form className="flex gap-2" onSubmit={submitLabel}>
|
||||
<Input name="name" className="h-[42px] flex-1 text-base" placeholder={selectedMailbox ? "标签名称" : "请先选择邮箱"} disabled={!selectedMailbox || labelsPending} required />
|
||||
<input type="color" value={labelColor} onChange={(event) => setLabelColor(event.target.value)} className="h-10 w-12 cursor-pointer rounded-md border border-input bg-background p-1" aria-label="标签颜色" />
|
||||
<Input type="color" value={labelColor} onChange={(event) => setLabelColor(event.target.value)} className="h-10 w-12 cursor-pointer bg-background p-1" aria-label="标签颜色" />
|
||||
<Button className="h-10 px-4" disabled={!selectedMailbox || labelsPending}>{labelsPending ? "创建中" : "创建"}</Button>
|
||||
</form>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
@@ -762,9 +834,9 @@ function MailPreferencesSection({
|
||||
<span key={label.id} className="inline-flex h-8 items-center gap-2 rounded-full border px-3 text-sm">
|
||||
<span className="size-3 rounded-full" style={{ backgroundColor: label.color || "#64748b" }} />
|
||||
{label.name}
|
||||
<button type="button" className="text-muted-foreground hover:text-destructive" disabled={labelsPending} onClick={() => onDeleteLabel(label.id)} aria-label={`删除标签 ${label.name}`}>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 p-0 text-muted-foreground shadow-none hover:text-destructive" disabled={labelsPending} onClick={() => onDeleteLabel(label.id)} aria-label={`删除标签 ${label.name}`}>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</Button>
|
||||
</span>
|
||||
))}
|
||||
{!labelsLoading && labels.length === 0 && <span className="text-sm text-muted-foreground">暂无标签</span>}
|
||||
@@ -1130,11 +1202,7 @@ function writeFeedbackTickets(items: FeedbackTicket[]) {
|
||||
}
|
||||
|
||||
function SwitchButton({ checked, onClick }: { checked: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button type="button" className={cn("relative h-6 w-11 rounded-full transition-colors", checked ? "bg-primary" : "bg-muted-foreground/30")} onClick={onClick} aria-pressed={checked}>
|
||||
<span className={cn("absolute top-0.5 size-5 rounded-full bg-background shadow transition-transform", checked ? "translate-x-5" : "translate-x-0.5")} />
|
||||
</button>
|
||||
)
|
||||
return <Switch checked={checked} onCheckedChange={onClick} aria-label="切换设置" />
|
||||
}
|
||||
|
||||
function readLocalString(key: string) {
|
||||
@@ -2313,29 +2381,19 @@ function ApiTokensSection({ items, loading, pending, onCreate, onUpdate, onDelet
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-stretch sm:justify-end">
|
||||
<Button asChild variant="outline" size="sm" className="w-full sm:w-auto">
|
||||
<a href="https://github.com/zxyszx/NewSzxcn-Email/blob/main/docs/API.md" target="_blank" rel="noreferrer">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
API 文档
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SettingsCard
|
||||
title="API 密钥"
|
||||
subtitle="用于服务端集成调用 `/api/open`,创建后请立即保存。"
|
||||
action={<Button type="button" size="sm" className="w-full shrink-0 sm:w-auto" onClick={openCreateDialog}><Plus className="h-4 w-4" />创建密钥</Button>}
|
||||
contentClassName="space-y-3"
|
||||
>
|
||||
<div>
|
||||
<section className="rounded-lg border bg-card">
|
||||
<div className="flex min-h-16 items-center justify-between gap-3 px-4 py-3">
|
||||
<h2 className="text-lg font-semibold leading-7">API 密钥</h2>
|
||||
<Button type="button" size="sm" className="h-8 rounded-md px-3 text-xs" onClick={openCreateDialog}>创建密钥</Button>
|
||||
</div>
|
||||
<div className="space-y-2 border-t px-4 py-4">
|
||||
{createdToken && (
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 p-4 text-amber-950">
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-amber-950">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold"><KeyRound className="h-4 w-4" />只显示一次</div>
|
||||
<div className="mt-2 flex min-w-0 flex-col gap-2 sm:flex-row">
|
||||
<code className="min-w-0 flex-1 overflow-x-auto rounded border bg-background px-3 py-2 text-xs">{createdToken}</code>
|
||||
<Button type="button" variant="outline" onClick={() => onCopy(createdToken)}><Copy className="h-4 w-4" />复制</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => onCopy(createdToken)}><Copy className="h-4 w-4" />复制</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -2343,11 +2401,11 @@ function ApiTokensSection({ items, loading, pending, onCreate, onUpdate, onDelet
|
||||
{items.map((item) => {
|
||||
const expired = item.expiresAt ? new Date(item.expiresAt).getTime() <= Date.now() : false
|
||||
return (
|
||||
<div key={item.id} className="grid gap-3 rounded-lg border bg-background p-4 transition-colors hover:bg-muted/40 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
||||
<div key={item.id} className="grid min-h-[74px] gap-3 rounded-lg border bg-background px-3 py-3 transition-colors hover:bg-muted/30 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="truncate text-sm font-semibold">{item.name}</div>
|
||||
<Badge variant={item.disabled || expired ? "secondary" : "default"}>{item.disabled ? "已禁用" : expired ? "已过期" : "可用"}</Badge>
|
||||
<Badge variant={item.disabled || expired ? "secondary" : "default"} className="h-5 px-1.5 text-[10px]">{item.disabled ? "已禁用" : expired ? "已过期" : "可用"}</Badge>
|
||||
</div>
|
||||
<div className="mt-2 grid gap-1 text-xs leading-5 text-muted-foreground sm:grid-cols-3">
|
||||
<span>创建:{formatDateTime(item.createdAt)}</span>
|
||||
@@ -2355,20 +2413,21 @@ function ApiTokensSection({ items, loading, pending, onCreate, onUpdate, onDelet
|
||||
<span>最后使用:{item.lastUsedAt ? formatDateTime(item.lastUsedAt) : "从未使用"}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{(item.scopes || ["*"]).map((scope) => <Badge key={scope} variant="outline">{scope}</Badge>)}
|
||||
{(item.scopes || ["*"]).map((scope) => <Badge key={scope} variant="outline" className="h-5 px-1.5 text-[10px] font-normal">{scope}</Badge>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 lg:justify-end">
|
||||
<Button type="button" variant="outline" size="sm" disabled={pending} onClick={() => { setEditingToken(item); setEditingScopes(item.scopes?.includes("*") ? ["messages:send", "messages:read"] : item.scopes || []) }}>编辑权限</Button>
|
||||
<Button type="button" variant="outline" size="sm" disabled={pending || expired} onClick={() => onUpdate(item.id, { disabled: !item.disabled })}>{item.disabled ? "启用" : "禁用"}</Button>
|
||||
<Button type="button" variant="destructive" size="sm" disabled={pending} onClick={() => setPendingConfirm({ title: "撤销 API 密钥?", description: `密钥“${item.name}”撤销后无法恢复,正在使用它的集成会立即失效。`, confirmText: "撤销密钥", destructive: true, onConfirm: () => { onDelete(item.id); setPendingConfirm(null) } })}>撤销</Button>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" disabled={pending} onClick={() => { setEditingToken(item); setEditingScopes(item.scopes?.includes("*") ? ["messages:send", "messages:read"] : item.scopes || []) }}>编辑权限</Button>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" disabled={pending || expired} onClick={() => onUpdate(item.id, { disabled: !item.disabled })}>{item.disabled ? "启用" : "禁用"}</Button>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs text-destructive hover:text-destructive" disabled={pending} onClick={() => setPendingConfirm({ title: "撤销 API 密钥?", description: `密钥“${item.name}”撤销后无法恢复,正在使用它的集成会立即失效。`, confirmText: "撤销密钥", destructive: true, onConfirm: () => { onDelete(item.id); setPendingConfirm(null) } })}>撤销</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{!loading && items.length === 0 && <EmptyState icon={<KeyRound />} text="暂无 API 密钥" description="点击上方按钮创建" action={<Button type="button" variant="outline" size="sm" onClick={openCreateDialog}><Plus className="h-4 w-4" />创建密钥</Button>} />}
|
||||
{loading && items.length === 0 && <EmptyState icon={<KeyRound />} text="正在加载 API 密钥" />}
|
||||
</SettingsCard>
|
||||
{!loading && items.length === 0 && <div className="grid min-h-[56px] place-items-center text-sm text-muted-foreground">暂无 API 密钥,点击上方按钮创建</div>}
|
||||
{loading && items.length === 0 && <div className="grid min-h-[56px] place-items-center text-sm text-muted-foreground">正在加载 API 密钥</div>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogContent className="max-h-[92dvh] overflow-y-auto sm:max-w-2xl">
|
||||
@@ -2610,22 +2669,29 @@ const conditionFields = Object.keys(conditionFieldLabels) as RuleConditionField[
|
||||
const commonRuleFolders = ["Inbox", "Archive", "Spam", "Trash"]
|
||||
const ruleActionLabels: Record<MailRuleAction["type"], string> = { archive: "移入归档", trash: "移入回收站", star: "添加星标", "mark-read": "标记已读", label: "添加标签", move: "移动到", forward: "邮件转发" }
|
||||
|
||||
function RulesSection({ items, mailboxes, labels, open, onOpenChange, onCreate, onDelete, pending }: { items: MailRule[]; mailboxes: Mailbox[]; labels: MailLabel[]; open: boolean; onOpenChange: (open: boolean) => void; onCreate: (payload: RuleCreatePayload) => void; onDelete: (id: string) => void; pending: boolean }) {
|
||||
function RulesSection({ items, mailboxes, labels, verifiedEmails, open, onOpenChange, onCreate, onUpdate, onToggle, onMove, onApply, onDelete, pending }: { items: MailRule[]; mailboxes: Mailbox[]; labels: MailLabel[]; verifiedEmails: string[]; open: boolean; onOpenChange: (open: boolean) => void; onCreate: (payload: RuleCreatePayload) => void; onUpdate: (id: string, payload: RuleCreatePayload) => void; onToggle: (item: MailRule) => void; onMove: (id: string, direction: "up" | "down") => void; onApply: (id: string) => void; onDelete: (id: string) => void; pending: boolean }) {
|
||||
const [editingRule, setEditingRule] = React.useState<MailRule | null>(null)
|
||||
|
||||
function setDialogOpen(next: boolean) {
|
||||
if (!next) setEditingRule(null)
|
||||
onOpenChange(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-stretch sm:justify-end">
|
||||
<Button className="h-9 w-full px-4 sm:w-auto" onClick={() => onOpenChange(true)}><Plus className="h-4 w-4" />新建规则</Button>
|
||||
<Button className="h-9 w-full rounded-md px-4 text-sm font-normal sm:w-auto" onClick={() => { setEditingRule(null); onOpenChange(true) }}>新建规则</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => <RuleListItem key={item.id} item={item} mailboxes={mailboxes} onDelete={onDelete} />)}
|
||||
{items.length === 0 && <EmptyState icon={<SlidersHorizontal />} text="暂无收件规则" description="新建规则后,可自动标记、移动或转发符合条件的邮件。" className="border-solid bg-card" />}
|
||||
<div className="space-y-3">
|
||||
{items.map((item, index) => <RuleListItem key={item.id} item={item} index={index} count={items.length} pending={pending} onEdit={() => { setEditingRule(item); onOpenChange(true) }} onToggle={() => onToggle(item)} onMove={(direction) => onMove(item.id, direction)} onApply={() => onApply(item.id)} onDelete={onDelete} />)}
|
||||
{items.length === 0 && <EmptyState icon={<SlidersHorizontal />} text="暂无收件规则" description="新建规则后,可自动标记、移动或转发符合条件的邮件。" className="min-h-[180px] border-solid bg-card" />}
|
||||
</div>
|
||||
<RuleDialog open={open} onOpenChange={onOpenChange} mailboxes={mailboxes} labels={labels} pending={pending} onCreate={onCreate} />
|
||||
<RuleDialog open={open} onOpenChange={setDialogOpen} mailboxes={mailboxes} labels={labels} verifiedEmails={verifiedEmails} pending={pending} initialRule={editingRule} onSave={(payload) => editingRule ? onUpdate(editingRule.id, payload) : onCreate(payload)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate }: { open: boolean; onOpenChange: (open: boolean) => void; mailboxes: Mailbox[]; labels: MailLabel[]; pending: boolean; onCreate: (payload: RuleCreatePayload) => void }) {
|
||||
function RuleDialog({ open, onOpenChange, mailboxes, labels, verifiedEmails, pending, initialRule, onSave }: { open: boolean; onOpenChange: (open: boolean) => void; mailboxes: Mailbox[]; labels: MailLabel[]; verifiedEmails: string[]; pending: boolean; initialRule: MailRule | null; onSave: (payload: RuleCreatePayload) => void }) {
|
||||
const [name, setName] = React.useState("我的规则")
|
||||
const [mailboxId, setMailboxId] = React.useState("all")
|
||||
const [matchMode, setMatchMode] = React.useState<"all" | "any">("all")
|
||||
@@ -2640,15 +2706,15 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate }
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return
|
||||
setName("我的规则")
|
||||
setMailboxId("all")
|
||||
setMatchMode("all")
|
||||
setConditions([{ field: "to", operator: "contains", value: "" }])
|
||||
setActions([{ type: "forward", value: "" }])
|
||||
setEnabled(true)
|
||||
setApplyToExisting(false)
|
||||
setStopProcessing(false)
|
||||
}, [open, labels])
|
||||
setName(initialRule?.name || "我的规则")
|
||||
setMailboxId(initialRule?.mailboxId || "all")
|
||||
setMatchMode(initialRule?.matchMode || "all")
|
||||
setConditions(initialRule?.conditions.length ? initialRule.conditions : [{ field: "to", operator: "contains", value: "" }])
|
||||
setActions(initialRule?.actions.length ? initialRule.actions : [{ type: "forward", value: "" }])
|
||||
setEnabled(initialRule?.enabled ?? true)
|
||||
setApplyToExisting(initialRule?.applyToExisting ?? false)
|
||||
setStopProcessing(initialRule?.stopProcessing ?? false)
|
||||
}, [initialRule, open])
|
||||
|
||||
function updateCondition(index: number, patch: Partial<MailRuleCondition>) {
|
||||
setConditions((items) => items.map((item, i) => {
|
||||
@@ -2669,20 +2735,20 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate }
|
||||
function removeAction(index: number) { setActions((items) => items.length > 1 ? items.filter((_, i) => i !== index) : items) }
|
||||
|
||||
const validConditions = conditions.map((item) => ({ ...item, value: (item.value || "").trim() })).filter((item) => item.field && item.operator && item.value)
|
||||
const validActions = actions.map((item) => normalizeDraftAction(item, availableLabels)).filter((item) => item.type !== "label" || item.value || item.labelId).filter((item) => item.type !== "move" || item.value).filter((item) => item.type !== "forward" || item.value)
|
||||
const validActions = actions.map((item) => normalizeDraftAction(item, availableLabels)).map((item) => item.type === "forward" ? { ...item, value: verifiedRuleForwardTargets(item.value || "", verifiedEmails).join(", ") } : item).filter((item) => item.type !== "label" || item.value || item.labelId).filter((item) => item.type !== "move" || item.value).filter((item) => item.type !== "forward" || item.value)
|
||||
const canCreate = validConditions.length > 0 && validActions.length > 0 && !pending
|
||||
|
||||
function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (!canCreate) return
|
||||
onCreate({ mailboxId: selectedMailboxId, name: name.trim() || "我的规则", matchMode, conditions: validConditions, actions: validActions, applyToExisting, stopProcessing, enabled })
|
||||
onSave({ mailboxId: selectedMailboxId, name: name.trim() || "我的规则", matchMode, conditions: validConditions, actions: validActions, applyToExisting, stopProcessing, enabled })
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex h-svh w-screen max-w-none gap-0 overflow-hidden p-0 sm:h-auto sm:max-h-[92vh] sm:w-[min(94vw,56rem)]">
|
||||
<DialogHeader className="border-b px-4 py-4 text-left sm:px-8 sm:py-6">
|
||||
<DialogTitle className="text-xl sm:text-2xl">新建规则</DialogTitle>
|
||||
<DialogTitle className="text-xl sm:text-2xl">{initialRule ? "编辑规则" : "新建规则"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="flex min-h-0 flex-1 flex-col" onSubmit={submit}>
|
||||
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto px-4 py-5 sm:space-y-7 sm:px-8 sm:py-7">
|
||||
@@ -2725,7 +2791,7 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate }
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{(Object.keys(ruleActionLabels) as MailRuleAction["type"][]).map((value) => <SelectItem key={value} value={value}>{ruleActionLabels[value]}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<RuleActionValue action={action} labels={availableLabels} onChange={(patch) => updateAction(index, patch)} />
|
||||
<RuleActionValue action={action} labels={availableLabels} verifiedEmails={verifiedEmails} onChange={(patch) => updateAction(index, patch)} />
|
||||
<Button type="button" variant="ghost" size="icon" className="text-muted-foreground" onClick={() => removeAction(index)} disabled={actions.length === 1}><X className="h-4 w-4" /></Button>
|
||||
{action.type !== "forward" && <Button type="button" variant="ghost" size="icon" onClick={addAction}><Plus className="h-4 w-4" /></Button>}
|
||||
</div>
|
||||
@@ -2746,7 +2812,7 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate }
|
||||
</div>
|
||||
<DialogFooter className="gap-2 border-t px-4 py-4 sm:px-8 sm:py-5 [&>button]:w-full sm:[&>button]:w-auto">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button disabled={!canCreate}>{pending ? "创建中..." : "创建"}</Button>
|
||||
<Button disabled={!canCreate}>{pending ? "保存中..." : initialRule ? "保存修改" : "创建"}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
@@ -2754,7 +2820,7 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate }
|
||||
)
|
||||
}
|
||||
|
||||
function RuleActionValue({ action, labels, onChange }: { action: MailRuleAction; labels: MailLabel[]; onChange: (patch: Partial<MailRuleAction>) => void }) {
|
||||
function RuleActionValue({ action, labels, verifiedEmails, onChange }: { action: MailRuleAction; labels: MailLabel[]; verifiedEmails: string[]; onChange: (patch: Partial<MailRuleAction>) => void }) {
|
||||
if (action.type === "label") {
|
||||
if (labels.length > 0) {
|
||||
return (
|
||||
@@ -2785,69 +2851,19 @@ function RuleActionValue({ action, labels, onChange }: { action: MailRuleAction;
|
||||
)
|
||||
}
|
||||
if (action.type === "forward") {
|
||||
return <RuleForwardTargets value={action.value || ""} onChange={(value) => onChange({ value })} />
|
||||
return <RuleForwardTargets value={action.value || ""} emails={verifiedEmails} onChange={(value) => onChange({ value })} />
|
||||
}
|
||||
return <Input value="无需填写" readOnly />
|
||||
}
|
||||
|
||||
function RuleForwardTargets({ value, onChange }: { value: string; onChange: (value: string) => void }) {
|
||||
const [rows, setRows] = React.useState(() => ruleForwardTargetRows(value))
|
||||
|
||||
React.useEffect(() => {
|
||||
const next = ruleForwardTargetRows(value)
|
||||
if (ruleForwardTargetsValue(next) !== ruleForwardTargetsValue(rows)) {
|
||||
setRows(next)
|
||||
}
|
||||
}, [value])
|
||||
|
||||
function commit(next: string[]) {
|
||||
const normalized = next.length > 0 ? next : [""]
|
||||
setRows(normalized)
|
||||
onChange(ruleForwardTargetsValue(normalized))
|
||||
}
|
||||
|
||||
function updateRow(index: number, nextValue: string) {
|
||||
const pasted = ruleForwardTargetRows(nextValue)
|
||||
const next = [...rows]
|
||||
if (pasted.length > 1) {
|
||||
next.splice(index, 1, ...pasted)
|
||||
} else {
|
||||
next[index] = nextValue
|
||||
}
|
||||
commit(next)
|
||||
}
|
||||
|
||||
function addRow(index: number) {
|
||||
const next = [...rows]
|
||||
next.splice(index + 1, 0, "")
|
||||
commit(next)
|
||||
}
|
||||
|
||||
function removeRow(index: number) {
|
||||
const next = rows.filter((_, itemIndex) => itemIndex !== index)
|
||||
commit(next.length > 0 ? next : [""])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{rows.map((email, index) => (
|
||||
<div key={index} className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_40px_40px]">
|
||||
<Input type="email" value={email} onChange={(event) => updateRow(index, event.target.value)} placeholder={`目标邮箱 ${index + 1}`} />
|
||||
<Button type="button" variant="ghost" size="icon" className="size-10 text-muted-foreground" onClick={() => removeRow(index)} disabled={rows.length === 1 && !email.trim()} aria-label={`移除目标邮箱 ${index + 1}`}><X className="h-4 w-4" /></Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="size-10" onClick={() => addRow(index)} aria-label={`添加目标邮箱 ${index + 2}`}><Plus className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
function RuleForwardTargets({ value, emails, onChange }: { value: string; emails: string[]; onChange: (value: string) => void }) {
|
||||
const selected = React.useMemo(() => verifiedRuleForwardTargets(value, emails), [emails, value])
|
||||
return <ForwardingTargetPicker emails={emails} selected={selected} onChange={(targets) => onChange(targets.join(", "))} placement="top" />
|
||||
}
|
||||
|
||||
function ruleForwardTargetRows(value: string) {
|
||||
const rows = value.split(/[\n\r,,;;]+/).map((item) => item.trim()).filter(Boolean)
|
||||
return rows.length > 0 ? rows : [""]
|
||||
}
|
||||
|
||||
function ruleForwardTargetsValue(rows: string[]) {
|
||||
return rows.map((item) => item.trim()).filter(Boolean).join(", ")
|
||||
function verifiedRuleForwardTargets(value: string, verifiedEmails: string[]) {
|
||||
const verifiedByAddress = new Map(verifiedEmails.map((email) => [email.trim().toLowerCase(), email.trim()]))
|
||||
return Array.from(new Set(value.split(/[\n\r,,;;]+/).map((item) => verifiedByAddress.get(item.trim().toLowerCase())).filter((item): item is string => !!item)))
|
||||
}
|
||||
|
||||
function RuleCheckbox({ checked, onCheckedChange, label }: { checked: boolean; onCheckedChange: (checked: boolean) => void; label: string }) {
|
||||
@@ -2855,25 +2871,32 @@ function RuleCheckbox({ checked, onCheckedChange, label }: { checked: boolean; o
|
||||
return <div className="flex items-center gap-3"><Checkbox id={id} checked={checked} onCheckedChange={(value) => onCheckedChange(value === true)} /><Label htmlFor={id} className="text-base font-medium">{label}</Label></div>
|
||||
}
|
||||
|
||||
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 : "全部邮箱"
|
||||
function RuleListItem({ item, index, count, pending, onEdit, onToggle, onMove, onApply, onDelete }: { item: MailRule; index: number; count: number; pending: boolean; onEdit: () => void; onToggle: () => void; onMove: (direction: "up" | "down") => void; onApply: () => void; onDelete: (id: string) => void }) {
|
||||
const [confirmOpen, setConfirmOpen] = React.useState(false)
|
||||
const conditionText = ruleConditionSummary(item.conditions, item.fromContains, item.subjectContains)
|
||||
const actionText = item.actions.map(ruleActionSummary).filter(Boolean).join(";") || "无动作"
|
||||
const moveDirection = index === 0 ? "down" : "up"
|
||||
const canMove = count > 1
|
||||
return (
|
||||
<div className="grid min-h-[82px] grid-cols-[minmax(0,1fr)_2rem] items-center gap-3 rounded-lg border bg-card px-4 py-3 shadow-[0_1px_2px_rgba(15,23,42,0.04)] transition-colors hover:bg-muted/30">
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm">
|
||||
<span className="truncate font-semibold text-foreground">{item.name}</span>
|
||||
<span className={cn("shrink-0 text-xs font-medium", item.enabled ? "text-emerald-600" : "text-muted-foreground")}>{item.enabled ? "已启用" : "已停用"}</span>
|
||||
<div className="grid min-h-[110px] grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-lg border bg-card px-4 py-4 transition-colors hover:bg-muted/20">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex min-w-0 items-center gap-2 leading-6">
|
||||
<h3 className="truncate text-base font-semibold text-foreground">{item.name}</h3>
|
||||
<span className={cn("shrink-0 rounded bg-emerald-100 px-1.5 py-0.5 text-xs font-medium", item.enabled ? "text-emerald-700" : "bg-muted text-muted-foreground")}>{item.enabled ? "已启用" : "已停用"}</span>
|
||||
</div>
|
||||
<div className="grid gap-0.5 text-xs leading-5 text-muted-foreground">
|
||||
<p className="truncate"><span className="font-medium text-foreground/80">条件:</span>{conditionText}</p>
|
||||
<p className="truncate"><span className="font-medium text-foreground/80">动作:</span>{actionText}</p>
|
||||
<div className="grid text-sm leading-6 text-muted-foreground">
|
||||
<p className="truncate"><span className="text-muted-foreground">条件:</span> {conditionText}</p>
|
||||
<p className="truncate"><span className="text-muted-foreground">动作:</span> {actionText}</p>
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground/80">{mailbox} · {item.matchMode === "any" ? "任一条件" : "所有条件"}</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="size-8 shrink-0 text-destructive" onClick={() => setConfirmOpen(true)}><Trash2 className="h-4 w-4" /></Button>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button type="button" variant="ghost" size="icon" className="size-7 text-muted-foreground" disabled={pending || !canMove} onClick={() => onMove(moveDirection)} aria-label={moveDirection === "up" ? "上移" : "下移"} title={moveDirection === "up" ? "上移" : "下移"}>{moveDirection === "up" ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}</Button>
|
||||
<span className="mx-1 h-6 w-px bg-border" />
|
||||
<Button type="button" variant="ghost" size="icon" className={cn("size-7", item.enabled ? "text-emerald-600" : "text-muted-foreground")} disabled={pending} onClick={onToggle} aria-label={item.enabled ? "禁用规则" : "启用规则"} title={item.enabled ? "禁用规则" : "启用规则"}>{item.enabled ? <Bell className="h-4 w-4" /> : <BellOff className="h-4 w-4" />}</Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="size-7 text-muted-foreground" disabled={pending} onClick={onEdit} aria-label="编辑规则" title="编辑规则"><PencilLine className="h-4 w-4" /></Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="size-7 text-muted-foreground" disabled={pending} onClick={onApply} aria-label="应用到现有邮件" title="应用到现有邮件"><PlayCircle className="h-4 w-4" /></Button>
|
||||
<Button variant="ghost" size="icon" className="size-7 shrink-0 text-destructive hover:bg-destructive/10 hover:text-destructive" disabled={pending} onClick={() => setConfirmOpen(true)} aria-label="删除规则" title="删除规则"><Trash2 className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
<ConfirmDialog open={confirmOpen} title="删除收件规则?" description={`规则“${item.name}”将不再处理后续邮件。`} confirmText="删除规则" destructive onOpenChange={setConfirmOpen} onConfirm={() => { onDelete(item.id); setConfirmOpen(false) }} />
|
||||
</div>
|
||||
)
|
||||
@@ -2928,7 +2951,7 @@ function ruleConditionItemSummary(item: MailRuleCondition): string {
|
||||
|
||||
function ruleActionSummary(action: MailRuleAction) {
|
||||
if (action.type === "label") return `${ruleActionLabels[action.type]}${action.value ? `:"${action.value}"` : ""}`
|
||||
if (action.type === "move") return `${ruleActionLabels[action.type]}:"${folderLabel(action.value || "Archive")}"`
|
||||
if (action.type === "move") return `${ruleActionLabels[action.type]}"${folderLabel(action.value || "Archive")}"`
|
||||
if (action.type === "forward") return `${ruleActionLabels[action.type]}${action.value ? `:${action.value}` : ""}`
|
||||
return ruleActionLabels[action.type]
|
||||
}
|
||||
@@ -2979,14 +3002,14 @@ function BlockedSection({ items, mailboxes, mailboxId, spamCount, onMailboxChang
|
||||
)
|
||||
}
|
||||
|
||||
function StatsSection({ stats, mailbox, rangeDays, onRangeChange, onRefresh }: { stats?: MailStats; mailbox?: Mailbox; rangeDays: number; onRangeChange: (days: number) => void; onRefresh: () => void }) {
|
||||
function StatsSection({ stats }: { stats?: MailStats; mailbox?: Mailbox; rangeDays: number; onRangeChange: (days: number) => void; onRefresh: () => void }) {
|
||||
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 = [
|
||||
{ label: "总收件", value: stats?.totalIncoming || 0, icon: <Mail className="h-4 w-4" />, tone: "bg-blue-50 text-blue-600" },
|
||||
{ label: "总收件", value: stats?.totalIncoming || 0, icon: <Mail className="h-4 w-4" />, tone: "bg-muted text-foreground" },
|
||||
{ label: "总发件", value: stats?.totalOutgoing || 0, icon: <SendHorizontal className="h-4 w-4" />, tone: "bg-emerald-50 text-emerald-600" },
|
||||
{ label: "未读邮件", value: stats?.unreadMessages || 0, icon: <MailCheck className="h-4 w-4" />, tone: "bg-amber-50 text-amber-600" },
|
||||
{ label: "存储用量", value: quotaLabel, detail: stats?.quotaBytes ? `${quotaPct.toFixed(0)}%` : "不限", icon: <HardDrive className="h-4 w-4" />, tone: "bg-slate-100 text-slate-700" },
|
||||
{ label: "存储用量", value: formatBytes(stats?.storageBytes || 0), subvalue: stats?.quotaBytes ? `/ ${formatBytes(stats.quotaBytes)} (${quotaPct.toFixed(0)}%)` : "不限", icon: <HardDrive className="h-4 w-4" />, tone: "bg-violet-50 text-violet-600" },
|
||||
]
|
||||
const secondaryStats = [
|
||||
{ label: "今日发件", value: stats?.todayOutgoing || 0 },
|
||||
@@ -2995,68 +3018,40 @@ function StatsSection({ stats, mailbox, rangeDays, onRangeChange, onRefresh }: {
|
||||
{ label: "平均邮件大小", value: formatBytes(stats?.averageMessageBytes || 0) },
|
||||
]
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm leading-6 text-muted-foreground">查看邮件收发趋势、分布情况和常用联系人。</p>
|
||||
{mailbox && <p className="mt-0.5 truncate text-xs text-muted-foreground/80">{mailbox.address}</p>}
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||
<div className="grid grid-cols-4 rounded-md border bg-background p-0.5 sm:flex">
|
||||
{[
|
||||
[7, "7天"],
|
||||
[30, "30天"],
|
||||
[90, "90天"],
|
||||
[365, "365天"],
|
||||
].map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={cn("h-7 rounded px-2.5 text-xs font-medium transition-colors", rangeDays === value ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground")}
|
||||
onClick={() => onRangeChange(Number(value))}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="w-full sm:w-auto" onClick={onRefresh}><RefreshCcw className="h-4 w-4" />刷新</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{primaryCards.map((card) => (
|
||||
<div key={card.label} className="grid h-[66px] grid-cols-[2rem_minmax(0,1fr)] items-center gap-3 rounded-lg border bg-card px-3 shadow-[0_1px_2px_rgba(15,23,42,0.04)]">
|
||||
<div className={cn("flex size-8 shrink-0 items-center justify-center rounded-lg", card.tone)}>{card.icon}</div>
|
||||
<div key={card.label} className="grid h-[106px] grid-cols-[2.5rem_minmax(0,1fr)] items-center gap-3 rounded-lg border bg-card px-5">
|
||||
<div className={cn("flex size-10 shrink-0 items-center justify-center rounded-lg", card.tone)}>{card.icon}</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="truncate text-xs font-medium text-muted-foreground">{card.label}</div>
|
||||
{card.detail && <Badge variant="secondary" className="rounded-md font-normal">{card.detail}</Badge>}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-lg font-semibold leading-6 text-foreground">{card.value}</div>
|
||||
<div className="truncate text-sm text-muted-foreground">{card.label}</div>
|
||||
<div className="truncate text-2xl font-semibold leading-8 text-foreground">{card.value}</div>
|
||||
{"subvalue" in card && <div className="truncate text-xs leading-4 text-muted-foreground">{card.subvalue}</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{secondaryStats.map((item) => (
|
||||
<div key={item.label} className="flex h-8 items-center justify-between gap-3 rounded-lg border bg-background px-3 text-sm">
|
||||
<div className="truncate text-xs font-medium text-muted-foreground">{item.label}</div>
|
||||
<div key={item.label} className="flex h-[54px] items-center justify-between gap-3 rounded-lg border bg-background px-4 text-sm">
|
||||
<div className="truncate text-sm text-muted-foreground">{item.label}</div>
|
||||
<div className="shrink-0 font-semibold text-foreground">{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_272px]">
|
||||
<StatsPanel title="收发趋势">
|
||||
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
|
||||
<StatsPanel title="收发趋势" className="h-[321px]">
|
||||
<StatsTrendChart points={stats?.trend || []} />
|
||||
</StatsPanel>
|
||||
<StatsPanel title="邮件分布">
|
||||
<StatsPanel title="邮件分布" className="h-[321px]">
|
||||
<StatsDistribution items={stats?.distribution || []} />
|
||||
</StatsPanel>
|
||||
</div>
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<StatsPanel title="存储用量">
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<StatsPanel title="存储用量" className="h-[338px]">
|
||||
<StatsStorage quotaLabel={quotaLabel} quotaPct={quotaPct} hasQuota={!!stats?.quotaBytes} />
|
||||
</StatsPanel>
|
||||
<StatsPanel title="常用联系人">
|
||||
<StatsPanel title="常用联系人" className="h-[338px]">
|
||||
<StatsContacts contacts={stats?.topContacts || []} />
|
||||
</StatsPanel>
|
||||
</div>
|
||||
@@ -3064,13 +3059,13 @@ function StatsSection({ stats, mailbox, rangeDays, onRangeChange, onRefresh }: {
|
||||
)
|
||||
}
|
||||
|
||||
function StatsPanel({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
function StatsPanel({ title, children, className }: { title: string; children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<section className="rounded-lg border bg-card shadow-[0_1px_2px_rgba(15,23,42,0.04)]">
|
||||
<div className="px-4 pb-1.5 pt-2.5">
|
||||
<h2 className="text-[13px] font-semibold leading-5 text-foreground">{title}</h2>
|
||||
<section className={cn("overflow-hidden rounded-lg border bg-card", className)}>
|
||||
<div className="px-5 pb-2 pt-4">
|
||||
<h2 className="text-base font-semibold leading-6 text-foreground">{title}</h2>
|
||||
</div>
|
||||
<div className="px-4 pb-2.5">{children}</div>
|
||||
<div className="px-5 pb-5">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -3088,21 +3083,21 @@ function StatsTrendChart({ points }: { points: MailStats["trend"] }) {
|
||||
const pathFor = (key: "incoming" | "outgoing") => data.map((item, index) => `${index === 0 ? "M" : "L"} ${xFor(index).toFixed(1)} ${yFor(item[key]).toFixed(1)}`).join(" ")
|
||||
const ticks = trendTicks(data)
|
||||
return (
|
||||
<div className="h-[150px] rounded-md bg-background">
|
||||
<div className="h-[250px] rounded-md bg-background">
|
||||
<div className="flex items-center justify-end gap-4 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5"><span className="size-2 rounded-full bg-blue-500" />收件</span>
|
||||
<span className="inline-flex items-center gap-1.5"><span className="size-2 rounded-full bg-foreground" />收件</span>
|
||||
<span className="inline-flex items-center gap-1.5"><span className="size-2 rounded-full bg-emerald-500" />发件</span>
|
||||
</div>
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="h-[134px] w-full overflow-visible" role="img" aria-label="邮件收发趋势">
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="h-[226px] w-full overflow-visible" role="img" aria-label="邮件收发趋势">
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((step) => {
|
||||
const y = padding.top + plotHeight * step
|
||||
return <line key={step} x1={padding.left} x2={width - padding.right} y1={y} y2={y} className="stroke-border" strokeDasharray={step === 1 ? undefined : "3 5"} />
|
||||
})}
|
||||
<path d={pathFor("incoming")} fill="none" className="stroke-blue-500" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d={pathFor("incoming")} fill="none" className="stroke-foreground" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d={pathFor("outgoing")} fill="none" className="stroke-emerald-500" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
{data.map((item, index) => (
|
||||
<g key={`${item.date}-${index}`}>
|
||||
<circle cx={xFor(index)} cy={yFor(item.incoming)} r="2.8" className="fill-blue-500" />
|
||||
<circle cx={xFor(index)} cy={yFor(item.incoming)} r="2.8" className="fill-foreground" />
|
||||
<circle cx={xFor(index)} cy={yFor(item.outgoing)} r="2.8" className="fill-emerald-500" />
|
||||
</g>
|
||||
))}
|
||||
@@ -3147,7 +3142,7 @@ function StatsDistribution({ items }: { items: MailStats["distribution"] }) {
|
||||
]
|
||||
const maxCount = Math.max(...rows.map((row) => row.count), 1)
|
||||
return (
|
||||
<div className="space-y-2 py-1">
|
||||
<div className="space-y-3 py-3">
|
||||
{rows.map((row) => (
|
||||
<div key={row.key} className="grid grid-cols-[4.5rem_minmax(0,1fr)_2rem] items-center gap-2 text-xs">
|
||||
<div className="truncate font-medium text-muted-foreground">{row.label}</div>
|
||||
@@ -3172,7 +3167,7 @@ function distributionBarTone(key: string) {
|
||||
|
||||
function StatsStorage({ quotaLabel, quotaPct, hasQuota }: { quotaLabel: string; quotaPct: number; hasQuota: boolean }) {
|
||||
return (
|
||||
<div className="h-[136px] pt-2">
|
||||
<div className="h-[260px] pt-3">
|
||||
<div className="mb-2 flex items-end justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-foreground">{quotaLabel}</div>
|
||||
<div className="text-xs font-semibold text-foreground">{hasQuota ? `${quotaPct.toFixed(0)}%` : "不限"}</div>
|
||||
@@ -3186,11 +3181,11 @@ function StatsStorage({ quotaLabel, quotaPct, hasQuota }: { quotaLabel: string;
|
||||
}
|
||||
|
||||
function StatsContacts({ contacts }: { contacts: MailStats["topContacts"] }) {
|
||||
if (contacts.length === 0) return <EmptyState icon={<Users />} text="暂无常用联系人" description="有邮件往来后会显示联系人排行" className="h-[136px] min-h-0 py-4" />
|
||||
if (contacts.length === 0) return <EmptyState icon={<Users />} text="暂无常用联系人" description="有邮件往来后会显示联系人排行" className="h-[260px] min-h-0 py-4" />
|
||||
return (
|
||||
<div className="h-[136px] space-y-1 overflow-hidden pt-1">
|
||||
{contacts.slice(0, 7).map((item, index) => (
|
||||
<div key={item.email} className="grid grid-cols-[1.25rem_minmax(0,1fr)_3.25rem] items-center gap-2 text-xs leading-5">
|
||||
<div className="h-[260px] space-y-1 overflow-hidden pt-1">
|
||||
{contacts.slice(0, 10).map((item, index) => (
|
||||
<div key={item.email} className="grid grid-cols-[1.25rem_minmax(0,1fr)_3.25rem] items-center gap-2 text-sm leading-6">
|
||||
<div className="text-center font-semibold text-muted-foreground">{index + 1}</div>
|
||||
<div className="min-w-0 truncate font-medium text-foreground">{item.email}</div>
|
||||
<div className="text-right font-semibold text-muted-foreground">{item.count} 封</div>
|
||||
|
||||
Reference in New Issue
Block a user