release: prepare v1.2.25
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
This commit is contained in:
@@ -57,7 +57,7 @@ export type AdminOverview = { users: number; activeUsers: number; domains: numbe
|
||||
export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string }
|
||||
export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; unreadCount?: number; createdAt: string }
|
||||
export type Alias = { id: string; domainId: string; source: string; destination: string; enabled: boolean; createdAt: string }
|
||||
export type MailFolder = { id: string; name: string; role: string; sortOrder: number; unreadCount: number; totalCount: number; uidValidity: number; uidNext: number; highestModseq: number }
|
||||
export type MailFolder = { id: string; name: string; role: string; icon: string; sortOrder: number; unreadCount: number; totalCount: number; uidValidity: number; uidNext: number; highestModseq: number }
|
||||
export type Attachment = { id: string; messageId: string; filename: string; contentType: string; sizeBytes: number; createdAt: string }
|
||||
export type MailLabel = { id: string; mailboxId?: string; name: string; color: string; messageCount?: number }
|
||||
export type MailMessage = {
|
||||
|
||||
@@ -233,9 +233,9 @@ export const api = {
|
||||
externalMessage: (id: string, remoteId: string) => request<MailMessage>(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}`),
|
||||
markExternalRead: (id: string, remoteId: string, read: boolean) => request<{ ok: boolean }>(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}/mark-read`, { method: "POST", body: JSON.stringify({ read }) }),
|
||||
folders: (mailboxId?: string) => request<ListResponse<MailFolder>>(`/api/mail/folders${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
|
||||
createFolder: (payload: { mailboxId?: string; name: string }) => {
|
||||
createFolder: (payload: { mailboxId?: string; name: string; icon?: string }) => {
|
||||
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
|
||||
return request<MailFolder>(`/api/mail/folders${query}`, { method: "POST", body: JSON.stringify({ name: payload.name }) })
|
||||
return request<MailFolder>(`/api/mail/folders${query}`, { method: "POST", body: JSON.stringify({ name: payload.name, icon: payload.icon }) })
|
||||
},
|
||||
reorderFolders: (payload: { mailboxId?: string; folderIds: string[]; folders?: { id: string; sortOrder: number }[] }) => {
|
||||
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
|
||||
|
||||
+123
-10
@@ -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, Download, Ellipsis, Eraser, Eye, FileText, Folder, Forward, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, Mailbox as MailboxIcon, MailCheck, MailQuestion, Moon, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Trash2, Type, Underline, Undo2, Upload, X } from "lucide-react"
|
||||
import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Ban, Bell, Bold, Bot, Briefcase, Calendar, Check, ChevronDown, Clock3, Code2, Copy, Download, Ellipsis, Eraser, Eye, FileText, Folder, Forward, GraduationCap, Heart, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, Mailbox as MailboxIcon, MailCheck, MailQuestion, Moon, PanelLeftOpen, Paperclip, PencilLine, Plane, Plus, Quote, Receipt, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, ShoppingBag, Signature, SlidersHorizontal, Smile, Sparkles, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, Upload, Users, X } from "lucide-react"
|
||||
import { api, ExternalImapAccount, 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"
|
||||
@@ -49,6 +49,65 @@ import { useToast } from "@/hooks/use-toast"
|
||||
import { hasPermission } from "@/lib/permissions"
|
||||
|
||||
const folderIcons: Record<string, React.ReactNode> = { inbox: <Inbox className="h-4 w-4" />, sent: <Send className="h-4 w-4" />, drafts: <FileText className="h-4 w-4" />, archive: <Archive className="h-4 w-4" />, spam: <Ban className="h-4 w-4" />, trash: <Trash2 className="h-4 w-4" /> }
|
||||
function NetflixFolderIcon({ className }: { className?: string }) {
|
||||
return <span aria-hidden="true" className={cn("inline-flex items-center justify-center font-black text-red-600", className)}>N</span>
|
||||
}
|
||||
const customFolderIconOptions = [
|
||||
{ key: "folder", label: "文件夹", icon: Folder },
|
||||
{ key: "mail", label: "邮件", icon: Mail },
|
||||
{ key: "briefcase", label: "工作", icon: Briefcase },
|
||||
{ key: "users", label: "联系人", icon: Users },
|
||||
{ key: "receipt", label: "账单", icon: Receipt },
|
||||
{ key: "shopping", label: "购物", icon: ShoppingBag },
|
||||
{ key: "plane", label: "旅行", icon: Plane },
|
||||
{ key: "graduation", label: "学习", icon: GraduationCap },
|
||||
{ key: "heart", label: "收藏", icon: Heart },
|
||||
{ key: "star", label: "重要", icon: Star },
|
||||
{ key: "bell", label: "提醒", icon: Bell },
|
||||
{ key: "shield", label: "安全", icon: ShieldCheck },
|
||||
{ key: "tag", label: "分类", icon: Tag },
|
||||
{ key: "netflix", label: "Netflix", icon: NetflixFolderIcon },
|
||||
{ key: "chatgpt", label: "ChatGPT", icon: Bot },
|
||||
] as const
|
||||
function suggestedFolderIcon(name: string) {
|
||||
const value = name.trim().toLocaleLowerCase()
|
||||
if (/netflix|奈飞|网飞/.test(value)) return "netflix"
|
||||
if (/chatgpt|openai|\bgpt\b/.test(value)) return "chatgpt"
|
||||
if (/账单|发票|收据|bill|invoice|receipt/.test(value)) return "receipt"
|
||||
if (/购物|订单|快递|shop|order|delivery/.test(value)) return "shopping"
|
||||
if (/旅行|旅游|机票|酒店|travel|trip|flight|hotel/.test(value)) return "plane"
|
||||
if (/学习|教育|课程|学校|study|school|course/.test(value)) return "graduation"
|
||||
if (/联系人|团队|用户|contact|team|people/.test(value)) return "users"
|
||||
if (/工作|项目|客户|work|project|business|client/.test(value)) return "briefcase"
|
||||
if (/收藏|喜欢|favorite|favourite/.test(value)) return "heart"
|
||||
if (/重要|紧急|important|urgent/.test(value)) return "star"
|
||||
if (/安全|验证|密码|登录|security|verify|password|login/.test(value)) return "shield"
|
||||
if (/提醒|通知|remind|notification/.test(value)) return "bell"
|
||||
if (/邮件|邮箱|mail|email/.test(value)) return "mail"
|
||||
return "folder"
|
||||
}
|
||||
|
||||
async function prepareFolderIcon(file: File) {
|
||||
if (!/^image\/(png|jpe?g|webp)$/i.test(file.type)) throw new Error("仅支持 PNG、JPG 或 WebP 图片")
|
||||
if (file.size > 2 * 1024 * 1024) throw new Error("原图不能超过 2 MB")
|
||||
const bitmap = await createImageBitmap(file)
|
||||
try {
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = 64
|
||||
canvas.height = 64
|
||||
const context = canvas.getContext("2d")
|
||||
if (!context) throw new Error("无法处理该图片")
|
||||
const scale = Math.min(64 / bitmap.width, 64 / bitmap.height)
|
||||
const width = Math.max(1, Math.round(bitmap.width * scale))
|
||||
const height = Math.max(1, Math.round(bitmap.height * scale))
|
||||
context.drawImage(bitmap, Math.round((64 - width) / 2), Math.round((64 - height) / 2), width, height)
|
||||
const result = canvas.toDataURL("image/png")
|
||||
if (result.length > 44_000) throw new Error("处理后的图标过大")
|
||||
return result
|
||||
} finally {
|
||||
bitmap.close()
|
||||
}
|
||||
}
|
||||
const folderLabels: Record<string, string> = {
|
||||
Inbox: "收件箱",
|
||||
Sent: "已发送",
|
||||
@@ -438,7 +497,7 @@ export function MailPage() {
|
||||
onSettled: () => setCancelingScheduledId(""),
|
||||
})
|
||||
const createFolder = useMutation({
|
||||
mutationFn: (name: string) => api.createFolder({ mailboxId: activeMailboxId, name }),
|
||||
mutationFn: ({ name, icon }: { name: string; icon: string }) => api.createFolder({ mailboxId: activeMailboxId, name, icon }),
|
||||
onSuccess: (created) => {
|
||||
qc.invalidateQueries({ queryKey: ["folders"] })
|
||||
setFolderDialogOpen(false)
|
||||
@@ -1844,7 +1903,7 @@ export function MailPage() {
|
||||
open={folderDialogOpen}
|
||||
pending={createFolder.isPending}
|
||||
onOpenChange={setFolderDialogOpen}
|
||||
onCreate={(name) => createFolder.mutate(name)}
|
||||
onCreate={(payload) => createFolder.mutate(payload)}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={!!pendingConfirm}
|
||||
@@ -1862,7 +1921,7 @@ export function MailPage() {
|
||||
|
||||
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 })
|
||||
const normalizedFolders = ["Inbox", "Drafts", "Sent", "Archive", "Spam", "Trash"].map((name) => byName.get(name) || { id: `virtual-${name}`, name, role: name.toLowerCase(), icon: "folder", sortOrder: 0, unreadCount: 0, totalCount: 0, uidValidity: 0, uidNext: 1, highestModseq: 1 })
|
||||
for (const item of folders) {
|
||||
if (!normalizedFolders.some((folder) => folder.name === item.name)) normalizedFolders.push(item)
|
||||
}
|
||||
@@ -1872,7 +1931,7 @@ function buildMailMenuItems(folders: MailFolder[], starredCount: number, schedul
|
||||
folderId: item.id,
|
||||
folderName: item.name,
|
||||
label: folderLabels[item.name] || item.name,
|
||||
icon: isCustomMailFolder(item) ? <Folder className="h-4 w-4" /> : folderIcons[item.role] || <Inbox className="h-4 w-4" />,
|
||||
icon: isCustomMailFolder(item) ? customFolderIcon(item.icon) : folderIcons[item.role] || <Inbox className="h-4 w-4" />,
|
||||
count: item.name === "Drafts" ? item.totalCount : item.unreadCount,
|
||||
custom: isCustomMailFolder(item),
|
||||
order: isCustomMailFolder(item) ? item.sortOrder || 100000 : menuAnchorOrder(item.name),
|
||||
@@ -1892,6 +1951,13 @@ function isCustomMailFolder(folder: Pick<MailFolder, "name" | "id">) {
|
||||
return !folder.id.startsWith("virtual-") && !["inbox", "sent", "drafts", "archive", "spam", "trash"].includes(folder.name.trim().toLowerCase())
|
||||
}
|
||||
|
||||
function customFolderIcon(iconKey: string | undefined, className = "h-4 w-4") {
|
||||
if (iconKey?.startsWith("data:image/png;base64,")) return <img src={iconKey} alt="" className={cn("shrink-0 object-contain", className)} />
|
||||
const option = customFolderIconOptions.find((item) => item.key === iconKey) || customFolderIconOptions[0]
|
||||
const Icon = option.icon
|
||||
return <Icon className={className} />
|
||||
}
|
||||
|
||||
function compareMailFolders(a: MailFolder, b: MailFolder) {
|
||||
return (isCustomMailFolder(a) ? a.sortOrder || 100000 : menuAnchorOrder(a.name)) - (isCustomMailFolder(b) ? b.sortOrder || 100000 : menuAnchorOrder(b.name)) || a.name.localeCompare(b.name)
|
||||
}
|
||||
@@ -2513,7 +2579,7 @@ function BulkActionToolbar({ pending, currentFolder, folders = [], readAction =
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
{movableFolders.map((folder) => (
|
||||
<DropdownMenuItem key={folder.id} onSelect={() => onMoveToFolder(folder.name)}>
|
||||
{folderIcons[folder.role] || <Folder className="h-4 w-4" />}
|
||||
{isCustomMailFolder(folder) ? customFolderIcon(folder.icon) : folderIcons[folder.role] || <Folder className="h-4 w-4" />}
|
||||
<span className="min-w-0 flex-1 truncate">{folderLabels[folder.name] || folder.name}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
@@ -2673,7 +2739,7 @@ function MessageContextMenu({ state, labels, folders, canSend, canOrganize, canM
|
||||
<div className="max-h-44 overflow-y-auto">
|
||||
{movableFolders.map((folder) => (
|
||||
<Button key={folder.id} type="button" variant="ghost" className={itemClass} onClick={() => moveToFolder(folder.name)}>
|
||||
{folderIcons[folder.role] || <Inbox className="h-4 w-4" />}
|
||||
{isCustomMailFolder(folder) ? customFolderIcon(folder.icon) : folderIcons[folder.role] || <Inbox className="h-4 w-4" />}
|
||||
<span className="min-w-0 flex-1 truncate text-left">{folderLabels[folder.name] || folder.name}</span>
|
||||
{folder.totalCount > 0 && <span className="text-xs text-muted-foreground">{folder.totalCount}</span>}
|
||||
</Button>
|
||||
@@ -2718,12 +2784,21 @@ function contextMenuPosition(x: number, y: number) {
|
||||
return { x: Math.min(Math.max(x, padding), maxX), y: Math.min(Math.max(y, padding), maxY) }
|
||||
}
|
||||
|
||||
function CreateFolderDialog({ open, pending, onOpenChange, onCreate }: { open: boolean; pending: boolean; onOpenChange: (open: boolean) => void; onCreate: (name: string) => void }) {
|
||||
function CreateFolderDialog({ open, pending, onOpenChange, onCreate }: { open: boolean; pending: boolean; onOpenChange: (open: boolean) => void; onCreate: (payload: { name: string; icon: string }) => void }) {
|
||||
const [name, setName] = React.useState("")
|
||||
const [icon, setIcon] = React.useState("auto")
|
||||
const [uploadError, setUploadError] = React.useState("")
|
||||
const iconInputRef = React.useRef<HTMLInputElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (open) setName("")
|
||||
if (open) {
|
||||
setName("")
|
||||
setIcon("auto")
|
||||
setUploadError("")
|
||||
}
|
||||
}, [open])
|
||||
const trimmed = name.trim()
|
||||
const suggestedIcon = suggestedFolderIcon(trimmed)
|
||||
const resolvedIcon = icon === "auto" ? suggestedIcon : icon
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[min(92vw,28rem)] max-w-none">
|
||||
@@ -2734,13 +2809,51 @@ function CreateFolderDialog({ open, pending, onOpenChange, onCreate }: { open: b
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (trimmed) onCreate(trimmed)
|
||||
if (trimmed) onCreate({ name: trimmed, icon: resolvedIcon })
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-folder-name">文件夹名称</Label>
|
||||
<Input id="new-folder-name" autoFocus value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:客户、账单、项目归档" />
|
||||
</div>
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium">图标</legend>
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
<Button type="button" variant="outline" size="icon" className={cn("relative size-10 shadow-none", icon === "auto" && "border-primary bg-primary/10 text-primary ring-1 ring-primary")} onClick={() => setIcon("auto")} aria-label={`自动匹配:${customFolderIconOptions.find((item) => item.key === suggestedIcon)?.label || "文件夹"}`} title={`自动匹配:${customFolderIconOptions.find((item) => item.key === suggestedIcon)?.label || "文件夹"}`} aria-pressed={icon === "auto"}>
|
||||
{customFolderIcon(suggestedIcon)}
|
||||
<Sparkles className="absolute -right-1 -top-1 h-3 w-3 rounded-full bg-background" />
|
||||
</Button>
|
||||
{customFolderIconOptions.map((option) => {
|
||||
const Icon = option.icon
|
||||
return (
|
||||
<Button key={option.key} type="button" variant="outline" size="icon" className={cn("size-10 shadow-none", icon === option.key && "border-primary bg-primary/10 text-primary ring-1 ring-primary")} onClick={() => setIcon(option.key)} aria-label={option.label} title={option.label} aria-pressed={icon === option.key}>
|
||||
<Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
<Button type="button" variant="outline" size="icon" className={cn("size-10 shadow-none", icon.startsWith("data:image/png;base64,") && "border-primary bg-primary/10 text-primary ring-1 ring-primary")} onClick={() => iconInputRef.current?.click()} aria-label="上传自定义图标" title="上传自定义图标" aria-pressed={icon.startsWith("data:image/png;base64,")}>
|
||||
{icon.startsWith("data:image/png;base64,") ? customFolderIcon(icon) : <Upload className="h-4 w-4" />}
|
||||
</Button>
|
||||
<input
|
||||
ref={iconInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="hidden"
|
||||
onChange={async (event) => {
|
||||
const file = event.target.files?.[0]
|
||||
event.target.value = ""
|
||||
if (!file) return
|
||||
try {
|
||||
setUploadError("")
|
||||
setIcon(await prepareFolderIcon(file))
|
||||
} catch (error) {
|
||||
setUploadError(error instanceof Error ? error.message : "无法处理该图片")
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{uploadError && <p role="alert" className="text-xs text-destructive">{uploadError}</p>}
|
||||
</fieldset>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button disabled={!trimmed || pending}>{pending ? "创建中..." : "创建"}</Button>
|
||||
|
||||
@@ -2273,7 +2273,7 @@ function RulesSection({ items, mailboxes, labels, verifiedEmails, open, onOpenCh
|
||||
<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-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.map((item, index) => <RuleListItem key={item.id} item={item} index={index} count={items.length} mailboxLabel={item.mailboxId ? mailboxes.find((mailbox) => mailbox.id === item.mailboxId)?.address || "指定邮箱" : "全部邮箱"} 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={setDialogOpen} mailboxes={mailboxes} labels={labels} verifiedEmails={verifiedEmails} pending={pending} initialRule={editingRule} onSave={(payload) => editingRule ? onUpdate(editingRule.id, payload) : onCreate(payload)} />
|
||||
@@ -2461,12 +2461,10 @@ 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, 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 }) {
|
||||
function RuleListItem({ item, index, count, mailboxLabel, pending, onEdit, onToggle, onMove, onApply, onDelete }: { item: MailRule; index: number; count: number; mailboxLabel: string; 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-[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">
|
||||
@@ -2475,12 +2473,14 @@ function RuleListItem({ item, index, count, pending, onEdit, onToggle, onMove, o
|
||||
<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 text-sm leading-6 text-muted-foreground">
|
||||
<p className="truncate"><span className="text-muted-foreground">适用:</span> {mailboxLabel}</p>
|
||||
<p className="truncate"><span className="text-muted-foreground">条件:</span> {conditionText}</p>
|
||||
<p className="truncate"><span className="text-muted-foreground">动作:</span> {actionText}</p>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<Button type="button" variant="ghost" size="icon" className="size-7 text-muted-foreground" disabled={pending || index === 0} onClick={() => onMove("up")} aria-label="上移" title="上移"><ChevronUp className="h-4 w-4" /></Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="size-7 text-muted-foreground" disabled={pending || index === count - 1} onClick={() => onMove("down")} aria-label="下移" title="下移"><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>
|
||||
|
||||
Reference in New Issue
Block a user