@@ -2,8 +2,8 @@ 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 { BookOpen , CheckCircle2 , ChevronDown , Circle , ClipboardList , Cloud , Copy , Download , ExternalLink , Eye , EyeOff , Github , Globe2 , HardDrive , KeyRound , Loader2 , Mail , Mailbox , MoreHorizontal , RefreshCcw , Scale , Search , Send , 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 { AlertCircle , CheckCircle2 , ChevronDown , ChevronRight , Circle , ClipboardList , Clock3 , Cloud , Copy , Database , Download , ExternalLink , Eye , EyeOff , Globe2 , HardDrive , KeyRound , Loader2 , Mail , MoreHorizontal , RefreshCcw , Search , Send , ShieldCheck , Trash2 , UserRound } from "lucide-react"
import { api , AdminOverview , 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"
import { Input } from "@/components/ui/input"
@@ -17,30 +17,30 @@ import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator"
import { Select , SelectContent , SelectItem , SelectTrigger , SelectValue } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { Skeleton } from "@/components/ui/skeleton"
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"
import type { BackupTransfer , PermissionKey , TelegramPairing } from "@/lib/api-types"
type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "sendAudit" | "backups" | "settings"
type SettingsTab = "base" | "smtp" | "storage" | "mail" | "notifications" | "externalImap" | "templates" | "security" | "about"
type SettingsTab = "base" | "smtp" | "storage" | "mail" | "notifications" | "externalImap" | "templates" | "security"
type PendingConfirm = { title : string ; description? : string ; confirmText : string ; onConfirm : ( ) = > void }
const sectionMeta : Record < Section , { label : string ; frontLabel : string ; description : string } > = {
overview : { label : "数据总览" , frontLabel : "数据统计 ", description : "系统 运行、DNS、邮箱和消息 状态集中查看。" } ,
users : { label : "账号管理" , frontLabel : "账号设置" , description : "管理登录账号、身份状态、邮箱数量上限和共享存储容量。" } ,
permissionGroups : { label : "权限配置" , frontLabel : "账号权限" , description : "配置自定义权限、发信频率、附件和邮箱创建额度。" } ,
domains : { label : "域名管理" , frontLabel : "邮箱地址" , description : "维护邮件域名、DKIM 和 DNS 检测。" } ,
mailboxes : { label : "邮箱管理" , frontLabel : "邮箱管理" , description : "按归属账号查看和管理子邮箱,默认邮箱受保护。" } ,
aliases : { label : "邮件转发" , frontLabel : "邮件转发" , description : "管理域名转发规则。" } ,
messages : { label : "全部邮件" , frontLabel : "全部邮箱" , description : "按邮箱、文件夹和关键词查看全站邮件。" } ,
sendAudit : { label : "发送队列" , frontLabel : "发送队列" , description : "查看发信投递、重试和失败记录。" } ,
backups : { label : "备份与恢复" , frontLabel : "数据保护" , description : "创建、校验和下载可迁移的加密完整备份。" } ,
settings : { label : "系统设置" , frontLabel : "账号设置" , description : "管理站点、发信、存储、注册、安全和邮件模板。" } ,
const sectionMeta : Record < Section , { label : string ; description : string } > = {
overview : { label : "仪表盘 " , description : "邮件 运行、域名与系统 状态集中查看。" } ,
users : { label : "账号管理" , description : "管理登录账号、身份状态、邮箱数量上限和共享存储容量。" } ,
permissionGroups : { label : "权限配置" , description : "配置自定义权限、发信频率、附件和邮箱创建额度。" } ,
domains : { label : "域名管理" , description : "维护邮件域名、DKIM 和 DNS 检测。" } ,
mailboxes : { label : "邮箱管理" , description : "按归属账号查看和管理子邮箱,默认邮箱受保护。" } ,
aliases : { label : "邮件转发" , description : "管理域名转发规则。" } ,
messages : { label : "全部邮件" , description : "按邮箱、文件夹和关键词查看全站邮件。" } ,
sendAudit : { label : "发送队列" , description : "查看发信投递、重试和失败记录。" } ,
backups : { label : "备份与恢复" , description : "创建、校验和下载可迁移的加密完整备份。" } ,
settings : { label : "系统设置" , description : "管理站点、发信、存储、注册、安全和邮件模板。" } ,
}
const sectionLabels = Object . fromEntries ( Object . entries ( sectionMeta ) . map ( ( [ key , value ] ) = > [ key , value . label ] ) ) as Record < Section , string >
const sectionKeys = Object . keys ( sectionLabels ) as Section [ ]
@@ -56,8 +56,6 @@ const sectionPermissions: Record<Section, PermissionKey[]> = {
backups : [ "admin.settings.view" ] ,
settings : [ "admin.settings.view" , "admin.templates.view" ] ,
}
const projectRepositoryUrl = "https://github.com/zxyszx/NewSzxcn-Email"
const projectTelegramUrl = "https://t.me/+EhII7MSyi3QwNDQ5"
const defaultPermissionLimits : PermissionLimits = { maxAttachmentMb : 25 , maxMailboxCount : 9 , smtpDailyLimit : 200 , smtpMinuteLimit : 20 , imapMinuteLimit : 200 , pop3MinuteLimit : 150 }
const defaultMailboxLimitOverride = 9
const defaultUserStorageQuotaMb = 100
@@ -89,11 +87,13 @@ export function AdminPage() {
const canMessagesView = hasPermission ( user , "admin.messages.view" )
const canSettingsView = hasPermission ( user , "admin.settings.view" )
const canTemplatesView = hasPermission ( user , "admin.templates.view" )
const canLoadDomains = canUsersView || canDomainsView || canDNSView || canMailboxesView || canAliasesView || canSettingsView || canTemplatesView
const canLoadMailboxes = canMailboxesView || canMessagesView || canSettingsView
const overview = useQuery ( { queryKey : [ "admin" , "overview" ] , queryFn : api.adminOverview , enabled : ! ! user && canOverview } )
const users = useQuery ( { queryKey : [ "admin" , "users" ] , queryFn : api.users , enabled : ! ! user && ( canUsersView || canMailboxesView ) } )
const permissionGroups = useQuery ( { queryKey : [ "admin" , "permission-groups" ] , queryFn : api.permissionGroups , enabled : ! ! user && ( canPermissionGroupsView || canUsersView ) } )
const domains = useQuery ( { queryKey : [ "admin" , "domains" ] , queryFn : api.domains , enabled : ! ! user && ( canUsersView || canDomainsView || canDNSView || canMailboxesView || canAliasesView || canSettingsView || canTemplatesView ) } )
const mailboxes = useQuery ( { queryKey : [ "admin" , "mailboxes" ] , queryFn : api.mailboxes , enabled : ! ! user && ( canMailboxesView || canMessagesView || canSettingsView ) } )
const domains = useQuery ( { queryKey : [ "admin" , "domains" ] , queryFn : api.domains , enabled : ! ! user && canLoadDomains } )
const mailboxes = useQuery ( { queryKey : [ "admin" , "mailboxes" ] , queryFn : api.mailboxes , enabled : ! ! user && canLoadMailboxes } )
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 ( )
@@ -107,14 +107,18 @@ export function AdminPage() {
const visibleSections = sectionKeys . filter ( ( key ) = > hasAnyPermission ( user , sectionPermissions [ key ] ) && ( key !== "backups" || user ? . role === "admin" ) )
const rawSection = params . get ( "section" ) as Section | null
const section : Section = rawSection && visibleSections . includes ( rawSection ) ? rawSection : visibleSections [ 0 ] || "overview"
const sectionQuery = section === "overview" ? overview
: section === "users" ? users
: section === "permissionGroups" ? permissionGroups
: section === "domains" ? domains
: section === "mailboxes" ? mailboxes
: section === "aliases" ? aliases
: section === "settings" ? settings
: null
const sectionQueries = section === "overview" ? [ overview , . . . ( canLoadDomains ? [ domains ] : [ ] ) , . . . ( canSettingsView ? [ settings ] : [ ] ) ]
: section === "users" ? [ users , permissionGroups , domains ]
: section === "permissionGroups" ? [ permissionGroups ]
: section === "domains" ? [ domains ]
: section === "mailboxes" ? [ mailboxes , users , domains ]
: section === "aliases" ? [ aliases , domains ]
: section === "messages" || section === "sendAudit" ? [ mailboxes ]
: section === "settings" ? [ domains , . . . ( canLoadMailboxes ? [ mailboxes ] : [ ] ) , . . . ( canSettingsView ? [ settings ] : [ ] ) ]
: [ ]
const sectionLoading = sectionQueries . some ( ( query ) = > query . isPending )
const sectionError = sectionQueries . find ( ( query ) = > query . isError )
const sectionReady = ! sectionLoading && ! sectionError
async function refreshAdminPage() {
if ( refreshing ) return
@@ -133,54 +137,68 @@ export function AdminPage() {
}
}
const overviewChecklist = sectionReady && section === "overview" ? setupChecklist ( overview . data , domainItems , settings . data ) . filter ( ( item ) = > visibleSections . includes ( item . section ) ) : undefined
const changeSection = ( next : Section ) = > setParams ( next === "overview" ? { } : { section : next } )
return (
< ScrollArea className = "h-[calc(100svh-3rem)] md:h-svh" >
< main className = "admin-page mx-auto w-full max-w-[144 0px] px-3 pb-8 pt-3 sm:px-4 sm:pt-4" >
< AdminPageHeader section = { section } refreshing = { refreshing } onRefresh = { refreshAdminPage } / >
< main className = "admin-page mx-auto w-full max-w-[132 0px] px-3 pb-8 pt-3 sm:px-4 sm:pt-4" >
< AdminPageHeader section = { section } refreshing = { refreshing } onRefresh = { refreshAdminPage } checklist = { overviewChecklist } onSectionChange = { changeSection } / >
{ sectionQuery ? . isError && < QueryFailure error = { sectionQuery . error } onRetry = { ( ) = > { void sectionQuery . refetch ( ) } } / > }
{ sectionError && < QueryFailure error = { sectionError . error } onRetry = { ( ) = > { void Promise . all ( sectionQueries . map ( ( query ) = > query . refetch ( ) ) ) } } / > }
{ sectionLoading && < AdminSectionLoading overview = { section === "overview" } / > }
{ section === "overview" && canOverview && (
< section className = "mb-3 grid grid-cols-2 gap-3 lg:grid-cols-4" >
< Stat icon = { < Users / > } label = "账号" value = { overview . data ? . users || 0 } detail = { ` ${ overview . data ? . activeUsers || 0 } 个活跃 ` } / >
< Stat icon = { < Globe2 / > } label = "邮件域名" value = { overview . data ? . domains || 0 } detail = { ` ${ domainItems . filter ( ( domain ) = > domain . dnsStatus === "ok" ) . length } 个 DNS 正常 ` } / >
< Stat icon = { < Mailbox / > } label = "邮箱" value = { overview . data ? . mailboxes || 0 } detail = { ` ${ overview . data ? . activeMailboxes || 0 } 个活跃 ` } / >
< Stat icon = { < ShieldCheck / > } label = "存储用量" value = { formatBytes ( overview . data ? . storageBytes || 0 ) } detail = { ` ${ overview . data ? . unreadMessages || 0 } 封未读 · ${ overview . data ? . aliases || 0 } 个转发 ` } / >
{ sectionReady && section === "overview" && canOverview && (
< section className = "mb-3 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4" >
< Stat icon = { < UserRound / > } tone = "primary" label = "账号" value = { overview . data ? . users || 0 } detail = { ` ${ overview . data ? . activeUsers || 0 } 个活跃 ` } / >
< Stat icon = { < Globe2 / > } tone = "cyan" label = "邮件域名" value = { overview . data ? . domains || 0 } detail = { domainItems . some ( ( domain ) = > domain . dnsStatus === "ok" ) ? ` ${ domainItems . filter ( ( domain ) = > domain . dnsStatus === "ok" ) . length } 个 DNS 正常 ` : "待检测" } / >
< Stat icon = { < Mail / > } tone = "sky" label = "邮箱" value = { overview . data ? . mailboxes || 0 } detail = { ` ${ overview . data ? . activeMailboxes || 0 } 个活跃 ` } / >
< Stat icon = { < Database / > } tone = "violet" label = "存储用量" value = { formatBytes ( overview . data ? . storageBytes || 0 ) } detail = { ` ${ overview . data ? . unreadMessages || 0 } 封未读 · ${ overview . data ? . aliases || 0 } 个转发 ` } / >
< / section >
) }
{ section === "overview" && < OverviewSection overview = { overview . data } domains = { domainItems } settings = { settings . data } visibleSections = { visibleSections } onSectionChange = { ( next ) = > setParams ( next === "overview" ? { } : { section : next } ) } / > }
{ section === "users" && < UsersSection users = { userItems } permissionGroups = { assignablePermissionGroups } domains = { domainItems } / > }
{ section === "permissionGroups" && < PermissionGroupsSection groups = { permissionGroups . data ? . items || [ ] } catalog = { permissionGroups . data ? . catalog || [ ] } / > }
{ 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 } systemAdmin = { user ? . role === "admin" } / > }
{ section === "sendAudit" && < AdminSendAuditSection mailboxes = { mailboxItems } / > }
{ section === "backups" && < BackupsSection / > }
{ section === "settings" && < SystemSettingsSection settings = { settings . data } domains = { domainItems } mailboxes = { mailboxItems } initialTab = { params . get ( "settingsTab" ) } / > }
{ sectionReady && section === "overview" && < OverviewSection overview = { overview . data } domains = { domainItems } domainsAvailable = { canLoadDomains } settings = { settings . data } settingsAvailable = { canSettingsView } visibleSections = { visibleSections } onSectionChange = { changeSection } / > }
{ sectionReady && section === "users" && < UsersSection users = { userItems } permissionGroups = { assignablePermissionGroups } domains = { domainItems } / > }
{ sectionReady && section === "permissionGroups" && < PermissionGroupsSection groups = { permissionGroups . data ? . items || [ ] } catalog = { permissionGroups . data ? . catalog || [ ] } / > }
{ sectionReady && section === "domains" && < DomainsSection domains = { domainItems } / > }
{ sectionReady && section === "mailboxes" && < MailboxesSection mailboxes = { mailboxItems } users = { userItems } domains = { domainItems } / > }
{ sectionReady && section === "aliases" && < AliasesSection aliases = { aliasItems } domains = { domainItems } / > }
{ sectionReady && section === "messages" && < AdminMessagesSection mailboxes = { mailboxItems } systemAdmin = { user ? . role === "admin" } / > }
{ sectionReady && section === "sendAudit" && < AdminSendAuditSection mailboxes = { mailboxItems } / > }
{ sectionReady && section === "backups" && < BackupsSection / > }
{ sectionReady && section === "settings" && < SystemSettingsSection settings = { settings . data } domains = { domainItems } mailboxes = { mailboxItems } initialTab = { params . get ( "settingsTab" ) } / > }
< / main >
< / ScrollArea >
)
}
function AdminPageHeader ( { section , refreshing , onRefresh } : { section : Section ; refreshing : boolean ; onRefresh : ( ) = > void } ) {
function AdminSectionLoading ( { overview = false } : { overview? : boolean } ) {
return (
< div className = "space-y-3" aria-label = "正在加载后台数据" aria-busy = "true" >
{ overview && < div className = "grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4" > { Array . from ( { length : 4 } ) . map ( ( _ , index ) = > < Skeleton key = { index } className = "h-[88px] w-full" / > ) } < / div > }
< Skeleton className = { cn ( "w-full" , overview ? "h-[126px]" : "h-[360px]" ) } / >
{ overview && < Skeleton className = "h-[152px] w-full" / > }
< span className = "sr-only" > 加 载 中 . . . < / span >
< / div >
)
}
type SetupChecklistItem = ReturnType < typeof setupChecklist > [ number ]
function AdminPageHeader ( { section , refreshing , onRefresh , checklist , onSectionChange } : { section : Section ; refreshing : boolean ; onRefresh : ( ) = > void ; checklist? : SetupChecklistItem [ ] ; onSectionChange : ( section : Section ) = > void } ) {
const meta = sectionMeta [ section ]
return (
< div className = "mb-4 border-b pb-3" >
< div className = "flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between" >
< div className = "mb-4 border-b border-border/80 pb-3" >
< div className = "flex flex-col gap-2 sm:flex-row sm:items-c enter sm:justify-between" >
< div className = "min-w-0" >
< div className = "mb-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground" >
< span > 后 台 管 理 < / span >
< span className = "h-1 w-1 rounded-full bg-muted-foreground/50" / >
< span > 前 台 : { meta . frontLabel } < / span >
< / div >
< 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 >
< p className = "mt-1 text-sm leading-5 text-muted-foreground/80 " > { meta . description } < / p >
< / div >
< 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 = "刷新后台数据" >
{ checklist && < SetupChecklistDialog checklist = { checklist } onSectionChange = { onSectionChange } / > }
< Button type = "button" variant = "outline" size = "sm" className = "h-9 gap-2 shadow-none" onClick = { onRefresh } disabled = { refreshing } aria-label = "刷新后台数据" title = "刷新后台数据" >
< RefreshCcw className = { cn ( "h-4 w-4" , refreshing && "animate-spin" ) } / >
< span className = "hidden sm:inline" > 刷 新 < / span >
< / Button >
< / div >
< / div >
@@ -188,51 +206,107 @@ function AdminPageHeader({ section, refreshing, onRefresh }: { section: Section;
)
}
function OverviewSection ( { overview , domains , settings , visibleSections , onSectionChange } : { overview ? : { activeUsers : number ; activeMailboxes : number ; aliases : number ; messages : number ; unreadMessages : number } ; domains : Domain [ ] ; settings? : SystemSettings ; visibleSections : Section [ ] ; onSectionChange : ( section : Section ) = > void } ) {
const checklist = setupChecklist ( overview , domains , settings ) . filter ( ( item ) = > visibleSections . includes ( item . section ) )
function SetupChecklistDialog ( { checklist , onSectionChange } : { checklist : SetupChecklistItem [ ] ; onSectionChange : ( section : Section ) = > void } ) {
const [ open , setOpen ] = React . useState ( false )
const completed = checklist . filter ( ( item ) = > item . done ) . length
const complete = checklist . length > 0 && completed === checklist . length
return (
< div className = "grid items-start gap-3 lg:grid-cols-[minmax(0,1.35fr)_minmax(340px,.65fr)]" >
< Card >
< CardHeader className = "pb-3" > < CardTitle > 首 次 配 置 < / CardTitle > < / CardHeader >
< CardContent className = "grid gap-2 sm:grid-cols-2" >
{ checklist . map ( ( item ) = > (
< Button key = { item . key } type = "button" variant = "outline" className = "h-auto min-h-[64px] w-full justify-start gap-3 px-3 py-2 text-left font-normal last:sm:col-span-2" onClick = { ( ) = > onSectionChange ( item . section ) } >
{ item . done ? < CheckCircle2 className = "h-4 w-4 shrink-0 text-green-600" / > : < Circle className = "h-4 w-4 shrink-0 text-muted-foreground" / > }
< span className = "min -w-0 flex-1 " >
< span className = "block font-medium" > { item . title } < / span >
< span className = "block truncate text-xs text-muted-foreground" > { item . detail } < / span >
< / span >
< / Button >
) ) }
< Dialog open = { open } onOpenChange = { setOpen } >
< DialogTrigger asChild >
< Button type = "button" variant = "outline" size = "sm" className = "h-9 gap-2 shadow-none" >
{ complete ? < CheckCircle2 className = "h-4 w-4 text-emerald-600" / > : < Circle className = "h-4 w-4 text-amber-600" / > }
< span > { complete ? "初始化完成" : ` 初始化 ${ completed } / ${ checklist . length } ` } < / span >
< / Button >
< / DialogTrigger >
< DialogContent className = "max -w-xl gap-3 p-5 " >
< DialogHeader > < DialogTitle > 首 次 配 置 < / DialogTitle > < / DialogHeader >
< div className = "grid gap-2 sm:grid-cols-2" >
{ checklist . map ( ( item ) = > (
< Button key = { item . key } type = "button" variant = "outline" className = "h-auto min-h-[62px] justify-start gap-3 px-3 py-2 text-left font-normal last:sm:col-span-2" onClick = { ( ) = > { setOpen ( false ) ; onSectionChange ( item . section ) } } >
{ item . done ? < CheckCircle2 className = "h-4 w-4 shrink-0 text-emerald-600" / > : < Circle className = "h-4 w-4 shrink-0 text-muted-foreground" / > }
< span className = "min-w-0 flex-1" > < span className = "block font-medium" > { item . title } < / span > < span className = "block truncate text-xs text-muted-foreground" > { item . detail } < / span > < / span >
< ChevronRight className = "h-4 w-4 shrink-0 text-muted-foreground" / >
< / Button >
) ) }
< / div >
< / DialogContent >
< / Dialog >
)
}
function OverviewSection ( { overview , domains , domainsAvailable , settings , settingsAvailable , visibleSections , onSectionChange } : { overview? : AdminOverview ; domains : Domain [ ] ; domainsAvailable : boolean ; settings? : SystemSettings ; settingsAvailable : boolean ; visibleSections : Section [ ] ; onSectionChange : ( section : Section ) = > void } ) {
const { toast } = useToast ( )
const dnsOK = domains . length > 0 && domains . every ( ( domain ) = > domain . dnsStatus === "ok" )
const dnsWarning = domains . length > 0 && domains . some ( ( domain ) = > domain . dnsStatus === "ok" )
return (
< div className = "space-y-3" >
< Card className = "border-border/80" >
< CardHeader className = "px-4 pb-2 pt-3 sm:px-4" > < CardTitle className = "text-base" > 邮 件 运 行 概 览 < / CardTitle > < / CardHeader >
< CardContent className = "px-4 pb-3 sm:px-4" >
< div className = "grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6" >
< OverviewMetric icon = { < Send / > } label = "今日发送" value = { overview ? . todaySent || 0 } tone = "primary" / >
< OverviewMetric icon = { < Download / > } label = "今日接收" value = { overview ? . todayReceived || 0 } tone = "success" / >
< OverviewMetric icon = { < CheckCircle2 / > } label = "发送成功" value = { overview ? . sendDelivered || 0 } tone = "success" / >
< OverviewMetric icon = { < AlertCircle / > } label = "发送失败" value = { overview ? . sendFailed || 0 } tone = { ( overview ? . sendFailed || 0 ) > 0 ? "danger" : "muted" } / >
< OverviewMetric icon = { < Clock3 / > } label = "队列邮件" value = { overview ? . queueMessages || 0 } tone = { ( overview ? . queueMessages || 0 ) > 0 ? "warning" : "muted" } / >
< OverviewMetric icon = { < Mail / > } label = "未读邮件" value = { overview ? . unreadMessages || 0 } tone = { ( overview ? . unreadMessages || 0 ) > 0 ? "primary" : "muted" } / >
< / div >
< / CardContent >
< / Card >
< Card >
< CardHeader className = "pb-3" > < CardTitle > 系 统 状 态 < / CardTitle > < / CardHeader >
< CardContent className = "space-y-4" >
< section >
< h3 className = "mb-2 text-xs font-medium text-muted-foreground" > DNS 状 态 < / h3 >
< div className = "space-y-2" >
{ domains . map ( ( domain ) = > < DomainBadgeRow key = { domain . id } domain = { domain } / > ) }
{ domains . length === 0 && < Empty text = "暂无域名 " / > }
< Card className = "border-border/80" >
< CardHeader className = "px-4 pb-2 pt-3" > < CardTitle className = "text-base" > 系 统 状 态 < / CardTitle > < / CardHeader >
< CardContent className = "grid gap-2.5 px-4 pb-3 md:grid-cols-3" >
< div className = "rounded-md border border-border/80 px-3 py-2" >
< DashboardGroupTitle > 系 统 健 康 < / DashboardGroupTitle >
< div className = "grid grid-cols-3 gap-2" >
< DashboardStatusItem label = "系统" status = { < LightStatus state = "success" label = "运行中 " / > } / >
< DashboardStatusItem label = "DNS" status = { ! domainsAvailable ? < LightStatus state = "muted" label = "不可查看" / > : < LightStatus state = { dnsOK ? "success" : dnsWarning ? "warning" : "muted" } label = { dnsOK ? "正常" : dnsWarning ? "部分正常" : domains . length ? "未检测" : "未配置" } / > } / >
< DashboardStatusItem label = "SMTP" status = { ! settingsAvailable ? < LightStatus state = "muted" label = "不可查看" / > : < LightStatus state = { settings ? . smtpHost ? "success" : "warning" } label = { settings ? . smtpHost ? "已配置" : "未配置" } / > } / >
< / div >
< / section >
< Separator / >
< section >
< h3 className = "mb-2 text-xs font-medium text-muted-foreground" > 运 行 信 息 < / h3 >
< div className = "space-y-2 text-sm text-muted-foreground" >
< InfoLine label = "公网地址 " value = { settings ? . publicBaseUrl || "-" } / >
< InfoLine label = "SMTP" value = { settings ? . smtpHost ? ` ${ settings . smtpHost } : ${ settings . smtpPort } ` : "-" } / >
< InfoLine label = "注册" value = { settings ? . openRegistration ? "已开放" : "关闭" } / >
< InfoLine label = "自助申请邮箱" value = { settings ? . userMailboxApplyEnabled ? "已启用" : "关闭" } / >
< / div >
< div className = "rounded-md border border-border/80 px-3 py-2" >
< DashboardGroupTitle > 服 务 信 息 < / DashboardGroupTitle >
< div className = "grid grid-cols-2 gap-3" >
< DashboardInfoItem label = "公网地址" value = { ! settingsAvailable ? "不可查看" : settings ? . publicBaseUrl || "-" } onCopy = { settings ? . publicBaseUrl ? ( ) = > copyOverviewValue ( settings . publicBaseUrl , "公网地址" , toast ) : undefined } / >
< DashboardInfoItem label = "SMTP " value = { ! settingsAvailable ? "不可查看" : settings ? . smtpHost ? ` ${ settings . smtpHost } : ${ settings . smtpPort } ` : "未配置" } onCopy = { settings ? . smtpHost ? ( ) = > copyOverviewValue ( ` ${ settings . smtpHost } : ${ settings . smtpPort } ` , "SMTP 地址" , toast ) : undefined } / >
< / div >
< / section >
< / div >
< div className = "rounded-md border border-border/80 px-3 py-2" >
< DashboardGroupTitle > 功 能 状 态 < / DashboardGroupTitle >
< div className = "grid grid-cols-2 gap-3" >
< DashboardStatusItem label = "注册" status = { ! settingsAvailable ? < LightStatus state = "muted" label = "不可查看" / > : < LightStatus state = { settings ? . openRegistration ? "success" : "muted" } label = { settings ? . openRegistration ? "已开放" : "关闭" } / > } / >
< DashboardStatusItem label = "自助申请邮箱" status = { ! settingsAvailable ? < LightStatus state = "muted" label = "不可查看" / > : < LightStatus state = { settings ? . userMailboxApplyEnabled ? "success" : "muted" } label = { settings ? . userMailboxApplyEnabled ? "已启用" : "关闭" } / > } / >
< / div >
< / div >
< / CardContent >
< / Card >
< Card className = "border-border/80" >
< CardHeader className = "flex-row items-center justify-between space-y-0 px-4 pb-2 pt-3" > < div className = "flex items-baseline gap-2" > < CardTitle className = "text-base" > 域 名 状 态 < / CardTitle > < span className = "text-xs text-muted-foreground" > { domains . length } 个 域 名 < / span > < / div > { visibleSections . includes ( "domains" ) && < Button type = "button" variant = "ghost" size = "sm" className = "h-7 gap-1 px-2 text-xs" onClick = { ( ) = > onSectionChange ( "domains" ) } > 管 理 域 名 < ChevronRight className = "h-3.5 w-3.5" / > < / Button > } < / CardHeader >
< CardContent className = "px-4 pb-3" >
{ ! domainsAvailable ? < Empty text = "没有权限查看域名详情" / > : domains . length > 0 ? < div className = "overflow-hidden rounded-md border border-border/80" >
< div className = "hidden grid-cols-[minmax(0,1fr)_120px_140px_150px_24px] items-center gap-3 border-b bg-muted/20 px-3 py-1.5 text-[11px] font-medium text-muted-foreground md:grid" > < span > 邮 件 域 名 < / span > < span > 使 用 状 态 < / span > < span > DNS 状 态 < / span > < span > 最 近 检 测 < / span > < span / > < / div >
< div className = "divide-y" > { domains . slice ( 0 , 5 ) . map ( ( domain ) = > {
const dnsDisplay = dnsStatusDisplay ( domain . dnsStatus )
return < Button key = { domain . id } type = "button" variant = "ghost" className = "grid h-auto min-h-12 w-full grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-none px-3 py-2 text-left font-normal transition-colors hover:bg-muted/35 md:grid-cols-[minmax(0,1fr)_120px_140px_150px_24px]" onClick = { ( ) = > onSectionChange ( "domains" ) } >
< span className = "min-w-0 truncate text-sm font-medium" > { domain . name } < / span >
< span className = "hidden md:block" > < LightStatus state = { domain . status === "active" ? "success" : "muted" } label = { domain . status === "active" ? "已启用" : "已停用" } / > < / span >
< span className = "justify-self-end md:justify-self-start" > < LightStatus state = { dnsDisplay . state } label = { dnsDisplay . label } / > < / span >
< span className = "hidden text-xs text-muted-foreground md:block" > { domain . dnsCheckedAt ? formatDate ( domain . dnsCheckedAt ) : "尚未检测" } < / span >
< ChevronRight className = "hidden h-4 w-4 text-muted-foreground md:block" / >
< span className = "col-span-2 flex items-center gap-2 text-[11px] text-muted-foreground md:hidden" > < span > { domain . status === "active" ? "已启用" : "已停用" } < / span > < span > · < / span > < span > { domain . dnsCheckedAt ? ` 检测于 ${ formatDate ( domain . dnsCheckedAt ) } ` : "尚未检测" } < / span > < / span >
< / Button >
} ) } < / div >
{ domains . length > 5 && < Button type = "button" variant = "ghost" className = "h-auto w-full justify-start rounded-none border-t px-3 py-2 text-left text-xs font-normal text-muted-foreground transition-colors hover:bg-muted/35 hover:text-foreground" onClick = { ( ) = > onSectionChange ( "domains" ) } > 还 有 { domains . length - 5 } 个 域 名 , 查 看 全 部 < / Button > }
< / div > : < Empty text = "暂无邮件域名" / > }
< / CardContent >
< / Card >
< / div >
)
}
function setupChecklist ( overview : { activeUsers : number ; activeMailboxes : number ; aliases : number ; messages : number ; unreadMessages : number } | undefined , domains : Domain [ ] , settings? : SystemSettings ) {
function setupChecklist ( overview : AdminOverview | undefined , domains : Domain [ ] , settings? : SystemSettings ) {
const hasDomain = domains . length > 0
const dnsReady = domains . some ( ( domain ) = > domain . dnsStatus === "ok" )
const hasMailbox = ( overview ? . activeMailboxes || 0 ) > 0
@@ -246,6 +320,38 @@ function setupChecklist(overview: { activeUsers: number; activeMailboxes: number
]
}
function OverviewMetric ( { icon , label , value , tone } : { icon : React.ReactNode ; label : string ; value : number ; tone : "primary" | "success" | "warning" | "danger" | "muted" } ) {
return < div className = "flex min-h-[50px] items-center gap-2 rounded-md border border-border/80 px-2 py-1" > < div className = { cn ( "grid h-7 w-7 shrink-0 place-items-center rounded-full [&>svg]:h-3.5 [&>svg]:w-3.5" , tone === "primary" && "bg-primary/5 text-primary" , tone === "success" && "bg-emerald-500/10 text-emerald-600" , tone === "warning" && "bg-amber-500/10 text-amber-600" , tone === "danger" && "bg-destructive/10 text-destructive" , tone === "muted" && "bg-muted text-muted-foreground" ) } > { icon } < / div > < div className = "min-w-0" > < div className = "truncate text-[10px] leading-3 text-muted-foreground" > { label } < / div > < div className = { cn ( "text-base font-semibold leading-5 tabular-nums" , tone === "success" && "text-emerald-700 dark:text-emerald-400" , tone === "warning" && "text-amber-700 dark:text-amber-400" , tone === "danger" && "text-destructive" ) } > { value } < / div > < / div > < / div >
}
function dnsStatusDisplay ( status : string ) : { state : "success" | "warning" | "danger" | "muted" ; label : string } {
if ( status === "ok" ) return { state : "success" , label : "DNS 正常" }
if ( status === "error" ) return { state : "danger" , label : "DNS 异常" }
if ( ! status || status === "unchecked" ) return { state : "muted" , label : "未检测" }
return { state : "warning" , label : "需检查" }
}
function LightStatus ( { state , label } : { state : "success" | "warning" | "danger" | "muted" ; label : string } ) {
return < span className = { cn ( "inline-flex h-6 items-center gap-1.5 whitespace-nowrap rounded-full px-1.5 text-xs font-medium" , state === "success" && "bg-emerald-500/[0.07] text-emerald-700 dark:text-emerald-400" , state === "warning" && "bg-amber-500/[0.07] text-amber-700 dark:text-amber-400" , state === "danger" && "bg-destructive/[0.07] text-destructive" , state === "muted" && "bg-muted/70 text-muted-foreground" ) } > < span className = { cn ( "h-1.5 w-1.5 rounded-full" , state === "success" && "bg-emerald-600" , state === "warning" && "bg-amber-500" , state === "danger" && "bg-destructive" , state === "muted" && "bg-muted-foreground/60" ) } / > { label } < / span >
}
function DashboardGroupTitle ( { children } : { children : React.ReactNode } ) {
return < div className = "mb-1.5 text-[11px] font-medium text-muted-foreground" > { children } < / div >
}
function DashboardStatusItem ( { label , status } : { label : string ; status : React.ReactNode } ) {
return < div className = "min-w-0" > < div className = "mb-0.5 truncate text-[10px] text-muted-foreground" > { label } < / div > { status } < / div >
}
function DashboardInfoItem ( { label , value , onCopy } : { label : string ; value : string ; onCopy ? : ( ) = > void } ) {
return < div className = "flex min-w-0 items-end gap-1" > < div className = "min-w-0 flex-1" > < div className = "text-[10px] text-muted-foreground" > { label } < / div > < div className = "truncate text-xs font-medium" title = { value } > { value } < / div > < / div > { onCopy && < Button type = "button" variant = "ghost" size = "icon" className = "h-6 w-6 shrink-0" onClick = { onCopy } title = { ` 复制 ${ label } ` } aria-label = { ` 复制 ${ label } ` } > < Copy className = "h-3 w-3" / > < / Button > } < / div >
}
async function copyOverviewValue ( value : string , label : string , toast : ReturnType < typeof useToast > [ "toast" ] ) {
await navigator . clipboard . writeText ( value )
toast ( { title : ` ${ label } 已复制 ` } )
}
function InfoLine ( { label , value } : { label : string ; value : React.ReactNode } ) {
return < div className = "grid min-h-10 grid-cols-[auto_minmax(0,1fr)] items-center gap-3 rounded-md border px-3 py-2" > < span className = "whitespace-nowrap" > { label } < / span > < span className = "min-w-0 break-all text-right font-medium text-foreground" > { value } < / span > < / div >
}
@@ -441,6 +547,8 @@ function BackupsSection() {
; ( groups [ transfer . name ] || = [ ] ) . push ( transfer )
return groups
} , { } ) ) . sort ( ( left , right ) = > Date . parse ( right [ 0 ] ? . startedAt || "" ) - Date . parse ( left [ 0 ] ? . startedAt || "" ) )
if ( backups . isPending ) return < AdminSectionLoading / >
if ( backups . isError ) return < QueryFailure error = { backups . error } onRetry = { ( ) = > { void backups . refetch ( ) } } compact / >
return (
< div className = "space-y-3" >
< div className = "grid gap-3 xl:grid-cols-[minmax(0,1.35fr)_minmax(300px,.65fr)]" >
@@ -996,11 +1104,11 @@ function DomainsSection({ domains }: { domains: Domain[] }) {
< div key = { domain . id } className = "flex flex-col gap-3 rounded-lg border p-4 md:flex-row md:items-center md:justify-between" >
< div >
< div className = "font-medium" > { domain . name } < / div >
< div className = "text-xs text-muted-foreground" > selector : { domain . dkimSelector } < / div >
< div className = "text-xs text-muted-foreground" > DKIM 选 择 器 : { domain . dkimSelector } < / div >
< / div >
< div className = "flex flex-wrap items-center gap-2" >
< StatusText active = { domain . status === "active" } activeLabel = "启用" inactiveLabel = "停用" / >
< StatusText active = { domain . dnsStatus === "ok" } activeLabel = "DNS 正常" inactiveLabel = { domain . dnsStatus || "未检测" } / >
< LightStatus state = { dnsStatusDisplay ( domain . dnsStatus ) . state } label = { dnsStatusDisplay ( domain . dnsStatus ) . label } / >
{ canViewDNS && < DomainDNSDialog domain = { domain } / > }
{ canUpdate && < Button variant = "outline" size = "sm" onClick = { ( ) = > update . mutate ( { id : domain.id , status : domain.status === "active" ? "disabled" : "active" } ) } > { domain . status === "active" ? "停用" : "启用" } < / Button > }
{ canDelete && < Button variant = "outline" size = "sm" onClick = { ( ) = > setPendingConfirm ( { title : "删除域名?" , description : ` 将删除 ${ domain . name } ,相关邮箱、转发和邮件也可能受影响。 ` , confirmText : "删除域名" , onConfirm : ( ) = > remove . mutate ( domain . id ) } ) } > < Trash2 className = "h-4 w-4" / > 删 除 < / Button > }
@@ -1020,7 +1128,7 @@ function DomainDNSDialog({ domain }: { domain: Domain }) {
< DialogTrigger asChild >
< Button variant = "outline" size = "sm" > DNS < / Button >
< / DialogTrigger >
< DialogContent className = "max-h-[86vh ] overflow-y-auto sm:max-w-3 xl" >
< DialogContent className = "max-h-[calc(100svh-1.5rem) ] overflow-y-auto p-4 sm:max-w-[calc(100vw-2rem)] sm:p-5 lg:max-w-5 xl" >
< DialogHeader > < DialogTitle > { domain . name } DNS < / DialogTitle > < / DialogHeader >
< DNSPanel domain = { domain } embedded / >
< / DialogContent >
@@ -1063,7 +1171,7 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
. filter ( ( group ) = > ! keyword || [ group . owner ? accountPrimaryEmail ( group . owner ) : "" , group . owner ? . displayName || "" , . . . group . mailboxes . map ( ( mailbox ) = > mailbox . address ) ] . some ( ( value ) = > value . toLowerCase ( ) . includes ( keyword ) ) )
const toggleOwner = ( ownerID : string ) = > setExpandedOwners ( ( current ) = > current . includes ( ownerID ) ? current . filter ( ( id ) = > id !== ownerID ) : [ . . . current , ownerID ] )
return (
< Card >
< Card className = "min-w-0" >
< CardHeader >
< div className = "flex flex-col gap-3 md:flex-row md:items-center md:justify-between" >
< CardTitle > 邮 箱 管 理 < / CardTitle >
@@ -1073,7 +1181,7 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
< / div >
< / div >
< / CardHeader >
< CardContent className = "space-y-4" >
< CardContent className = "min-w-0 space-y-4" >
< div className = "relative" >
< Search className = "absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" / >
< Input value = { query } onChange = { ( event ) = > setQuery ( event . target . value ) } placeholder = "搜索账号或邮箱" className = "pl-9" / >
@@ -1219,7 +1327,7 @@ function AdminMessagesSection({ mailboxes, systemAdmin }: { mailboxes: MailboxTy
const detail = useQuery ( { queryKey : [ "admin" , "message" , selectedId ] , queryFn : ( ) = > api . adminMessage ( selectedId ! ) , enabled : ! ! selectedId } )
const items = messages . data ? . pages . flatMap ( ( page ) = > page . items || [ ] ) || [ ]
return (
< Card >
< Card className = "min-w-0" >
< CardHeader >
< div className = "flex flex-col gap-3 md:flex-row md:items-center md:justify-between" >
< CardTitle > 全 部 邮 件 < / CardTitle >
@@ -1228,7 +1336,7 @@ function AdminMessagesSection({ mailboxes, systemAdmin }: { mailboxes: MailboxTy
< / Button >
< / div >
< / CardHeader >
< CardContent className = "space-y-4" >
< CardContent className = "min-w-0 space-y-4" >
< div className = "flex flex-col gap-3 xl:flex-row" >
< div className = "relative flex-1" >
< Search className = "absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" / >
@@ -1277,17 +1385,17 @@ function AdminMessagesSection({ mailboxes, systemAdmin }: { mailboxes: MailboxTy
< / div >
) ) }
< / div >
< div className = "hidden md:block" >
< Table >
< div className = "min-w-0 overflow- hidden md:block max-md:hidden " >
< Table className = "table-fixed" >
< TableHeader >
< TableRow >
< TableHead > 邮 件 < / TableHead >
< TableHead > 邮 箱 < / TableHead >
< TableHead > 发 件 人 < / TableHead >
< TableHead > 收 件 人 < / TableHead >
< TableHead > 文 件 夹 < / TableHead >
< TableHead > 时 间 < / TableHead >
< TableHead className = "w-20 " > < / TableHead >
< TableHead className = "w-[25%]" > 邮 件 < / TableHead >
< TableHead className = "w-[17%]" > 邮 箱 < / TableHead >
< TableHead className = "w-[15%]" > 发 件 人 < / TableHead >
< TableHead className = "w-[17%]" > 收 件 人 < / TableHead >
< TableHead className = "w-[9%]" > 文 件 夹 < / TableHead >
< TableHead className = "w-[10%]" > 时 间 < / TableHead >
< TableHead className = "w-[7%] " > < / TableHead >
< / TableRow >
< / TableHeader >
< TableBody >
@@ -1297,9 +1405,9 @@ function AdminMessagesSection({ mailboxes, systemAdmin }: { mailboxes: MailboxTy
< div className = "truncate font-medium" > { message . subject } < / div >
< div className = "truncate text-xs text-muted-foreground" > { message . snippet } < / div >
< / TableCell >
< TableCell >
< div className = "font-medium" > { message . mailboxAddress || message . recipientAddress || "-" } < / div >
{ message . ownerEmail && < div className = "text-xs text-muted-foreground" > { message . ownerEmail } < / div > }
< TableCell className = "min-w-0" >
< div className = "truncate font-medium" title = { message . mailboxAddress || message . recipientAddress || "-" } > { message . mailboxAddress || message . recipientAddress || "-" } < / div >
{ message . ownerEmail && < div className = "truncate text-xs text-muted-foreground" title = { message . ownerEmail } > { message . ownerEmail } < / div > }
< / TableCell >
< TableCell className = "max-w-[220px] truncate" title = { adminSenderTitle ( message ) } > { adminSenderDisplayName ( message ) } < / TableCell >
< TableCell className = "max-w-[220px] truncate" > { message . recipientAddress || message . to ? . join ( ", " ) || "" } < / TableCell >
@@ -1453,25 +1561,25 @@ function SystemSettingsSection({ settings, domains, mailboxes, initialTab }: { s
const canResetTemplates = hasPermission ( user , "admin.templates.reset" )
const templates = useQuery ( { queryKey : [ "admin" , "mail-templates" ] , queryFn : api.mailTemplates , enabled : canViewTemplates } )
const requestedTab = initialTab as SettingsTab | undefined
const [ settingsTab , setSettingsTab ] = React . useState < SettingsTab > ( ( ) = > requestedTab && [ "base" , "smtp" , "storage" , "mail" , "notifications" , "externalImap" , "templates" , "security" , "about" ] . includes ( requestedTab ) ? requestedTab : "base" )
const [ settingsTab , setSettingsTab ] = React . useState < SettingsTab > ( ( ) = > requestedTab && [ "base" , "smtp" , "storage" , "mail" , "notifications" , "externalImap" , "templates" , "security" ] . includes ( requestedTab ) ? requestedTab : "base" )
const maildirHealth = useQuery ( { queryKey : [ "admin" , "maildir-sync" , "health" ] , queryFn : api.maildirSyncHealth , enabled : canSettingsView && settingsTab === "storage" } )
const [ smtpRequireTls , setSmtpRequireTls ] = React . useState ( false )
const [ allowInsecureHttp , setAllowInsecureHttp ] = React . useState ( true )
const [ openRegistration , setOpenRegistration ] = React . useState ( false )
const [ twoFactorEnabled , setTwoFactorEnabled ] = React . useState ( false )
const [ turnstileEnabled , setTurnstileEnabled ] = React . useState ( false )
const [ catchAllEnabled , setCatchAllEnabled ] = React . useState ( false )
const [ mailAutoRefresh , setMailAutoRefresh ] = React . useState ( true )
const [ userMailboxApplyEnabled , setUserMailboxApplyEnabled ] = React . useState ( false )
const [ userMailboxDomainIds , setUserMailboxDomainIds ] = React . useState < string [ ] > ( [ ] )
const [ externalImapEnabled , setExternalImapEnabled ] = React . useState ( false )
const [ externalImapAllowPrivateHosts , setExternalImapAllowPrivateHosts ] = React . useState ( false )
const [ telegramMailEnabled , setTelegramMailEnabled ] = React . useState ( false )
const [ smtpRequireTls , setSmtpRequireTls ] = React . useState ( ( ) = > settings ? . smtpRequireTls ? ? false )
const [ allowInsecureHttp , setAllowInsecureHttp ] = React . useState ( ( ) = > settings ? . allowInsecureHttp ? ? true )
const [ openRegistration , setOpenRegistration ] = React . useState ( ( ) = > settings ? . openRegistration ? ? false )
const [ twoFactorEnabled , setTwoFactorEnabled ] = React . useState ( ( ) = > settings ? . twoFactorEnabled ? ? false )
const [ turnstileEnabled , setTurnstileEnabled ] = React . useState ( ( ) = > settings ? . turnstileEnabled ? ? false )
const [ catchAllEnabled , setCatchAllEnabled ] = React . useState ( ( ) = > settings ? . catchAllEnabled ? ? false )
const [ mailAutoRefresh , setMailAutoRefresh ] = React . useState ( ( ) = > settings ? . mailAutoRefresh ? ? true )
const [ userMailboxApplyEnabled , setUserMailboxApplyEnabled ] = React . useState ( ( ) = > settings ? . userMailboxApplyEnabled ? ? false )
const [ userMailboxDomainIds , setUserMailboxDomainIds ] = React . useState < string [ ] > ( ( ) = > settings ? . userMailboxDomainIds || [ ] )
const [ externalImapEnabled , setExternalImapEnabled ] = React . useState ( ( ) = > settings ? . externalImapEnabled ? ? false )
const [ externalImapAllowPrivateHosts , setExternalImapAllowPrivateHosts ] = React . useState ( ( ) = > settings ? . externalImapAllowPrivateHosts ? ? false )
const [ telegramMailEnabled , setTelegramMailEnabled ] = React . useState ( ( ) = > settings ? . telegramMailEnabled ? ? false )
const [ telegramBotToken , setTelegramBotToken ] = React . useState ( "" )
const [ telegramPrivateChatId , setTelegramPrivateChatId ] = React . useState ( "" )
const [ telegramBodyMode , setTelegramBodyMode ] = React . useState < "summary" | "full" > ( "summary" )
const [ telegramMailboxIds , setTelegramMailboxIds ] = React . useState < string [ ] > ( [ ] )
const [ telegramIncludeUnregistered , setTelegramIncludeUnregistered ] = React . useState ( false )
const [ telegramPrivateChatId , setTelegramPrivateChatId ] = React . useState ( ( ) = > settings ? . telegramPrivateChatId || "" )
const [ telegramBodyMode , setTelegramBodyMode ] = React . useState < "summary" | "full" > ( ( ) = > settings ? . telegramBodyMode === "full" ? "full" : "summary" )
const [ telegramMailboxIds , setTelegramMailboxIds ] = React . useState < string [ ] > ( ( ) = > settings ? . telegramMailboxIds || [ ] )
const [ telegramIncludeUnregistered , setTelegramIncludeUnregistered ] = React . useState ( ( ) = > settings ? . telegramIncludeUnregistered ? ? false )
const [ telegramPairing , setTelegramPairing ] = React . useState < TelegramPairing | null > ( null )
React . useEffect ( ( ) = > {
if ( ! settings ) return
@@ -1613,11 +1721,10 @@ function SystemSettingsSection({ settings, domains, mailboxes, initialTab }: { s
] : [ ] ) ,
. . . ( canViewTemplates ? [ { key : "templates" as const , label : "模板" } ] : [ ] ) ,
. . . ( canSettingsView ? [ { key : "security" as const , label : "安全" } ] : [ ] ) ,
{ key : "about" , label : "关于" } ,
]
React . useEffect ( ( ) = > {
if ( tabs . some ( ( tab ) = > tab . key === settingsTab ) ) return
setSettingsTab ( tabs [ 0 ] ? . key || "about " )
setSettingsTab ( tabs [ 0 ] ? . key || "base " )
} , [ settingsTab , tabs ] )
return (
< form key = { formKey } onSubmit = { ( event ) = > { event . preventDefault ( ) ; if ( canUpdateSettings ) save . mutate ( new FormData ( event . currentTarget ) ) } } className = "space-y-6" >
@@ -1860,9 +1967,7 @@ function SystemSettingsSection({ settings, domains, mailboxes, initialTab }: { s
< / CardContent >
< / Card > }
{ settingsTab === "about" && < AboutProjectCard / > }
{ settingsTab !== "about" && canUpdateSettings && < div className = "flex justify-end" >
{ canUpdateSettings && < div className = "flex justify-end" >
< Button disabled = { save . isPending || ! settings } > { save . isPending ? "保存中..." : "保存设置" } < / Button >
< / div > }
< / form >
@@ -1965,76 +2070,6 @@ function queryErrorMessage(error: unknown) {
return error instanceof Error ? error . message : "读取 Maildir 同步健康失败"
}
function AboutProjectCard() {
return (
< Card >
< CardHeader >
< CardTitle > 关 于 < / CardTitle >
< / CardHeader >
< CardContent className = "space-y-4 text-sm" >
< AboutRow label = "版本" >
< SystemVersionDialog mode = "inline" / >
< / AboutRow >
< AboutRow label = "交流" >
< div className = "flex flex-wrap gap-3" >
< Button type = "button" variant = "outline" className = "h-11 justify-start px-4 text-base font-normal" asChild >
< a href = { projectRepositoryUrl } target = "_blank" rel = "noreferrer" >
< Github className = "h-5 w-5" / >
GitHub
< / a >
< / Button >
< Button type = "button" variant = "outline" className = "h-11 justify-start px-4 text-base font-normal" asChild >
< a href = { ` ${ projectRepositoryUrl } /issues ` } target = "_blank" rel = "noreferrer" >
< Circle className = "h-5 w-5 text-muted-foreground" / >
Issues
< / a >
< / Button >
< Button type = "button" variant = "outline" className = "h-11 justify-start px-4 text-base font-normal" asChild >
< a href = { projectTelegramUrl } target = "_blank" rel = "noreferrer" >
< ExternalLink className = "h-5 w-5 text-sky-500" / >
Telegram 群 组
< / a >
< / Button >
< / div >
< / AboutRow >
< AboutRow label = "支持" >
< Button type = "button" variant = "outline" className = "h-11 justify-start px-4 text-base font-normal" asChild >
< a href = { projectRepositoryUrl } target = "_blank" rel = "noreferrer" >
< Star className = "h-5 w-5 text-yellow-500" / >
给 项 目 点 Star
< / a >
< / Button >
< / AboutRow >
< AboutRow label = "帮助" >
< div className = "flex flex-wrap gap-3" >
< Button type = "button" variant = "outline" className = "h-11 justify-start px-4 text-base font-normal" asChild >
< a href = { ` ${ projectRepositoryUrl } #readme ` } target = "_blank" rel = "noreferrer" >
< BookOpen className = "h-5 w-5 text-sky-500" / >
项 目 文 档
< / a >
< / Button >
< Button type = "button" variant = "outline" className = "h-11 justify-start px-4 text-base font-normal" asChild >
< a href = { ` ${ projectRepositoryUrl } /blob/main/LICENSE ` } target = "_blank" rel = "noreferrer" >
< Scale className = "h-5 w-5 text-emerald-500" / >
开 源 协 议
< / a >
< / Button >
< / div >
< / AboutRow >
< / CardContent >
< / Card >
)
}
function AboutRow ( { label , children } : { label : string ; children : React.ReactNode } ) {
return (
< div className = "grid gap-2 sm:grid-cols-[4.5rem_minmax(0,1fr)] sm:items-center" >
< div className = "font-medium text-muted-foreground" > { label } : < / div >
< div className = "min-w-0" > { children } < / div >
< / div >
)
}
function TestSMTPDialog ( { disabled } : { disabled? : boolean } ) {
const { toast } = useToast ( )
const [ open , setOpen ] = React . useState ( false )
@@ -2215,8 +2250,8 @@ function sendAuditBadgeVariant(event?: string) {
return "secondary"
}
function Stat ( { icon , label , value , detail } : { icon : React.ReactNode ; label : string ; value : React.ReactNode ; detail : string } ) {
return < Card > < CardContent className = "flex min-h-[84 px] items-center gap-3 p-4 " > < div className = "grid h-9 w-9 shrink-0 place-items-center rounded-md bg-muted text-foreground [&>svg]:h-4 [&>svg]:w-4" > { icon } < / div > < div className = "min-w-0" > < div className = "flex items-baseline gap-2" > < div className = " truncate text-xl font-semibold leading-7 "> { value } < / div > < div className = "truncate text-sm text-muted-foreground " > { label } < / div > < / div > < div className = "mt-0.5 truncate text-xs text-muted-foreground" > { detail } < / div > < / div > < / CardContent > < / Card >
function Stat ( { icon , tone , label , value , detail } : { icon : React.ReactNode ; tone : "primary" | "cyan" | "sky" | "violet" ; label : string ; value : React.ReactNode ; detail : string } ) {
return < Card className = "border-border/80" > < CardContent className = "flex min-h-[88 px] items-center gap-3 p-3 !pt-3 " > < div className = { cn ( "grid h-10 w-10 shrink-0 place-items-center rounded-lg [&>svg]:h-5 [&>svg]:w-5" , tone === "primary" && "bg-primary/5 text-primary" , tone === "cyan" && "bg-cyan-500/10 text-cyan-600 dark:text-cyan-400" , tone === "sky" && "bg-sky-500/10 text-sky-600 dark:text-sky-400" , tone === "violet" && "bg-violet-500/10 text-violet-600 dark:text-violet-400" ) } > { icon } < / div > < div className = "min-w-0" > < div className = "truncate text-xs font-medium " > { label } < / div > < div className = "truncate text-2xl font-semibold leading-7 tabular-nums " > { value } < / div > < div className = "truncate text-[11px] text-muted-foreground" > { detail } < / div > < / div > < / CardContent > < / Card >
}
function InfoBox ( { label , value } : { label : string ; value : React.ReactNode } ) { return < div className = "rounded-lg border p-4" > < div className = "text-xl font-semibold tracking-tight sm:text-2xl" > { value } < / div > < div className = "text-xs text-muted-foreground" > { label } < / div > < / div > }
function Empty ( { text } : { text : string } ) { return < div className = "rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground" > { text } < / div > }
@@ -2232,7 +2267,6 @@ function QueryFailure({ error, onRetry, compact = false }: { error: unknown; onR
< / div >
)
}
function DomainBadgeRow ( { domain } : { domain : Domain } ) { return < div className = "flex items-center justify-between rounded-lg border p-3" > < span className = "font-medium" > { domain . name } < / span > < Badge variant = { domain . dnsStatus === "ok" ? "default" : "secondary" } > { domain . dnsStatus === "ok" ? "正常" : domain . dnsStatus } < / Badge > < / div > }
function invalidateAdmin ( qc : ReturnType < typeof useQueryClient > ) { qc . invalidateQueries ( { queryKey : [ "admin" ] } ) ; qc . invalidateQueries ( { queryKey : [ "mailboxes" ] } ) ; qc . invalidateQueries ( { queryKey : [ "me" ] } ) }
function UserMailboxCell ( { user } : { user : AdminUser } ) {
@@ -2556,27 +2590,77 @@ function DNSPanel({ domain, embedded = false }: { domain?: Domain; embedded?: bo
const check = useMutation ( { mutationFn : ( ) = > api . checkDns ( domain ! . id ) , onSuccess : ( res ) = > { qc . invalidateQueries ( { queryKey : [ "admin" , "domains" ] } ) ; toast ( { title : res.status === "ok" ? "DNS 检测通过" : "DNS 检测未通过" , description : Object.values ( res . checks ) . map ( ( c ) = > c . message ) . join ( "; " ) } ) } , onError : ( error ) = > toast ( { title : "DNS 检测失败" , description : error.message } ) } )
if ( ! domain ) return < Card > < CardContent className = "p-6 text-muted-foreground" > 请 选 择 域 名 < / CardContent > < / Card >
const content = < >
< p className = "mb-3 text-sm text-muted-foreground" > 以 下 为 需 要 在 域 名 DNS 管 理 中 添 加 的 记 录 : < / p >
{ records . isError ? < QueryFailure error = { records . error } onRetry = { ( ) = > { void records . refetch ( ) } } compact / > : < div className = "space-y-3" > { records . data ? . items . map ( ( r ) = > < DNSRecordRow key = { ` ${ r . type } - ${ r . name } ` } record = { r } / > ) } < / div > }
{ check . data && < >
< Separator className = "my-4" / >
< div className = "flex items-center gap-2 text-sm font-medium text-muted-foreground" > < CheckCircle2 className = "h-4 w-4" / > 检 测 结 果 < / div >
< div className = "mt-2 space-y-2" > { Object . entries ( check . data . checks ) . map ( ( [ k , v ] ) = > < DNSCheckRow key = { k } name = { k } check = { v } / > ) } < / div >
< / > } < / >
const checkButton = canCheckDNS ? < Button variant = "outline" size = "sm" onClick = { ( ) = > check . mutate ( ) } disabled = { check . isPending } > < RefreshCcw className = "h-4 w-4" / > 检 测 < / Button > : null
< p className = "mb-3 text-sm text-muted-foreground" > 以 下 内 容 可 直 接 填 写 到 常 见 DNS 控 制 台 , 根 域 名 的 主 机 记 录 使 用 @ 。 < / p >
{ check . isPending && < DNSCheckPending / > }
{ check . isError && < DNSCheckFailure error = { check . error } / > }
{ check . data && ! check . isPending && < DNSCheckSummary checks = { check . data . checks } / > }
{ records . isError ? < QueryFailure error = { records . error } onRetry = { ( ) = > { void records . refetch ( ) } } compact / > : < div className = "grid gap-3 md:auto-rows-fr md:grid-cols-2" > { records . data ? . items . map ( ( r ) = > < DNSRecordRow key = { ` ${ r . type } - ${ r . name } ` } record = { r } domainName = { domain . name } / > ) } < / div > }
< / >
const checkButton = canCheckDNS ? < Button variant = "outline" size = "sm" onClick = { ( ) = > check . mutate ( ) } disabled = { check . isPending } > < RefreshCcw className = { cn ( "h-4 w-4" , check . isPending && "animate-spin" ) } / > { check . isPending ? "检测中" : check . data ? "重新检测" : "检测" } < / Button > : null
const header = < div className = "flex items-center justify-between" > < CardTitle > DNS 记 录 < / CardTitle > { checkButton } < / div >
if ( embedded ) return < div className = "space-y-4 " > < div className = "flex items-center justify-between" > < div className = "font-medium" > DNS 记 录 < / div > { checkButton } < / div > { content } < / div >
if ( embedded ) return < div className = "space-y-3 " > < div className = "flex items-center justify-between" > < div className = "font-medium" > DNS 记 录 < / div > { checkButton } < / div > { content } < / div >
return < Card > < CardHeader > { header } < / CardHeader > < CardContent > { content } < / CardContent > < / Card >
}
const dnsCheckMeta : Record < string , { label : string ; description : string } > = {
mx : { label : "MX" , description : "收信地址" } ,
spf : { label : "SPF" , description : "发信授权" } ,
dkim : { label : "DKIM" , description : "邮件签名" } ,
dmarc : { label : "DMARC" , description : "防伪策略" } ,
}
function DNSCheckPending() {
return < div className = "mb-4 flex items-center gap-3 rounded-lg border border-primary/30 bg-primary/5 p-4" role = "status" aria-live = "polite" >
< div className = "grid h-10 w-10 shrink-0 place-items-center rounded-full bg-primary/10 text-primary" > < Loader2 className = "h-5 w-5 animate-spin" / > < / div >
< div > < div className = "font-semibold text-foreground" > 正 在 检 测 DNS < / div > < p className = "mt-0.5 text-sm text-muted-foreground" > 正 在 查 询 最 新 解 析 结 果 , 请 稍 候 . . . < / p > < / div >
< / div >
}
function DNSCheckFailure ( { error } : { error : Error } ) {
return < div className = "mb-4 flex items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-4" role = "alert" >
< div className = "grid h-10 w-10 shrink-0 place-items-center rounded-full bg-destructive/10 text-destructive" > < AlertCircle className = "h-5 w-5" / > < / div >
< div className = "min-w-0" > < div className = "font-semibold text-destructive" > DNS 检 测 失 败 < / div > < p className = "mt-0.5 break-words text-sm text-muted-foreground" > { error . message || "暂时无法查询 DNS,请稍后重新检测。" } < / p > < / div >
< / div >
}
function DNSCheckSummary ( { checks } : { checks : Record < string , { ok : boolean ; message : string ; found ? : string [ ] } > } ) {
const entries = Object . entries ( checks ) . sort ( ( [ left ] , [ right ] ) = > {
const order = [ "mx" , "spf" , "dkim" , "dmarc" ]
return order . indexOf ( left ) - order . indexOf ( right )
} )
const passed = entries . filter ( ( [ , item ] ) = > item . ok ) . length
const allPassed = entries . length > 0 && passed === entries . length
return < section className = { cn ( "mb-4 overflow-hidden rounded-lg border p-4" , allPassed ? "border-emerald-500/40 bg-emerald-500/[0.07]" : "border-destructive/40 bg-destructive/5" ) } role = { allPassed ? "status" : "alert" } aria-live = "polite" aria-label = "DNS 检测结果" >
< div className = "flex items-start gap-3" >
< div className = { cn ( "grid h-10 w-10 shrink-0 place-items-center rounded-full" , allPassed ? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400" : "bg-destructive/10 text-destructive" ) } >
{ allPassed ? < CheckCircle2 className = "h-5 w-5" / > : < AlertCircle className = "h-5 w-5" / > }
< / div >
< div className = "min-w-0 flex-1" >
< div className = "flex flex-wrap items-center justify-between gap-2" >
< h3 className = { cn ( "text-base font-semibold" , allPassed ? "text-emerald-800 dark:text-emerald-300" : "text-destructive" ) } > { allPassed ? "DNS 配置全部通过" : "DNS 配置需要处理" } < / h3 >
< Badge variant = "outline" className = { cn ( "shrink-0 bg-background/70" , allPassed ? "border-emerald-500/40 text-emerald-800 dark:text-emerald-300" : "border-destructive/40 text-destructive" ) } > { passed } / { entries . length } 项 通 过 < / Badge >
< / div >
< p className = "mt-1 text-sm text-muted-foreground" > { allPassed ? "所有邮件相关记录均已正确解析,可以正常使用。" : "请处理下面标红的项目,修改 DNS 后再重新检测。" } < / p >
< / div >
< / div >
< div className = "mt-3 grid gap-x-4 sm:grid-cols-2 md:grid-cols-4" >
{ entries . map ( ( [ name , item ] ) = > < DNSCheckRow key = { name } name = { name } check = { item } / > ) }
< / div >
< / section >
}
function DNSCheckRow ( { name , check } : { name : string ; check : { ok : boolean ; message : string ; found? : string [ ] } } ) {
const visibleRecords = check . found ? . filter ( Boolean ) ? ? [ ]
return < div className = "space-y-1 text-sm" >
< div className = "flex items-start gap-2" >
< CheckCircle2 className = { ` mt-0.5 h-4 w-4 shrink-0 ${ check . ok ? "text-green-600" : "text-destructive" } ` } / >
< div className = "min-w-0" > < span className = "font-medium" > { name . toUpperCase ( ) } : < / span > { check . message } < / div >
const meta = dnsCheckMeta [ name . toLowerCase ( ) ] || { label : name.toUpperCase ( ) , description : "DNS 记录" }
return < div className = { cn ( "space-y-1 border-t py-2.5 text-sm" , check . ok ? "border-emerald-500/20" : "border-destructive/20" ) } >
< div className = "flex items-start gap-2.5" >
{ check . ok ? < CheckCircle2 className = "mt-0.5 h-4 w-4 shrink-0 text-emerald-600" / > : < AlertCircle className = "mt-0.5 h-4 w-4 shrink-0 text-destructive" / > }
< div className = "min-w-0 flex-1" >
< div className = "shrink-0 font-medium text-foreground" > { meta . label } < span className = "ml-1 font-normal text-muted-foreground" > ( { meta . description } ) < / span > < / div >
< div className = { cn ( "mt-0.5" , check . ok ? "text-muted-foreground" : "font-medium text-destructive" ) } > { check . message } < / div >
< / div >
< / div >
{ ! check . ok && visibleRecords . length > 0 && < div className = "ml-6 rounded-md bg-mute d/6 0 px-3 py-2 font-mono text-xs text-muted-foreground" >
{ ! check . ok && visibleRecords . length > 0 && < div className = "ml-6 rounded-md bg-backgroun d/7 0 px-3 py-2 font-mono text-xs text-muted-foreground" >
< div className = "mb-1 font-sans text-foreground" > 当 前 解 析 < / div >
< div className = "space-y-1" > { visibleRecords . map ( ( record , index ) = > < div key = { ` ${ name } - ${ index } ` } className = "break-all" > { record } < / div > ) } < / div >
< / div > }
@@ -2587,36 +2671,61 @@ function dnsDescription(record: DNSRecord): string {
if ( record . type === "TXT" && record . name . startsWith ( "_dmarc" ) ) return "声明域名的 DMARC 策略(如何处理未通过 SPF/DKIM 验证的邮件)。"
if ( record . type === "TXT" && record . value . includes ( "DKIM1" ) ) return "DKIM 公钥。收件服务器用此密钥验证邮件是否由你发出。"
if ( record . type === "TXT" && record . value . includes ( "spf1" ) ) return "声明哪些服务器有权使用你的域名发件,防止伪造。"
if ( record . type === "MX" ) return ` 确保 ${ record . name } 的 A 记录已指向你的服务器 IP,邮件才能到达。 `
if ( record . type === "MX" ) {
const mx = mxRecordParts ( record . value )
return ` 邮件由 ${ mx . target } 接收,请确保它的 A 记录指向服务器 IP。优先级 ${ mx . priority } ,数值越小越优先。 `
}
return ""
}
function DNSRecordRow ( { record } : { record : DNSRecord } ) {
function mxRecordParts ( value : string ) {
const [ rawPriority = "10" , . . . rawTarget ] = value . trim ( ) . split ( /\s+/ )
const priority = /^\d+$/ . test ( rawPriority ) ? rawPriority : "10"
const target = ( rawTarget . length > 0 ? rawTarget . join ( " " ) : value ) . replace ( /\.$/ , "" )
return { priority , target }
}
function dnsHostForProvider ( recordName : string , domainName : string ) {
const name = recordName . replace ( /\.$/ , "" )
const domain = domainName . replace ( /\.$/ , "" )
if ( name . toLowerCase ( ) === domain . toLowerCase ( ) ) return "@"
const suffix = ` . ${ domain } `
if ( name . toLowerCase ( ) . endsWith ( suffix . toLowerCase ( ) ) ) return name . slice ( 0 , - suffix . length )
return name
}
function DNSRecordRow ( { record , domainName } : { record : DNSRecord ; domainName : string } ) {
const { toast } = useToast ( )
const desc = dnsDescription ( record )
const longValue = record . value . length > 180
const mx = record . type === "MX" ? mxRecordParts ( record . value ) : null
const displayHost = dnsHostForProvider ( record . name , domainName )
const displayValue = mx ? . target || record . value
const [ valueExpanded , setValueExpanded ] = React . useState ( false )
async function copyField ( label : string , value : string ) {
await navigator . clipboard . writeText ( value )
toast ( { title : ` ${ label } 已复制 ` } )
}
return < div className = "rounded-lg border bg-card p-3" >
< div className = "mb-2 flex items-center" >
return < div className = "flex h-full flex-col rounded-lg border bg-card p-3" >
< div className = "mb-2 flex min-h-7 items-center justify-between gap-2 " >
< Badge variant = "outline" className = "font-mono" > { record . type } < / Badge >
{ longValue && < Button type = "button" size = "sm" variant = "ghost" className = "h-7 gap-1 px-2 text-xs text-muted-foreground" aria-expanded = { valueExpanded } onClick = { ( ) = > setValueExpanded ( ( current ) = > ! current ) } > < ChevronDown className = { cn ( "h-3.5 w-3.5 transition-transform" , valueExpanded && "rotate-180" ) } / > { valueExpanded ? "收起完整内容" : "查看完整内容" } < / Button > }
< / div >
{ desc && < p className = "mb-2 text-xs text-muted-foreground" > { desc } < / p > }
< div className = "space-y-1 font-mono text-xs text-muted-foreground" >
{ desc && < p className = "mb-2 line-clamp-2 min-h-9 text-xs leading-[1.125rem] text-muted-foreground" > { desc } < / p > }
< div className = "mt-auto space-y-1 font-mono text-xs text-muted-foreground" >
< div className = "grid grid-cols-[4.5rem_minmax(0,1fr)_1.75rem] items-start gap-2" >
< span className = "pt-1 text-foreground" > 主 机 记 录 < / span >
< code className = "break-all pt-1 font-mono" > { record . name } < / code >
< Button type = "button" size = "icon" variant = "ghost" className = "h-7 w-7" aria-label = "复制主机记录" title = "复制主机记录" onClick = { ( ) = > copyField ( "主机记录" , record . name ) } > < Copy className = "h-3.5 w-3.5" / > < / Button >
< code className = "break-all pt-1 font-mono font-medium text-foreground " > { displayHost } < / code >
< Button type = "button" size = "icon" variant = "ghost" className = "h-7 w-7" aria-label = "复制主机记录" title = "复制主机记录" onClick = { ( ) = > copyField ( "主机记录" , displayHost ) } > < Copy className = "h-3.5 w-3.5" / > < / Button >
< / div >
< div className = "grid grid-cols-[4.5rem_minmax(0,1fr)_1.75rem] items-start gap-2" >
< span className = "pt-1 text-foreground" > 记 录 值 < / span >
< code className = "break-all pt-1 font-mono" > { record . value } < / code >
< Button type = "button" size = "icon" variant = "ghost" className = "h-7 w-7" aria-label = "复制记录值" title = "复制记录值" onClick = { ( ) = > copyField ( "记录值" , record . value ) } > < Copy className = "h-3.5 w-3.5" / > < / Button >
< code className = { cn ( "break-all pt-1 font-mono" , longValue && ! valueExpanded && "line-clamp-3" ) } > { displayValue } < / code >
< Button type = "button" size = "icon" variant = "ghost" className = "h-7 w-7" aria-label = "复制记录值" title = "复制记录值" onClick = { ( ) = > copyField ( "记录值" , displayValue ) } > < Copy className = "h-3.5 w-3.5" / > < / Button >
< / div >
< div className = "grid grid-cols-[4.5rem_minmax(0,1fr)] gap-2 " >
< span className = "text-foreground" > TTL < / span >
< code className = "font-mono" > { record . ttl } 秒 < / code >
< div className = "grid grid-cols-2 gap-4 " >
{ mx && < div className = "grid grid-cols-[4.5rem_minmax(0,1fr)] gap-2" > < span className = "text-foreground" > 优 先 级 < / span > < code className = "font-mono font-medium text-foreground" > { mx . priority } < / code > < / div > }
< div className = "grid grid-cols-[4.5rem_minmax(0,1fr)] gap-2" > < span className = "text-foreground" > TTL < / span > < code className = "font-mono" > { record . ttl } 秒 < / code > < / div >
< / div >
< / div >
< / div >