feat(mail): 增强邮件规则与阅读体验
- 将收件规则升级为支持多条件、多动作、应用现有邮件和终止继续处理。 - 新增规则构建与显示模式设置,优化规则列表与邮件详情展示。 - 增加邮件列表简洁模式、批量操作、手动刷新和更精细的已读状态控制。
This commit is contained in:
+465
-19
@@ -4,13 +4,15 @@ import { marked } from "marked"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import type { ImperativePanelHandle } from "react-resizable-panels"
|
||||
import { Archive, Bold, Check, ChevronsUpDown, Code2, Copy, Forward, Image, Inbox, Italic, Link, List, ListOrdered, Mail, MailCheck, Minus, Moon, PanelLeftClose, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, RefreshCcw, Reply, Search, Send, Settings, SlidersHorizontal, Star, Strikethrough, Sun, Tag, Trash2, WrapText, X } from "lucide-react"
|
||||
import { Archive, ArrowLeft, Bold, Check, ChevronsUpDown, Code2, Copy, Forward, Image, Inbox, Italic, Link, List, ListOrdered, Mail, MailCheck, Minus, Moon, PanelLeftClose, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, RefreshCcw, Reply, Search, Send, Settings, SlidersHorizontal, Star, Strikethrough, Sun, Tag, Trash2, WrapText, X } from "lucide-react"
|
||||
import { api, Mailbox, MailFolder, MailLabel, MailMessage } from "@/lib/api"
|
||||
import { cn, formatBytes, formatDate } from "@/lib/utils"
|
||||
import { cn, formatBytes, formatDate, formatDateTime } from "@/lib/utils"
|
||||
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
||||
import { useDisplayMode } from "@/lib/display-mode"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
@@ -70,12 +72,16 @@ export function MailPage() {
|
||||
const [selectedLabelId, setSelectedLabelId] = React.useState("")
|
||||
const [query, setQuery] = React.useState("")
|
||||
const [selectedId, setSelectedId] = React.useState<string | null>(null)
|
||||
const [compactSelectedIds, setCompactSelectedIds] = React.useState<string[]>([])
|
||||
const [composeOpen, setComposeOpen] = React.useState(false)
|
||||
const [composeDraft, setComposeDraft] = React.useState<ComposeDraft | undefined>()
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false)
|
||||
const [mailFilter, setMailFilter] = React.useState<MailFilter>("all")
|
||||
const [selectedMailboxId, setSelectedMailboxId] = React.useState(() => localStorage.getItem("lanqin:selected-mailbox") || "")
|
||||
const [darkMode, setDarkMode] = React.useState(getInitialTheme)
|
||||
const [displayMode] = useDisplayMode()
|
||||
const [refreshing, setRefreshing] = React.useState(false)
|
||||
const [bulkPending, setBulkPending] = React.useState(false)
|
||||
const sidebarPanelRef = React.useRef<ImperativePanelHandle>(null)
|
||||
const themeMountedRef = React.useRef(false)
|
||||
|
||||
@@ -94,8 +100,36 @@ export function MailPage() {
|
||||
},
|
||||
enabled: !!selectedMailboxId && (mailView !== "label" || !!selectedLabelId),
|
||||
})
|
||||
const detail = useQuery({ queryKey: ["message", selectedId], queryFn: () => api.message(selectedId!), enabled: !!selectedId })
|
||||
const star = useMutation({ mutationFn: ({ id, starred }: { id: string; starred: boolean }) => api.star(id, starred), onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["mail-stats"] }); await qc.invalidateQueries({ queryKey: ["labels"] }) } })
|
||||
const detail = useQuery({ queryKey: ["message", selectedId], queryFn: () => api.message(selectedId!, { markRead: false }), enabled: !!selectedId })
|
||||
function updateCachedMessage(id: string, patch: Partial<MailMessage>) {
|
||||
qc.setQueryData(["message", id], (current: MailMessage | undefined) => current ? { ...current, ...patch } : current)
|
||||
qc.setQueriesData({ queryKey: ["messages"] }, (current: { items?: MailMessage[] } | undefined) => {
|
||||
if (!current?.items) return current
|
||||
return { ...current, items: current.items.map((message) => message.id === id ? { ...message, ...patch } : message) }
|
||||
})
|
||||
}
|
||||
const star = useMutation({
|
||||
mutationFn: ({ id, starred }: { id: string; starred: boolean }) => api.star(id, starred),
|
||||
onMutate: ({ id, starred }) => updateCachedMessage(id, { isStarred: starred }),
|
||||
onSuccess: async (_, variables) => {
|
||||
await qc.invalidateQueries({ queryKey: ["messages"] })
|
||||
await qc.invalidateQueries({ queryKey: ["message", variables.id] })
|
||||
await qc.invalidateQueries({ queryKey: ["mail-stats"] })
|
||||
await qc.invalidateQueries({ queryKey: ["labels"] })
|
||||
},
|
||||
onError: (error) => toast({ title: "操作失败", description: error.message }),
|
||||
})
|
||||
const markRead = useMutation({
|
||||
mutationFn: ({ id, read }: { id: string; read: boolean }) => api.markRead(id, read),
|
||||
onMutate: ({ id, read }) => updateCachedMessage(id, { isRead: read }),
|
||||
onSuccess: async (_, variables) => {
|
||||
await qc.invalidateQueries({ queryKey: ["messages"] })
|
||||
await qc.invalidateQueries({ queryKey: ["message", variables.id] })
|
||||
await qc.invalidateQueries({ queryKey: ["folders"] })
|
||||
await qc.invalidateQueries({ queryKey: ["mail-stats"] })
|
||||
},
|
||||
onError: (error) => toast({ title: "操作失败", description: error.message }),
|
||||
})
|
||||
const addLabel = useMutation({
|
||||
mutationFn: ({ id, label }: { id: string; label: MailLabel }) => api.addLabel(id, { name: label.name, color: label.color }),
|
||||
onSuccess: async (data) => {
|
||||
@@ -157,6 +191,10 @@ export function MailPage() {
|
||||
setMailFilter("all")
|
||||
}, [mailView])
|
||||
|
||||
React.useEffect(() => {
|
||||
setCompactSelectedIds([])
|
||||
}, [selectedMailboxId, mailView, folder, selectedLabelId, query, displayMode])
|
||||
|
||||
React.useEffect(() => {
|
||||
applyTheme(darkMode, themeMountedRef.current)
|
||||
themeMountedRef.current = true
|
||||
@@ -199,6 +237,51 @@ export function MailPage() {
|
||||
const selectedLabel = labelItems.find((item) => item.id === selectedLabelId)
|
||||
const viewTitle = mailView === "starred" ? "星标邮件" : mailView === "label" ? selectedLabel?.name || "标签" : folderLabels[folder] || folder
|
||||
const emptyMessage = allMessages.length === 0 ? (mailView === "starred" ? "暂无星标邮件" : mailView === "label" ? "当前标签没有邮件" : "当前文件夹没有邮件") : "当前筛选条件下没有邮件"
|
||||
const visibleMessageIds = visibleMessages.map((message) => message.id)
|
||||
const selectedCountOnPage = compactSelectedIds.filter((id) => visibleMessageIds.includes(id)).length
|
||||
const compactAllSelected = visibleMessageIds.length > 0 && selectedCountOnPage === visibleMessageIds.length
|
||||
const compactSomeSelected = selectedCountOnPage > 0 && !compactAllSelected
|
||||
function toggleCompactSelectAll(checked: boolean) {
|
||||
setCompactSelectedIds(checked ? visibleMessageIds : [])
|
||||
}
|
||||
function toggleCompactSelect(messageId: string, checked: boolean) {
|
||||
setCompactSelectedIds((ids) => checked ? Array.from(new Set([...ids, messageId])) : ids.filter((id) => id !== messageId))
|
||||
}
|
||||
async function refreshMailData() {
|
||||
await Promise.all([
|
||||
qc.invalidateQueries({ queryKey: ["messages"] }),
|
||||
qc.invalidateQueries({ queryKey: ["folders"] }),
|
||||
qc.invalidateQueries({ queryKey: ["mail-stats"] }),
|
||||
qc.invalidateQueries({ queryKey: ["labels"] }),
|
||||
])
|
||||
}
|
||||
async function runBulkAction(action: BulkAction) {
|
||||
const ids = compactSelectedIds.filter((id) => visibleMessageIds.includes(id))
|
||||
if (ids.length === 0) return
|
||||
setBulkPending(true)
|
||||
try {
|
||||
if (action === "read" || action === "unread") {
|
||||
const read = action === "read"
|
||||
await Promise.all(ids.map((id) => api.markRead(id, read)))
|
||||
} else if (action === "star" || action === "unstar") {
|
||||
const starred = action === "star"
|
||||
await Promise.all(ids.map((id) => api.star(id, starred)))
|
||||
} else if (action === "delete") {
|
||||
await Promise.all(ids.map((id) => api.delete(id)))
|
||||
} else {
|
||||
const target = action === "archive" ? "Archive" : action === "trash" ? "Trash" : "Spam"
|
||||
await Promise.all(ids.map((id) => api.move(id, target)))
|
||||
}
|
||||
if (selectedId && ids.includes(selectedId)) setSelectedId(null)
|
||||
setCompactSelectedIds([])
|
||||
await refreshMailData()
|
||||
toast({ title: `已处理 ${ids.length} 封邮件` })
|
||||
} catch (error) {
|
||||
toast({ title: "批量操作失败", description: error instanceof Error ? error.message : "请稍后重试" })
|
||||
} finally {
|
||||
setBulkPending(false)
|
||||
}
|
||||
}
|
||||
function openCompose(draft?: ComposeDraft) { setComposeDraft(draft || { key: `new-${Date.now()}` }); setComposeOpen(true) }
|
||||
function openReply(message: MailMessage) { openCompose({ key: `reply-${message.id}-${Date.now()}`, to: message.from, subject: withPrefix(message.subject, "Re:"), text: quoteMessage(message) }) }
|
||||
function openForward(message: MailMessage) { openCompose({ key: `forward-${message.id}-${Date.now()}`, subject: withPrefix(message.subject, "Fwd:"), text: quoteMessage(message) }) }
|
||||
@@ -228,6 +311,22 @@ export function MailPage() {
|
||||
setSelectedId(null)
|
||||
setMailFilter("all")
|
||||
}
|
||||
function openMessage(messageId: string | null) {
|
||||
setSelectedId(messageId)
|
||||
if (!messageId) return
|
||||
const message = allMessages.find((item) => item.id === messageId)
|
||||
if (message && !message.isRead) {
|
||||
markRead.mutate({ id: message.id, read: true })
|
||||
}
|
||||
}
|
||||
async function refreshMail() {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await refreshMailData()
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
async function copyCurrentMailbox() {
|
||||
if (!selectedMailbox?.address) return
|
||||
await navigator.clipboard.writeText(selectedMailbox.address)
|
||||
@@ -337,7 +436,7 @@ export function MailPage() {
|
||||
<section className="flex h-full min-h-0 flex-col">
|
||||
<header className="flex h-16 shrink-0 items-center justify-between gap-3 border-b px-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="icon" variant="ghost" onClick={() => { qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["mail-stats"] }); qc.invalidateQueries({ queryKey: ["labels"] }) }}><RefreshCcw className="h-4 w-4" /></Button>
|
||||
<Button size="icon" variant="ghost" onClick={refreshMail} disabled={refreshing} className={cn("transition-all", refreshing && "bg-primary/5 text-primary")}><RefreshCcw className={cn("h-4 w-4", refreshing && "animate-spin")} /></Button>
|
||||
<Button variant="outline" size="sm" disabled={markAllRead.isPending || unreadCount === 0} onClick={() => markAllRead.mutate(allMessages)}><MailCheck className="h-4 w-4" />全部已读</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -358,18 +457,54 @@ export function MailPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{displayMode === "compact" ? (
|
||||
<CompactMailView
|
||||
title={viewTitle}
|
||||
icon={mailView === "label" && selectedLabel ? <Tag className="h-4 w-4" style={{ color: selectedLabel.color }} /> : undefined}
|
||||
messages={visibleMessages}
|
||||
total={allMessages.length}
|
||||
selectedIds={compactSelectedIds}
|
||||
allSelected={compactAllSelected}
|
||||
someSelected={compactSomeSelected}
|
||||
loading={messages.isLoading}
|
||||
emptyMessage={emptyMessage}
|
||||
selectedId={selectedId}
|
||||
selected={selected}
|
||||
detailLoading={detail.isLoading}
|
||||
labels={labelItems}
|
||||
labelPending={addLabel.isPending || removeLabel.isPending}
|
||||
onSelect={openMessage}
|
||||
onSelectAll={toggleCompactSelectAll}
|
||||
onToggleSelected={toggleCompactSelect}
|
||||
onCloseReader={() => setSelectedId(null)}
|
||||
onStar={(message) => star.mutate({ id: message.id, starred: !message.isStarred })}
|
||||
onReply={openReply}
|
||||
onForward={openForward}
|
||||
onArchive={(message) => move.mutate({ id: message.id, folder: message.folder === "Archive" ? "Inbox" : "Archive" })}
|
||||
onDelete={(message) => del.mutate(message.id)}
|
||||
onToggleRead={(message) => markRead.mutate({ id: message.id, read: !message.isRead })}
|
||||
onAddLabel={(message, label) => addLabel.mutate({ id: message.id, label })}
|
||||
onRemoveLabel={(message, labelId) => removeLabel.mutate({ id: message.id, labelId })}
|
||||
bulkPending={bulkPending}
|
||||
onBulkAction={runBulkAction}
|
||||
/>
|
||||
) : (
|
||||
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1">
|
||||
<ResizablePanel defaultSize={32} minSize={24} maxSize={44}>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex h-14 shrink-0 items-center justify-between border-b px-5">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">{mailView === "label" && selectedLabel && <Tag className="h-4 w-4" style={{ color: selectedLabel.color }} />}{viewTitle}</div>
|
||||
<div className="text-xs text-muted-foreground">{visibleMessages.length} / {allMessages.length} 封邮件</div>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Checkbox aria-label="选择当前页邮件" checked={compactAllSelected ? true : compactSomeSelected ? "indeterminate" : false} onCheckedChange={(value) => toggleCompactSelectAll(value === true)} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">{mailView === "label" && selectedLabel && <Tag className="h-4 w-4" style={{ color: selectedLabel.color }} />}{viewTitle}</div>
|
||||
<div className="text-xs text-muted-foreground">{selectedCountOnPage > 0 ? `已选 ${selectedCountOnPage} 封` : `${visibleMessages.length} / ${allMessages.length} 封邮件`}</div>
|
||||
</div>
|
||||
</div>
|
||||
{selectedCountOnPage > 0 && <BulkActionMenu pending={bulkPending} onAction={runBulkAction} />}
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
{messages.isLoading && <MessageSkeleton />}
|
||||
{visibleMessages.map((m) => <MessageRow key={m.id} message={m} active={selectedId === m.id} onClick={() => setSelectedId(m.id)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} />)}
|
||||
{visibleMessages.map((m) => <MessageRow key={m.id} message={m} active={selectedId === m.id} checked={compactSelectedIds.includes(m.id)} onCheckedChange={(checked) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} />)}
|
||||
{!messages.isLoading && visibleMessages.length === 0 && <div className="p-8 text-center text-sm text-muted-foreground">{emptyMessage}</div>}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
@@ -395,7 +530,7 @@ export function MailPage() {
|
||||
<Button variant="destructive" size="sm" onClick={() => del.mutate(selected.id)}>删除</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground"><span className="font-medium text-foreground">{selected.from}</span> 发给 {selected.to.join(", ")} · {formatDate(selected.receivedAt)}</div>
|
||||
<div className="text-sm text-muted-foreground"><span className="font-medium text-foreground">{selected.from}</span> 发给 {selected.to.join(", ")} · {formatDateTime(selected.receivedAt)}</div>
|
||||
<MessageLabels
|
||||
messageLabels={selected.labels || []}
|
||||
availableLabels={labelItems}
|
||||
@@ -414,6 +549,8 @@ export function MailPage() {
|
||||
</section>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
)}
|
||||
</section>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
@@ -442,6 +579,257 @@ function buildMailMenuItems(folders: MailFolder[], starredCount: number): MailMe
|
||||
function FolderSkeleton() { return <div className="space-y-2 p-2"><Skeleton className="h-8 w-full" /><Skeleton className="h-8 w-4/5" /><Skeleton className="h-8 w-3/4" /></div> }
|
||||
function MessageSkeleton() { return <div className="space-y-0">{Array.from({ length: 6 }).map((_, i) => <div className="space-y-2 border-b p-4" key={i}><Skeleton className="h-4 w-1/2" /><Skeleton className="h-4 w-4/5" /><Skeleton className="h-3 w-full" /></div>)}</div> }
|
||||
|
||||
type BulkAction = "read" | "unread" | "star" | "unstar" | "archive" | "trash" | "spam" | "delete"
|
||||
|
||||
function BulkActionMenu({ pending, onAction }: { pending: boolean; onAction: (action: BulkAction) => void }) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={pending}>
|
||||
批量操作
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => onAction("read")}>标为已读</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onAction("unread")}>标为未读</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onAction("star")}>添加星标</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onAction("unstar")}>取消星标</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onAction("archive")}>归档</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onAction("trash")}>移入回收站</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onAction("spam")}>移入垃圾邮件</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onAction("delete")} className="text-destructive">删除</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function CompactMailView({
|
||||
title,
|
||||
icon,
|
||||
messages,
|
||||
total,
|
||||
selectedIds,
|
||||
allSelected,
|
||||
someSelected,
|
||||
loading,
|
||||
emptyMessage,
|
||||
selectedId,
|
||||
selected,
|
||||
detailLoading,
|
||||
labels,
|
||||
labelPending,
|
||||
onSelect,
|
||||
onSelectAll,
|
||||
onToggleSelected,
|
||||
onCloseReader,
|
||||
onStar,
|
||||
onReply,
|
||||
onForward,
|
||||
onArchive,
|
||||
onDelete,
|
||||
onToggleRead,
|
||||
onAddLabel,
|
||||
onRemoveLabel,
|
||||
bulkPending,
|
||||
onBulkAction,
|
||||
}: {
|
||||
title: string
|
||||
icon?: React.ReactNode
|
||||
messages: MailMessage[]
|
||||
total: number
|
||||
selectedIds: string[]
|
||||
allSelected: boolean
|
||||
someSelected: boolean
|
||||
loading: boolean
|
||||
emptyMessage: string
|
||||
selectedId: string | null
|
||||
selected?: MailMessage
|
||||
detailLoading: boolean
|
||||
labels: MailLabel[]
|
||||
labelPending: boolean
|
||||
onSelect: (id: string | null) => void
|
||||
onSelectAll: (checked: boolean) => void
|
||||
onToggleSelected: (id: string, checked: boolean) => void
|
||||
onCloseReader: () => void
|
||||
onStar: (message: MailMessage) => void
|
||||
onReply: (message: MailMessage) => void
|
||||
onForward: (message: MailMessage) => void
|
||||
onArchive: (message: MailMessage) => void
|
||||
onDelete: (message: MailMessage) => void
|
||||
onToggleRead: (message: MailMessage) => void
|
||||
onAddLabel: (message: MailMessage, label: MailLabel) => void
|
||||
onRemoveLabel: (message: MailMessage, labelId: string) => void
|
||||
bulkPending: boolean
|
||||
onBulkAction: (action: BulkAction) => void
|
||||
}) {
|
||||
const selectedIndex = selectedId ? messages.findIndex((message) => message.id === selectedId) : -1
|
||||
const previousMessage = selectedIndex > 0 ? messages[selectedIndex - 1] : undefined
|
||||
const nextMessage = selectedIndex >= 0 && selectedIndex < messages.length - 1 ? messages[selectedIndex + 1] : undefined
|
||||
|
||||
if (selectedId) {
|
||||
return (
|
||||
<CompactMessageDetail
|
||||
selected={selected}
|
||||
loading={detailLoading}
|
||||
labels={labels}
|
||||
labelPending={labelPending}
|
||||
previousMessage={previousMessage}
|
||||
nextMessage={nextMessage}
|
||||
onBack={onCloseReader}
|
||||
onSelect={onSelect}
|
||||
onStar={onStar}
|
||||
onReply={onReply}
|
||||
onForward={onForward}
|
||||
onArchive={onArchive}
|
||||
onDelete={onDelete}
|
||||
onToggleRead={onToggleRead}
|
||||
onAddLabel={onAddLabel}
|
||||
onRemoveLabel={onRemoveLabel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-background">
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox aria-label="选择当前页邮件" checked={allSelected ? true : someSelected ? "indeterminate" : false} onCheckedChange={(value) => onSelectAll(value === true)} />
|
||||
<div className="flex items-center gap-2 text-base font-semibold">{icon}{title}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedIds.length > 0 ? (
|
||||
<>
|
||||
<span className="text-sm text-muted-foreground">已选 {selectedIds.length} 封</span>
|
||||
<BulkActionMenu pending={bulkPending} onAction={onBulkAction} />
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">{messages.length} / {total} 封</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
{loading && <MessageSkeleton />}
|
||||
{messages.map((message) => <CompactMessageRow key={message.id} message={message} active={selectedId === message.id} checked={selectedIds.includes(message.id)} onCheckedChange={(checked) => onToggleSelected(message.id, checked)} onClick={() => onSelect(message.id)} onStar={() => onStar(message)} />)}
|
||||
{!loading && messages.length === 0 && <div className="p-8 text-center text-sm text-muted-foreground">{emptyMessage}</div>}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CompactMessageDetail({
|
||||
selected,
|
||||
loading,
|
||||
labels,
|
||||
labelPending,
|
||||
previousMessage,
|
||||
nextMessage,
|
||||
onBack,
|
||||
onSelect,
|
||||
onStar,
|
||||
onReply,
|
||||
onForward,
|
||||
onArchive,
|
||||
onDelete,
|
||||
onToggleRead,
|
||||
onAddLabel,
|
||||
onRemoveLabel,
|
||||
}: {
|
||||
selected?: MailMessage
|
||||
loading: boolean
|
||||
labels: MailLabel[]
|
||||
labelPending: boolean
|
||||
previousMessage?: MailMessage
|
||||
nextMessage?: MailMessage
|
||||
onBack: () => void
|
||||
onSelect: (id: string | null) => void
|
||||
onStar: (message: MailMessage) => void
|
||||
onReply: (message: MailMessage) => void
|
||||
onForward: (message: MailMessage) => void
|
||||
onArchive: (message: MailMessage) => void
|
||||
onDelete: (message: MailMessage) => void
|
||||
onToggleRead: (message: MailMessage) => void
|
||||
onAddLabel: (message: MailMessage, label: MailLabel) => void
|
||||
onRemoveLabel: (message: MailMessage, labelId: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-background">
|
||||
<div className="flex h-14 shrink-0 items-center justify-between gap-3 border-b px-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onBack}><ArrowLeft className="h-4 w-4" />返回</Button>
|
||||
{selected && <Button variant="outline" size="sm" onClick={() => onReply(selected)}><Reply className="h-4 w-4" />回复</Button>}
|
||||
{selected && <Button variant="outline" size="sm" onClick={() => onDelete(selected)}><Trash2 className="h-4 w-4" />删除</Button>}
|
||||
{selected && <Button variant="outline" size="sm" onClick={() => onForward(selected)}><Forward className="h-4 w-4" />转发</Button>}
|
||||
{selected && <Button variant="outline" size="sm" onClick={() => onArchive(selected)}>{selected.folder === "Archive" ? "取消归档" : "归档"}</Button>}
|
||||
{selected && <Button variant="outline" size="sm" onClick={() => onToggleRead(selected)}><MailCheck className="h-4 w-4" />{selected.isRead ? "标为未读" : "标为已读"}</Button>}
|
||||
{selected && <Button variant="outline" size="sm" onClick={() => onStar(selected)}><Star className={cn("h-4 w-4", selected.isStarred && "fill-yellow-400 text-yellow-500")} />{selected.isStarred ? "取消星标" : "添加星标"}</Button>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" disabled={!previousMessage} onClick={() => previousMessage && onSelect(previousMessage.id)}>上一封</Button>
|
||||
<Button variant="ghost" size="sm" disabled={!nextMessage} onClick={() => nextMessage && onSelect(nextMessage.id)}>下一封</Button>
|
||||
</div>
|
||||
</div>
|
||||
{loading && <div className="space-y-4 p-8"><Skeleton className="h-8 w-2/3" /><Skeleton className="h-4 w-1/3" /><Separator /><Skeleton className="h-64 w-full" /></div>}
|
||||
{!loading && !selected && <div className="grid flex-1 place-items-center text-sm text-muted-foreground">邮件不存在</div>}
|
||||
{selected && (
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="w-full px-8 py-6">
|
||||
<div className="space-y-5 border-b pb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="min-w-0 flex-1 truncate text-2xl font-semibold tracking-tight">{selected.subject}</h1>
|
||||
<Button type="button" variant="ghost" size="icon" aria-label={selected.isStarred ? "取消星标" : "添加星标"} className="text-muted-foreground hover:text-yellow-500" onClick={() => onStar(selected)}>
|
||||
<Star className={cn("h-5 w-5", selected.isStarred && "fill-yellow-400 text-yellow-500")} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<Avatar className="size-10 rounded-full"><AvatarFallback className="bg-primary text-sm font-semibold text-primary-foreground">{accountInitial(selected.from)}</AvatarFallback></Avatar>
|
||||
<div className="min-w-0 text-sm">
|
||||
<div className="truncate font-medium text-foreground">{selected.from}</div>
|
||||
<div className="truncate text-muted-foreground">收件人 {selected.to.join(", ")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right text-sm text-muted-foreground">{formatDateTime(selected.receivedAt)}</div>
|
||||
</div>
|
||||
<MessageLabels
|
||||
messageLabels={selected.labels || []}
|
||||
availableLabels={labels}
|
||||
onAdd={(label) => onAddLabel(selected, label)}
|
||||
onRemove={(labelId) => onRemoveLabel(selected, labelId)}
|
||||
pending={labelPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="py-8">
|
||||
<div className="mail-html prose max-w-none text-sm leading-7" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(selected.bodyHtml || `<pre>${selected.bodyText || ""}</pre>`) }} />
|
||||
{selected.attachments && selected.attachments.length > 0 && <div className="mt-8 rounded-lg border p-4"><div className="mb-3 font-medium">附件</div><div className="space-y-2">{selected.attachments.map((a) => <a className="flex items-center justify-between rounded-md border p-3 text-sm hover:bg-accent" href={`/api/mail/attachments/${a.id}`} key={a.id}><span className="flex items-center gap-2"><Paperclip className="h-4 w-4" />{a.filename}</span><span className="text-muted-foreground">{formatBytes(a.sizeBytes)}</span></a>)}</div></div>}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CompactMessageRow({ message, active, checked, onCheckedChange, onClick, onStar }: { message: MailMessage; active: boolean; checked: boolean; onCheckedChange: (checked: boolean) => void; onClick: () => void; onStar: () => void }) {
|
||||
const visibleLabels = (message.labels || []).slice(0, 2)
|
||||
return (
|
||||
<div onClick={onClick} className={cn("grid cursor-pointer grid-cols-[32px_28px_minmax(140px,240px)_minmax(0,1fr)_88px_36px] items-center gap-2 border-b px-4 py-2 text-sm transition-colors hover:bg-accent/50", active && "bg-accent", !message.isRead && "font-semibold")}>
|
||||
<Checkbox aria-label="选择邮件" checked={checked} onCheckedChange={(value) => onCheckedChange(value === true)} onClick={(event) => event.stopPropagation()} />
|
||||
<Mail className={cn("h-4 w-4", message.isRead ? "text-muted-foreground/70" : "fill-yellow-200 text-yellow-500")} />
|
||||
<div className="truncate">{message.from}</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-medium">{message.subject}</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground">{message.snippet}</span>
|
||||
{visibleLabels.map((label) => <MailLabelBadge key={label.id} label={label} compact />)}
|
||||
{message.hasAttachments && <Paperclip className="h-3 w-3 shrink-0 text-muted-foreground" />}
|
||||
</div>
|
||||
<div className="shrink-0 text-right text-xs text-muted-foreground">{formatDate(message.receivedAt)}</div>
|
||||
<Button type="button" variant="ghost" size="icon" aria-label={message.isStarred ? "取消星标" : "添加星标"} className="h-7 w-7 text-muted-foreground hover:text-yellow-500" onClick={(event) => { event.stopPropagation(); onStar() }}>
|
||||
<Star className={cn("h-4 w-4", message.isStarred && "fill-yellow-400 text-yellow-500")} />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NewLabelButton({ collapsed, pending, onCreate }: { collapsed: boolean; pending: boolean; onCreate: (name: string) => void }) {
|
||||
const [editing, setEditing] = React.useState(false)
|
||||
const [value, setValue] = React.useState("")
|
||||
@@ -549,15 +937,73 @@ function accountInitial(name: string, email?: string) {
|
||||
return (first || "蓝").toUpperCase()
|
||||
}
|
||||
|
||||
function MessageRow({ message, active, onClick, onStar }: { message: MailMessage; active: boolean; onClick: () => void; onStar: () => void }) {
|
||||
function MessageRow({
|
||||
message,
|
||||
active,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
onClick,
|
||||
onStar,
|
||||
}: {
|
||||
message: MailMessage
|
||||
active: boolean
|
||||
checked: boolean
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
onClick: () => void
|
||||
onStar: () => void
|
||||
}) {
|
||||
const visibleLabels = (message.labels || []).slice(0, 2)
|
||||
const hiddenLabelCount = Math.max((message.labels?.length || 0) - visibleLabels.length, 0)
|
||||
return <div onClick={onClick} className={cn("cursor-pointer border-b p-4 transition-colors hover:bg-accent/50", active && "bg-accent", !message.isRead && "font-semibold")}>
|
||||
<div className="mb-1 flex items-center justify-between gap-2"><div className="truncate text-sm">{message.from}</div><div className="shrink-0 text-xs text-muted-foreground">{formatDate(message.receivedAt)}</div></div>
|
||||
<div className="mb-1 flex items-center gap-2"><Button type="button" variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-yellow-500" onClick={(e) => { e.stopPropagation(); onStar() }}><Star className={cn("h-4 w-4", message.isStarred && "fill-yellow-400 text-yellow-500")} /></Button><span className="truncate text-sm">{message.subject}</span>{message.hasAttachments && <Paperclip className="h-3 w-3 text-muted-foreground" />}</div>
|
||||
{message.labels && message.labels.length > 0 && <div className="mb-1 flex flex-wrap gap-1">{message.labels.map((label) => <span key={label.id} className="inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium" style={{ borderColor: label.color, color: label.color }}>{label.name}</span>)}</div>}
|
||||
<div className="line-clamp-2 text-xs text-muted-foreground">{message.snippet}</div>
|
||||
<div className="flex gap-3">
|
||||
<Checkbox
|
||||
aria-label="选择邮件"
|
||||
checked={checked}
|
||||
onCheckedChange={(value) => onCheckedChange(value === true)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className="mt-0.5 shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 truncate text-sm">{message.from}</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={message.isStarred ? "取消星标" : "添加星标"}
|
||||
className="h-7 w-7 text-muted-foreground hover:text-yellow-500"
|
||||
onClick={(e) => { e.stopPropagation(); onStar() }}
|
||||
>
|
||||
<Star className={cn("h-4 w-4", message.isStarred && "fill-yellow-400 text-yellow-500")} />
|
||||
</Button>
|
||||
<div className="text-xs text-muted-foreground">{formatDate(message.receivedAt)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-1 flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 truncate text-sm">{message.subject}</span>
|
||||
{visibleLabels.map((label) => <MailLabelBadge key={label.id} label={label} compact />)}
|
||||
{hiddenLabelCount > 0 && <Badge variant="outline" className="h-5 shrink-0 rounded-md px-1.5 text-[11px] font-normal text-muted-foreground">+{hiddenLabelCount}</Badge>}
|
||||
{message.hasAttachments && <Paperclip className="h-3 w-3 shrink-0 text-muted-foreground" />}
|
||||
</div>
|
||||
<div className="line-clamp-2 text-xs text-muted-foreground">{message.snippet}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
function MailLabelBadge({ label, compact }: { label: MailLabel; compact?: boolean }) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("shrink-0 rounded-md font-normal", compact ? "h-5 px-1.5 text-[11px]" : "h-8 px-2 text-xs")}
|
||||
style={{ borderColor: label.color, color: label.color }}
|
||||
>
|
||||
{label.name}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function MessageLabels({ messageLabels, availableLabels, onAdd, onRemove, pending }: { messageLabels: MailLabel[]; availableLabels: MailLabel[]; onAdd: (label: MailLabel) => void; onRemove: (labelId: string) => void; pending: boolean }) {
|
||||
const activeIds = new Set(messageLabels.map((label) => label.id))
|
||||
return (
|
||||
@@ -565,12 +1011,12 @@ function MessageLabels({ messageLabels, availableLabels, onAdd, onRemove, pendin
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground"><Tag className="h-3.5 w-3.5" />标签</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{messageLabels.map((label) => (
|
||||
<span key={label.id} className="inline-flex items-center gap-1 rounded-full border px-2 py-1 text-xs font-medium" style={{ borderColor: label.color, color: label.color }}>
|
||||
{label.name}
|
||||
<Badge key={label.id} variant="outline" className="h-8 gap-1.5 rounded-md px-2 text-xs font-normal" style={{ borderColor: label.color, color: label.color }}>
|
||||
<span>{label.name}</span>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-4 w-4 rounded-full p-0 hover:bg-black/5" onClick={() => onRemove(label.id)} disabled={pending}>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
{messageLabels.length === 0 && <span className="text-xs text-muted-foreground">无标签</span>}
|
||||
<DropdownMenu>
|
||||
@@ -793,7 +1239,7 @@ function withPrefix(subject: string, prefix: string) { return subject.toLowerCas
|
||||
function quoteMessage(message: MailMessage) {
|
||||
const body = message.bodyText || stripHtml(message.bodyHtml || message.snippet || "")
|
||||
const quote = body.split("\n").map((line) => `> ${line}`).join("\n")
|
||||
return `\n\n----- 原始邮件 -----\nFrom: ${message.from}\nTo: ${message.to.join(", ")}\nDate: ${formatDate(message.receivedAt)}\nSubject: ${message.subject}\n\n${quote}`
|
||||
return `\n\n----- 原始邮件 -----\nFrom: ${message.from}\nTo: ${message.to.join(", ")}\nDate: ${formatDateTime(message.receivedAt)}\nSubject: ${message.subject}\n\n${quote}`
|
||||
}
|
||||
function stripHtml(html: string) { const div = document.createElement("div"); div.innerHTML = DOMPurify.sanitize(html); return div.textContent || div.innerText || "" }
|
||||
async function fileToAttachment(file: File) {
|
||||
|
||||
+266
-11
@@ -2,11 +2,12 @@ import * as React from "react"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import type { ImperativePanelHandle } from "react-resizable-panels"
|
||||
import { useNavigate, useSearchParams } from "react-router-dom"
|
||||
import { ArrowLeft, BarChart3, Ban, Contact, Copy, KeyRound, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftClose, PanelLeftOpen, RefreshCcw, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2 } from "lucide-react"
|
||||
import { ArrowLeft, BarChart3, Ban, Contact, Copy, Info, KeyRound, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftClose, PanelLeftOpen, Plus, RefreshCcw, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, X } from "lucide-react"
|
||||
import { QRCodeSVG } from "qrcode.react"
|
||||
import { api, Mailbox, MailStats } from "@/lib/api"
|
||||
import { api, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailStats } from "@/lib/api"
|
||||
import { cn, formatBytes } from "@/lib/utils"
|
||||
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
||||
import { DisplayMode, useDisplayMode } from "@/lib/display-mode"
|
||||
import { useMe } from "@/hooks/use-me"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -14,6 +15,8 @@ import { Label } from "@/components/ui/label"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
@@ -46,9 +49,9 @@ export function ProfilePage() {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false)
|
||||
const [mailboxId, setMailboxId] = React.useState(() => localStorage.getItem("lanqin:selected-mailbox") || "")
|
||||
const [darkMode, setDarkMode] = React.useState(getInitialTheme)
|
||||
const [ruleMailboxId, setRuleMailboxId] = React.useState("all")
|
||||
const [ruleAction, setRuleAction] = React.useState("archive")
|
||||
const [displayMode, setDisplayMode] = useDisplayMode()
|
||||
const [blockedMailboxId, setBlockedMailboxId] = React.useState("all")
|
||||
const [ruleDialogOpen, setRuleDialogOpen] = React.useState(false)
|
||||
const themeMountedRef = React.useRef(false)
|
||||
|
||||
const rawTab = params.get("tab") as Tab | null
|
||||
@@ -58,6 +61,7 @@ export function ProfilePage() {
|
||||
const contacts = useQuery({ queryKey: ["contacts"], queryFn: api.contacts })
|
||||
const rules = useQuery({ queryKey: ["rules"], queryFn: api.rules })
|
||||
const blocked = useQuery({ queryKey: ["blocked-senders"], queryFn: api.blockedSenders })
|
||||
const ruleLabels = useQuery({ queryKey: ["labels", "rules", mailboxId], queryFn: () => api.labels(mailboxId), enabled: !!mailboxId })
|
||||
const selectedMailbox = React.useMemo(() => mailboxes.data?.items.find((m) => m.id === mailboxId), [mailboxes.data?.items, mailboxId])
|
||||
const stats = useQuery({ queryKey: ["mail-stats", mailboxId], queryFn: () => api.mailStats(mailboxId), enabled: !!mailboxId })
|
||||
|
||||
@@ -97,8 +101,24 @@ export function ProfilePage() {
|
||||
})
|
||||
const deleteContact = useMutation({ mutationFn: api.deleteContact, onSuccess: () => { qc.invalidateQueries({ queryKey: ["contacts"] }); toast({ title: "联系人已删除" }) } })
|
||||
const createRule = useMutation({
|
||||
mutationFn: (form: FormData) => api.createRule({ mailboxId: ruleMailboxId === "all" ? "" : ruleMailboxId, name: String(form.get("name") || ""), fromContains: String(form.get("fromContains") || ""), subjectContains: String(form.get("subjectContains") || ""), action: ruleAction, enabled: true }),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["rules"] }); toast({ title: "收件规则已保存" }) },
|
||||
mutationFn: (payload: {
|
||||
mailboxId: string
|
||||
name: string
|
||||
matchMode: "all" | "any"
|
||||
conditions: MailRuleCondition[]
|
||||
actions: MailRuleAction[]
|
||||
applyToExisting: boolean
|
||||
stopProcessing: boolean
|
||||
enabled: boolean
|
||||
}) => api.createRule(payload),
|
||||
onSuccess: (rule) => {
|
||||
qc.invalidateQueries({ queryKey: ["rules"] })
|
||||
qc.invalidateQueries({ queryKey: ["messages"] })
|
||||
qc.invalidateQueries({ queryKey: ["mail-stats"] })
|
||||
qc.invalidateQueries({ queryKey: ["labels"] })
|
||||
setRuleDialogOpen(false)
|
||||
toast({ title: rule.appliedExistingCount ? `收件规则已保存,已应用 ${rule.appliedExistingCount} 封邮件` : "收件规则已保存" })
|
||||
},
|
||||
onError: (error) => toast({ title: "保存失败", description: error.message }),
|
||||
})
|
||||
const deleteRule = useMutation({ mutationFn: api.deleteRule, onSuccess: () => { qc.invalidateQueries({ queryKey: ["rules"] }); toast({ title: "规则已删除" }) } })
|
||||
@@ -173,14 +193,14 @@ export function ProfilePage() {
|
||||
if (tab === "mailboxes") return <MailboxManagement mailboxes={mailboxes.data?.items || []} selectedMailboxId={mailboxId} onSelect={setMailboxId} onCopy={copy} onOpen={(id) => { setMailboxId(id); navigate("/") }} />
|
||||
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={stats.data} pending={cleanup.isPending} onCleanup={(target) => cleanup.mutate(target)} />
|
||||
if (tab === "rules") return <RulesSection items={rules.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={ruleMailboxId} action={ruleAction} onMailboxChange={setRuleMailboxId} onActionChange={setRuleAction} onCreate={(form) => createRule.mutate(form)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending} />
|
||||
if (tab === "rules") return <RulesSection items={rules.data?.items || []} mailboxes={mailboxes.data?.items || []} labels={ruleLabels.data?.items || []} open={ruleDialogOpen} onOpenChange={setRuleDialogOpen} onCreate={(payload) => createRule.mutate(payload)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending} />
|
||||
if (tab === "blocked") return <BlockedSection items={blocked.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={blockedMailboxId} spamCount={stats.data?.byFolder.find((f) => f.role === "spam")?.count || 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} onRefresh={() => stats.refetch()} />
|
||||
return <ProfileOverview user={user!} profile={profile} password={password} passwordFormRef={passwordFormRef} stats={stats.data} twoFactorFormRef={twoFactorFormRef} setupTwoFactor={setupTwoFactor} enableTwoFactor={enableTwoFactor} disableTwoFactor={disableTwoFactor} onCopy={copy} />
|
||||
return <ProfileOverview user={user!} profile={profile} password={password} passwordFormRef={passwordFormRef} stats={stats.data} displayMode={displayMode} onDisplayModeChange={setDisplayMode} twoFactorFormRef={twoFactorFormRef} setupTwoFactor={setupTwoFactor} enableTwoFactor={enableTwoFactor} disableTwoFactor={disableTwoFactor} onCopy={copy} />
|
||||
}
|
||||
}
|
||||
|
||||
function ProfileOverview({ user, profile, password, passwordFormRef, stats, twoFactorFormRef, setupTwoFactor, enableTwoFactor, disableTwoFactor, onCopy }: { user: { email: string; displayName: string; role: string; disabled: boolean; twoFactorEnabled: boolean; createdAt: string }; profile: { mutate: (form: FormData) => void; isPending: boolean }; password: { mutate: (form: FormData) => void; isPending: boolean }; passwordFormRef: React.RefObject<HTMLFormElement>; stats?: MailStats; twoFactorFormRef: React.RefObject<HTMLFormElement>; setupTwoFactor: { data?: { secret: string; otpauthUrl: string }; mutate: () => void; reset: () => void; isPending: boolean }; enableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; disableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; onCopy: (text: string) => void }) {
|
||||
function ProfileOverview({ user, profile, password, passwordFormRef, stats, displayMode, onDisplayModeChange, twoFactorFormRef, setupTwoFactor, enableTwoFactor, disableTwoFactor, onCopy }: { user: { email: string; displayName: string; role: string; disabled: boolean; twoFactorEnabled: boolean; createdAt: string }; profile: { mutate: (form: FormData) => void; isPending: boolean }; password: { mutate: (form: FormData) => void; isPending: boolean }; passwordFormRef: React.RefObject<HTMLFormElement>; stats?: MailStats; displayMode: DisplayMode; onDisplayModeChange: (mode: DisplayMode) => void; twoFactorFormRef: React.RefObject<HTMLFormElement>; setupTwoFactor: { data?: { secret: string; otpauthUrl: string }; mutate: () => void; reset: () => void; isPending: boolean }; enableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; disableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; onCopy: (text: string) => void }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
@@ -224,6 +244,25 @@ function ProfileOverview({ user, profile, password, passwordFormRef, stats, twoF
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>界面设置</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Field label="显示模式">
|
||||
<Select value={displayMode} onValueChange={(value) => onDisplayModeChange(value as DisplayMode)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="detailed">详细</SelectItem>
|
||||
<SelectItem value="compact">简洁</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>双因素认证</CardTitle>
|
||||
@@ -326,8 +365,224 @@ function CleanupSection({ mailbox, stats, pending, onCleanup }: { mailbox?: Mail
|
||||
return <div className="space-y-6"><StatsSummary stats={stats} /><Card><CardHeader><CardTitle>清理当前邮箱</CardTitle></CardHeader><CardContent className="grid gap-3 md:grid-cols-3"><CleanupButton icon={<MailCheck className="h-4 w-4" />} title="归档已读收件箱" disabled={!mailbox || pending} onClick={() => onCleanup("archive-read-inbox")} /><CleanupButton icon={<MailX className="h-4 w-4" />} title="清空垃圾邮件" disabled={!mailbox || pending} onClick={() => onCleanup("empty-spam")} /><CleanupButton icon={<Trash2 className="h-4 w-4" />} title="清空回收站" disabled={!mailbox || pending} onClick={() => onCleanup("empty-trash")} /></CardContent></Card></div>
|
||||
}
|
||||
|
||||
function RulesSection({ items, mailboxes, mailboxId, action, onMailboxChange, onActionChange, onCreate, onDelete, pending }: { items: any[]; mailboxes: Mailbox[]; mailboxId: string; action: string; onMailboxChange: (value: string) => void; onActionChange: (value: string) => void; onCreate: (form: FormData) => void; onDelete: (id: string) => void; pending: boolean }) {
|
||||
return <div className="grid gap-6 lg:grid-cols-[420px_minmax(0,1fr)]"><Card><CardHeader><CardTitle>新增收件规则</CardTitle></CardHeader><CardContent><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); onCreate(new FormData(e.currentTarget)); e.currentTarget.reset() }}><Field label="规则名称"><Input name="name" /></Field><Field label="适用邮箱"><MailboxSelect value={mailboxId} mailboxes={mailboxes} onChange={onMailboxChange} /></Field><div className="grid gap-3 md:grid-cols-2"><Field label="发件人包含"><Input name="fromContains" /></Field><Field label="主题包含"><Input name="subjectContains" /></Field></div><Field label="执行动作"><Select value={action} onValueChange={onActionChange}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="archive">移入归档</SelectItem><SelectItem value="trash">移入回收站</SelectItem><SelectItem value="star">添加星标</SelectItem><SelectItem value="mark-read">标记已读</SelectItem></SelectContent></Select></Field><Button className="w-full" disabled={pending}>{pending ? "保存中..." : "保存规则"}</Button></form></CardContent></Card><Card><CardHeader><CardTitle>规则列表</CardTitle></CardHeader><CardContent className="space-y-2">{items.map((item) => <div key={item.id} className="flex items-center justify-between gap-3 rounded-lg border p-3"><div className="min-w-0"><div className="truncate text-sm font-medium">{item.name}<Badge variant="outline" className="ml-2">{actionLabels[item.action]}</Badge></div><div className="truncate text-xs text-muted-foreground">{item.mailboxId ? mailboxes.find((m) => m.id === item.mailboxId)?.address : "全部邮箱"} · {item.fromContains ? `发件人包含 ${item.fromContains}` : ""} {item.subjectContains ? `主题包含 ${item.subjectContains}` : ""}</div></div><Button variant="ghost" size="icon" className="size-8 text-destructive" onClick={() => onDelete(item.id)}><Trash2 className="h-4 w-4" /></Button></div>)}{items.length === 0 && <EmptyState text="暂无收件规则" />}</CardContent></Card></div>
|
||||
type RuleCreatePayload = {
|
||||
mailboxId: string
|
||||
name: string
|
||||
matchMode: "all" | "any"
|
||||
conditions: MailRuleCondition[]
|
||||
actions: MailRuleAction[]
|
||||
applyToExisting: boolean
|
||||
stopProcessing: boolean
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const conditionFieldLabels: Record<MailRuleCondition["field"], string> = { from: "发件人地址", to: "收件人地址", subject: "邮件主题", body: "邮件正文" }
|
||||
const conditionOperatorLabels: Record<MailRuleCondition["operator"], string> = { contains: "包含", "not-contains": "不包含", equals: "等于", "not-equals": "不等于", "starts-with": "开头是", "ends-with": "结尾是" }
|
||||
const ruleActionLabels: Record<MailRuleAction["type"], string> = { archive: "移入归档", trash: "移入回收站", star: "添加星标", "mark-read": "标记已读", label: "添加标签", move: "移动到" }
|
||||
|
||||
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 }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => onOpenChange(true)}><Plus className="h-4 w-4" />新建规则</Button>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader><CardTitle>规则列表</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{items.map((item) => <RuleListItem key={item.id} item={item} mailboxes={mailboxes} onDelete={onDelete} />)}
|
||||
{items.length === 0 && <EmptyState text="暂无收件规则" />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<RuleDialog open={open} onOpenChange={onOpenChange} mailboxes={mailboxes} labels={labels} pending={pending} onCreate={onCreate} />
|
||||
</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 }) {
|
||||
const [name, setName] = React.useState("我的规则")
|
||||
const [mailboxId, setMailboxId] = React.useState("all")
|
||||
const [matchMode, setMatchMode] = React.useState<"all" | "any">("all")
|
||||
const [conditions, setConditions] = React.useState<MailRuleCondition[]>([{ field: "from", operator: "contains", value: "" }])
|
||||
const [actions, setActions] = React.useState<MailRuleAction[]>([{ type: "label", value: labels[0]?.name || "" }])
|
||||
const [enabled, setEnabled] = React.useState(true)
|
||||
const [applyToExisting, setApplyToExisting] = React.useState(false)
|
||||
const [stopProcessing, setStopProcessing] = React.useState(false)
|
||||
const selectedMailboxId = mailboxId === "all" ? "" : mailboxId
|
||||
const labelQuery = useQuery({ queryKey: ["labels", "rule-dialog", selectedMailboxId], queryFn: () => api.labels(selectedMailboxId), enabled: !!selectedMailboxId })
|
||||
const availableLabels = selectedMailboxId ? (labelQuery.data?.items || []) : labels
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return
|
||||
setName("我的规则")
|
||||
setMailboxId("all")
|
||||
setMatchMode("all")
|
||||
setConditions([{ field: "from", operator: "contains", value: "" }])
|
||||
setActions([{ type: "label", value: labels[0]?.name || "" }])
|
||||
setEnabled(true)
|
||||
setApplyToExisting(false)
|
||||
setStopProcessing(false)
|
||||
}, [open, labels])
|
||||
|
||||
function updateCondition(index: number, patch: Partial<MailRuleCondition>) {
|
||||
setConditions((items) => items.map((item, i) => i === index ? { ...item, ...patch } : item))
|
||||
}
|
||||
function updateAction(index: number, patch: Partial<MailRuleAction>) {
|
||||
setActions((items) => items.map((item, i) => i === index ? normalizeDraftAction({ ...item, ...patch }, availableLabels) : item))
|
||||
}
|
||||
function addCondition() { setConditions((items) => [...items, { field: "subject", operator: "contains", value: "" }]) }
|
||||
function addAction() { setActions((items) => [...items, { type: "star" }]) }
|
||||
function removeCondition(index: number) { setConditions((items) => items.length > 1 ? items.filter((_, i) => i !== index) : items) }
|
||||
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.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)
|
||||
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 })
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[min(94vw,84rem)] max-w-none gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="border-b px-8 py-6">
|
||||
<DialogTitle className="text-2xl">新建规则</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={submit}>
|
||||
<div className="space-y-7 px-8 py-7">
|
||||
<Field label="名称"><Input value={name} onChange={(event) => setName(event.target.value)} placeholder="我的规则" /></Field>
|
||||
<Field label="适用邮箱"><MailboxSelect value={mailboxId} mailboxes={mailboxes} onChange={setMailboxId} /></Field>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||
<span>当新邮件到达时,满足以下</span>
|
||||
<Select value={matchMode} onValueChange={(value) => setMatchMode(value as "all" | "any")}>
|
||||
<SelectTrigger className="h-9 w-[132px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="all">所有条件</SelectItem><SelectItem value="any">任一条件</SelectItem></SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{conditions.map((condition, index) => (
|
||||
<div key={index} className="grid gap-3 md:grid-cols-[220px_150px_minmax(0,1fr)_auto_auto]">
|
||||
<Select value={condition.field} onValueChange={(value) => updateCondition(index, { field: value as MailRuleCondition["field"] })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{(Object.keys(conditionFieldLabels) as MailRuleCondition["field"][]).map((value) => <SelectItem key={value} value={value}>{conditionFieldLabels[value]}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Select value={condition.operator} onValueChange={(value) => updateCondition(index, { operator: value as MailRuleCondition["operator"] })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{(Object.keys(conditionOperatorLabels) as MailRuleCondition["operator"][]).map((value) => <SelectItem key={value} value={value}>{conditionOperatorLabels[value]}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Input value={condition.value} onChange={(event) => updateCondition(index, { value: event.target.value })} placeholder="输入值" />
|
||||
<Button type="button" variant="ghost" size="icon" className="text-muted-foreground" onClick={() => removeCondition(index)} disabled={conditions.length === 1}><X className="h-4 w-4" /></Button>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={addCondition}><Plus className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm">执行以下动作</div>
|
||||
<div className="space-y-3">
|
||||
{actions.map((action, index) => (
|
||||
<div key={index} className="grid gap-3 md:grid-cols-[220px_minmax(0,1fr)_auto_auto]">
|
||||
<Select value={action.type} onValueChange={(value) => updateAction(index, { type: value as MailRuleAction["type"], value: "", labelId: "" })}>
|
||||
<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)} />
|
||||
<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>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={addAction}><Plus className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<RuleCheckbox checked={enabled} onCheckedChange={setEnabled} label="立即启用" />
|
||||
<RuleCheckbox checked={applyToExisting} onCheckedChange={setApplyToExisting} label="应用于现有邮件" />
|
||||
<div className="flex items-center gap-2">
|
||||
<RuleCheckbox checked={stopProcessing} onCheckedChange={setStopProcessing} label="终止规则:命中此规则后不再应用其他规则" />
|
||||
<Info className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="border-t px-8 py-5">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button disabled={!canCreate}>{pending ? "创建中..." : "创建"}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function RuleActionValue({ action, labels, onChange }: { action: MailRuleAction; labels: MailLabel[]; onChange: (patch: Partial<MailRuleAction>) => void }) {
|
||||
if (action.type === "label") {
|
||||
if (labels.length > 0) {
|
||||
return (
|
||||
<Select value={action.value || labels[0].name} onValueChange={(value) => onChange({ value, labelId: labels.find((item) => item.name === value)?.id || "" })}>
|
||||
<SelectTrigger><SelectValue placeholder="选择标签" /></SelectTrigger>
|
||||
<SelectContent>{labels.map((label) => <SelectItem key={label.id} value={label.name}>{label.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
return <Input value={action.value || ""} onChange={(event) => onChange({ value: event.target.value, labelId: "" })} placeholder="标签名称" />
|
||||
}
|
||||
if (action.type === "move") {
|
||||
return (
|
||||
<Select value={action.value || "Archive"} onValueChange={(value) => onChange({ value })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="Inbox">收件箱</SelectItem><SelectItem value="Archive">归档</SelectItem><SelectItem value="Spam">垃圾邮件</SelectItem><SelectItem value="Trash">回收站</SelectItem></SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
return <Input value="无需填写" readOnly />
|
||||
}
|
||||
|
||||
function RuleCheckbox({ checked, onCheckedChange, label }: { checked: boolean; onCheckedChange: (checked: boolean) => void; label: string }) {
|
||||
const id = React.useId()
|
||||
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 : "全部邮箱"
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border p-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2 text-sm font-medium">
|
||||
<span className="truncate">{item.name}</span>
|
||||
<Badge variant={item.enabled ? "default" : "secondary"}>{item.enabled ? "启用" : "停用"}</Badge>
|
||||
{item.actions.map((action, index) => <Badge key={`${action.type}-${index}`} variant="outline">{actionSummary(action)}</Badge>)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{mailbox} · {item.matchMode === "any" ? "任一条件" : "所有条件"} · {conditionSummary(item.conditions, item.fromContains, item.subjectContains)}</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="size-8 shrink-0 text-destructive" onClick={() => onDelete(item.id)}><Trash2 className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeDraftAction(action: MailRuleAction, labels: MailLabel[]): MailRuleAction {
|
||||
if (action.type === "label") {
|
||||
const value = action.value || labels[0]?.name || ""
|
||||
return { type: "label", value, labelId: labels.find((label) => label.name === value)?.id || action.labelId || "" }
|
||||
}
|
||||
if (action.type === "move") return { type: "move", value: action.value || "Archive" }
|
||||
return { type: action.type }
|
||||
}
|
||||
|
||||
function conditionSummary(conditions: MailRuleCondition[] = [], fromContains = "", subjectContains = "") {
|
||||
const items = conditions.length > 0 ? conditions : [fromContains ? { field: "from", operator: "contains", value: fromContains } as MailRuleCondition : undefined, subjectContains ? { field: "subject", operator: "contains", value: subjectContains } as MailRuleCondition : undefined].filter(Boolean) as MailRuleCondition[]
|
||||
return items.map((item) => `${conditionFieldLabels[item.field]} ${conditionOperatorLabels[item.operator]} ${item.value}`).join(";") || "无条件"
|
||||
}
|
||||
|
||||
function actionSummary(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")}`
|
||||
return ruleActionLabels[action.type]
|
||||
}
|
||||
|
||||
function BlockedSection({ items, mailboxes, mailboxId, spamCount, onMailboxChange, onCreate, onDelete, pending }: { items: any[]; mailboxes: Mailbox[]; mailboxId: string; spamCount: number; onMailboxChange: (value: string) => void; onCreate: (form: FormData) => void; onDelete: (id: string) => void; pending: boolean }) {
|
||||
|
||||
Reference in New Issue
Block a user