@@ -17,6 +17,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator"
import { Separator } from "@/components/ui/separator"
import { Select , SelectContent , SelectItem , SelectTrigger , SelectValue } from "@/components/ui/select"
import { Select , SelectContent , SelectItem , SelectTrigger , SelectValue } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { Switch } from "@/components/ui/switch"
import { Skeleton } from "@/components/ui/skeleton"
import { Table , TableBody , TableCell , TableHead , TableHeader , TableRow } from "@/components/ui/table"
import { Table , TableBody , TableCell , TableHead , TableHeader , TableRow } from "@/components/ui/table"
import { Textarea } from "@/components/ui/textarea"
import { Textarea } from "@/components/ui/textarea"
import { ConfirmDialog } from "@/components/confirm-dialog"
import { ConfirmDialog } from "@/components/confirm-dialog"
@@ -86,11 +87,13 @@ export function AdminPage() {
const canMessagesView = hasPermission ( user , "admin.messages.view" )
const canMessagesView = hasPermission ( user , "admin.messages.view" )
const canSettingsView = hasPermission ( user , "admin.settings.view" )
const canSettingsView = hasPermission ( user , "admin.settings.view" )
const canTemplatesView = hasPermission ( user , "admin.templates.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 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 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 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 domains = useQuery ( { queryKey : [ "admin" , "domains" ] , queryFn : api.domains , enabled : ! ! user && canLoadDomains } )
const mailboxes = useQuery ( { queryKey : [ "admin" , "mailboxes" ] , queryFn : api.mailboxes , enabled : ! ! user && ( canMailboxesView || canMessagesView || canSettingsView ) } )
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 aliases = useQuery ( { queryKey : [ "admin" , "aliases" ] , queryFn : api.aliases , enabled : ! ! user && canAliasesView } )
const settings = useQuery ( { queryKey : [ "admin" , "settings" ] , queryFn : api.systemSettings , enabled : ! ! user && canSettingsView } )
const settings = useQuery ( { queryKey : [ "admin" , "settings" ] , queryFn : api.systemSettings , enabled : ! ! user && canSettingsView } )
const [ params , setParams ] = useSearchParams ( )
const [ params , setParams ] = useSearchParams ( )
@@ -104,14 +107,18 @@ export function AdminPage() {
const visibleSections = sectionKeys . filter ( ( key ) = > hasAnyPermission ( user , sectionPermissions [ key ] ) && ( key !== "backups" || user ? . role === "admin" ) )
const visibleSections = sectionKeys . filter ( ( key ) = > hasAnyPermission ( user , sectionPermissions [ key ] ) && ( key !== "backups" || user ? . role === "admin" ) )
const rawSection = params . get ( "section" ) as Section | null
const rawSection = params . get ( "section" ) as Section | null
const section : Section = rawSection && visibleSections . includes ( rawSection ) ? rawSection : visibleSections [ 0 ] || "overview"
const section : Section = rawSection && visibleSections . includes ( rawSection ) ? rawSection : visibleSections [ 0 ] || "overview"
const sectionQuery = section === "overview" ? overview
const sectionQueries = section === "overview" ? [ overview , . . . ( canLoadDomains ? [ domains ] : [ ] ) , . . . ( canSettingsView ? [ settings ] : [ ] ) ]
: section === "users" ? users
: section === "users" ? [ users , permissionGroups , domains ]
: section === "permissionGroups" ? permissionGroups
: section === "permissionGroups" ? [ permissionGroups ]
: section === "domains" ? domains
: section === "domains" ? [ domains ]
: section === "mailboxes" ? mailboxes
: section === "mailboxes" ? [ mailboxes , users , domains ]
: section === "aliases" ? aliases
: section === "aliases" ? [ aliases , domains ]
: section === "settings" ? settings
: section === "messages" || section === "sendAudit" ? [ mailboxes ]
: null
: 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() {
async function refreshAdminPage() {
if ( refreshing ) return
if ( refreshing ) return
@@ -130,17 +137,18 @@ export function AdminPage() {
}
}
}
}
const overviewChecklist = setupChecklist ( overview . data , domainItems , settings . data ) . filter ( ( item ) = > visibleSections . includes ( item . section ) )
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 } )
const changeSection = ( next : Section ) = > setParams ( next === "overview" ? { } : { section : next } )
return (
return (
< ScrollArea className = "h-[calc(100svh-3rem)] md:h-svh" >
< ScrollArea className = "h-[calc(100svh-3rem)] md:h-svh" >
< main className = "admin-page mx-auto w-full max-w-[1320px] px-3 pb-8 pt-3 sm:px-4 sm:pt-4" >
< main className = "admin-page mx-auto w-full max-w-[1320px] px-3 pb-8 pt-3 sm:px-4 sm:pt-4" >
< AdminPageHeader section = { section } refreshing = { refreshing } onRefresh = { refreshAdminPage } checklist = { section === "overview" ? overviewChecklist : undefined } onSectionChange = { changeSection } / >
< 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 && (
{ sectionReady && section === "overview" && canOverview && (
< section className = "mb-3 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4" >
< 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 = { < 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 = { < Globe2 / > } tone = "cyan" label = "邮件域名" value = { overview . data ? . domains || 0 } detail = { domainItems . some ( ( domain ) = > domain . dnsStatus === "ok" ) ? ` ${ domainItems . filter ( ( domain ) = > domain . dnsStatus === "ok" ) . length } 个 DNS 正常 ` : "待检测" } / >
@@ -149,21 +157,32 @@ export function AdminPage() {
< / section >
< / section >
) }
) }
{ section === "overview" && < OverviewSection overview = { overview . data } domains = { domainItems } settings = { settings . data } visibleSections = { visibleSections } onSectionChange = { changeSection } / > }
{ sectionReady && section === "overview" && < OverviewSection overview = { overview . data } domains = { domainItems } domainsAvailable = { canLoadDomains } settings = { settings . data } settingsAvailable = { canSettingsView } visibleSections = { visibleSections } onSectionChange = { changeSection } / > }
{ section === "users" && < UsersSection users = { userItems } permissionGroups = { assignablePermissionGroups } domains = { domainItems } / > }
{ sectionReady && section === "users" && < UsersSection users = { userItems } permissionGroups = { assignablePermissionGroups } domains = { domainItems } / > }
{ section === "permissionGroups" && < PermissionGroupsSection groups = { permissionGroups . data ? . items || [ ] } catalog = { permissionGroups . data ? . catalog || [ ] } / > }
{ sectionReady && section === "permissionGroups" && < PermissionGroupsSection groups = { permissionGroups . data ? . items || [ ] } catalog = { permissionGroups . data ? . catalog || [ ] } / > }
{ section === "domains" && < DomainsSection domains = { domainItems } / > }
{ sectionReady && section === "domains" && < DomainsSection domains = { domainItems } / > }
{ section === "mailboxes" && < MailboxesSection mailboxes = { mailboxItems } users = { userItems } domains = { domainItems } / > }
{ sectionReady && section === "mailboxes" && < MailboxesSection mailboxes = { mailboxItems } users = { userItems } domains = { domainItems } / > }
{ section === "aliases" && < AliasesSection aliases = { aliasItems } domains = { domainItems } / > }
{ sectionReady && section === "aliases" && < AliasesSection aliases = { aliasItems } domains = { domainItems } / > }
{ section === "messages" && < AdminMessagesSection mailboxes = { mailboxItems } systemAdmin = { user ? . role === "admin" } / > }
{ sectionReady && section === "messages" && < AdminMessagesSection mailboxes = { mailboxItems } systemAdmin = { user ? . role === "admin" } / > }
{ section === "sendAudit" && < AdminSendAuditSection mailboxes = { mailboxItems } / > }
{ sectionReady && section === "sendAudit" && < AdminSendAuditSection mailboxes = { mailboxItems } / > }
{ section === "backups" && < BackupsSection / > }
{ sectionReady && section === "backups" && < BackupsSection / > }
{ section === "settings" && < SystemSettingsSection settings = { settings . data } domains = { domainItems } mailboxes = { mailboxItems } initialTab = { params . get ( "settingsTab" ) } / > }
{ sectionReady && section === "settings" && < SystemSettingsSection settings = { settings . data } domains = { domainItems } mailboxes = { mailboxItems } initialTab = { params . get ( "settingsTab" ) } / > }
< / main >
< / main >
< / ScrollArea >
< / ScrollArea >
)
)
}
}
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 ]
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 } ) {
function AdminPageHeader ( { section , refreshing , onRefresh , checklist , onSectionChange } : { section : Section ; refreshing : boolean ; onRefresh : ( ) = > void ; checklist? : SetupChecklistItem [ ] ; onSectionChange : ( section : Section ) = > void } ) {
@@ -215,7 +234,7 @@ function SetupChecklistDialog({ checklist, onSectionChange }: { checklist: Setup
)
)
}
}
function OverviewSection ( { overview , domains , settings , visibleSections , onSectionChange } : { overview? : AdminOverview ; domains : Domain [ ] ; settings? : SystemSettings ; visibleSections : Section [ ] ; onSectionChange : ( section : Section ) = > void } ) {
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 { toast } = useToast ( )
const dnsOK = domains . length > 0 && domains . every ( ( domain ) = > domain . dnsStatus === "ok" )
const dnsOK = domains . length > 0 && domains . every ( ( domain ) = > domain . dnsStatus === "ok" )
const dnsWarning = domains . length > 0 && domains . some ( ( domain ) = > domain . dnsStatus === "ok" )
const dnsWarning = domains . length > 0 && domains . some ( ( domain ) = > domain . dnsStatus === "ok" )
@@ -242,22 +261,22 @@ function OverviewSection({ overview, domains, settings, visibleSections, onSecti
< DashboardGroupTitle > 系 统 健 康 < / DashboardGroupTitle >
< DashboardGroupTitle > 系 统 健 康 < / DashboardGroupTitle >
< div className = "grid grid-cols-3 gap-2" >
< div className = "grid grid-cols-3 gap-2" >
< DashboardStatusItem label = "系统" status = { < LightStatus state = "success" label = "运行中" / > } / >
< DashboardStatusItem label = "系统" status = { < LightStatus state = "success" label = "运行中" / > } / >
< DashboardStatusItem label = "DNS" status = { < LightStatus state = { dnsOK ? "success" : dnsWarning ? "warning" : "muted" } label = { dnsOK ? "正常" : dnsWarning ? "部分正常" : domains . length ? "未检测" : "未配置" } / > } / >
< 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 = { < LightStatus state = { settings ? . smtpHost ? "success" : "warning" } label = { settings ? . smtpHost ? "已配置" : "未配置" } / > } / >
< DashboardStatusItem label = "SMTP" status = { ! settingsAvailable ? < LightStatus state = "muted" label = "不可查看" / > : < LightStatus state = { settings ? . smtpHost ? "success" : "warning" } label = { settings ? . smtpHost ? "已配置" : "未配置" } / > } / >
< / div >
< / div >
< / div >
< / div >
< div className = "rounded-md border border-border/80 px-3 py-2" >
< div className = "rounded-md border border-border/80 px-3 py-2" >
< DashboardGroupTitle > 服 务 信 息 < / DashboardGroupTitle >
< DashboardGroupTitle > 服 务 信 息 < / DashboardGroupTitle >
< div className = "grid grid-cols-2 gap-3" >
< div className = "grid grid-cols-2 gap-3" >
< DashboardInfoItem label = "公网地址" value = { settings ? . publicBaseUrl || "-" } onCopy = { settings ? . publicBaseUrl ? ( ) = > copyOverviewValue ( settings . publicBaseUrl , "公网地址" , toast ) : undefined } / >
< DashboardInfoItem label = "公网地址" value = { ! settingsAvailable ? "不可查看" : settings ? . publicBaseUrl || "-" } onCopy = { settings ? . publicBaseUrl ? ( ) = > copyOverviewValue ( settings . publicBaseUrl , "公网地址" , toast ) : undefined } / >
< DashboardInfoItem label = "SMTP" value = { settings ? . smtpHost ? ` ${ settings . smtpHost } : ${ settings . smtpPort } ` : "未配置" } onCopy = { settings ? . smtpHost ? ( ) = > copyOverviewValue ( ` ${ settings . smtpHost } : ${ settings . smtpPort } ` , "SMTP 地址" , 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 >
< / div >
< / div >
< / div >
< div className = "rounded-md border border-border/80 px-3 py-2" >
< div className = "rounded-md border border-border/80 px-3 py-2" >
< DashboardGroupTitle > 功 能 状 态 < / DashboardGroupTitle >
< DashboardGroupTitle > 功 能 状 态 < / DashboardGroupTitle >
< div className = "grid grid-cols-2 gap-3" >
< div className = "grid grid-cols-2 gap-3" >
< DashboardStatusItem label = "注册" status = { < LightStatus state = { settings ? . openRegistration ? "success" : "muted" } label = { settings ? . openRegistration ? "已开放" : "关闭" } / > } / >
< DashboardStatusItem label = "注册" status = { ! settingsAvailable ? < LightStatus state = "muted" label = "不可查看" / > : < LightStatus state = { settings ? . openRegistration ? "success" : "muted" } label = { settings ? . openRegistration ? "已开放" : "关闭" } / > } / >
< DashboardStatusItem label = "自助申请邮箱" status = { < LightStatus state = { settings ? . userMailboxApplyEnabled ? "success" : "muted" } label = { settings ? . userMailboxApplyEnabled ? "已启用" : "关闭" } / > } / >
< DashboardStatusItem label = "自助申请邮箱" status = { ! settingsAvailable ? < LightStatus state = "muted" label = "不可查看" / > : < LightStatus state = { settings ? . userMailboxApplyEnabled ? "success" : "muted" } label = { settings ? . userMailboxApplyEnabled ? "已启用" : "关闭" } / > } / >
< / div >
< / div >
< / div >
< / div >
< / CardContent >
< / CardContent >
@@ -266,7 +285,7 @@ function OverviewSection({ overview, domains, settings, visibleSections, onSecti
< Card className = "border-border/80" >
< 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 >
< 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" >
< CardContent className = "px-4 pb-3" >
{ domains . length > 0 ? < div className = "overflow-hidden rounded-md border border-border/80" >
{ ! 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 = "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 ) = > {
< div className = "divide-y" > { domains . slice ( 0 , 5 ) . map ( ( domain ) = > {
const dnsDisplay = dnsStatusDisplay ( domain . dnsStatus )
const dnsDisplay = dnsStatusDisplay ( domain . dnsStatus )
@@ -528,6 +547,8 @@ function BackupsSection() {
; ( groups [ transfer . name ] || = [ ] ) . push ( transfer )
; ( groups [ transfer . name ] || = [ ] ) . push ( transfer )
return groups
return groups
} , { } ) ) . sort ( ( left , right ) = > Date . parse ( right [ 0 ] ? . startedAt || "" ) - Date . parse ( left [ 0 ] ? . startedAt || "" ) )
} , { } ) ) . 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 (
return (
< div className = "space-y-3" >
< div className = "space-y-3" >
< div className = "grid gap-3 xl:grid-cols-[minmax(0,1.35fr)_minmax(300px,.65fr)]" >
< div className = "grid gap-3 xl:grid-cols-[minmax(0,1.35fr)_minmax(300px,.65fr)]" >
@@ -1107,7 +1128,7 @@ function DomainDNSDialog({ domain }: { domain: Domain }) {
< DialogTrigger asChild >
< DialogTrigger asChild >
< Button variant = "outline" size = "sm" > DNS < / Button >
< Button variant = "outline" size = "sm" > DNS < / Button >
< / DialogTrigger >
< / 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 >
< DialogHeader > < DialogTitle > { domain . name } DNS < / DialogTitle > < / DialogHeader >
< DNSPanel domain = { domain } embedded / >
< DNSPanel domain = { domain } embedded / >
< / DialogContent >
< / DialogContent >
@@ -1542,23 +1563,23 @@ function SystemSettingsSection({ settings, domains, mailboxes, initialTab }: { s
const requestedTab = initialTab as SettingsTab | undefined
const requestedTab = initialTab as SettingsTab | undefined
const [ settingsTab , setSettingsTab ] = React . useState < SettingsTab > ( ( ) = > requestedTab && [ "base" , "smtp" , "storage" , "mail" , "notifications" , "externalImap" , "templates" , "security" ] . 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 maildirHealth = useQuery ( { queryKey : [ "admin" , "maildir-sync" , "health" ] , queryFn : api.maildirSyncHealth , enabled : canSettingsView && settingsTab === "storage" } )
const [ smtpRequireTls , setSmtpRequireTls ] = React . useState ( false )
const [ smtpRequireTls , setSmtpRequireTls ] = React . useState ( ( ) = > settings ? . smtpRequireTls ? ? false )
const [ allowInsecureHttp , setAllowInsecureHttp ] = React . useState ( true )
const [ allowInsecureHttp , setAllowInsecureHttp ] = React . useState ( ( ) = > settings ? . allowInsecureHttp ? ? true )
const [ openRegistration , setOpenRegistration ] = React . useState ( false )
const [ openRegistration , setOpenRegistration ] = React . useState ( ( ) = > settings ? . openRegistration ? ? false )
const [ twoFactorEnabled , setTwoFactorEnabled ] = React . useState ( false )
const [ twoFactorEnabled , setTwoFactorEnabled ] = React . useState ( ( ) = > settings ? . twoFactorEnabled ? ? false )
const [ turnstileEnabled , setTurnstileEnabled ] = React . useState ( false )
const [ turnstileEnabled , setTurnstileEnabled ] = React . useState ( ( ) = > settings ? . turnstileEnabled ? ? false )
const [ catchAllEnabled , setCatchAllEnabled ] = React . useState ( false )
const [ catchAllEnabled , setCatchAllEnabled ] = React . useState ( ( ) = > settings ? . catchAllEnabled ? ? false )
const [ mailAutoRefresh , setMailAutoRefresh ] = React . useState ( true )
const [ mailAutoRefresh , setMailAutoRefresh ] = React . useState ( ( ) = > settings ? . mailAutoRefresh ? ? true )
const [ userMailboxApplyEnabled , setUserMailboxApplyEnabled ] = React . useState ( false )
const [ userMailboxApplyEnabled , setUserMailboxApplyEnabled ] = React . useState ( ( ) = > settings ? . userMailboxApplyEnabled ? ? false )
const [ userMailboxDomainIds , setUserMailboxDomainIds ] = React . useState < string [ ] > ( [ ] )
const [ userMailboxDomainIds , setUserMailboxDomainIds ] = React . useState < string [ ] > ( ( ) = > settings ? . userMailboxDomainIds || [ ] )
const [ externalImapEnabled , setExternalImapEnabled ] = React . useState ( false )
const [ externalImapEnabled , setExternalImapEnabled ] = React . useState ( ( ) = > settings ? . externalImapEnabled ? ? false )
const [ externalImapAllowPrivateHosts , setExternalImapAllowPrivateHosts ] = React . useState ( false )
const [ externalImapAllowPrivateHosts , setExternalImapAllowPrivateHosts ] = React . useState ( ( ) = > settings ? . externalImapAllowPrivateHosts ? ? false )
const [ telegramMailEnabled , setTelegramMailEnabled ] = React . useState ( false )
const [ telegramMailEnabled , setTelegramMailEnabled ] = React . useState ( ( ) = > settings ? . telegramMailEnabled ? ? false )
const [ telegramBotToken , setTelegramBotToken ] = React . useState ( "" )
const [ telegramBotToken , setTelegramBotToken ] = React . useState ( "" )
const [ telegramPrivateChatId , setTelegramPrivateChatId ] = React . useState ( "" )
const [ telegramPrivateChatId , setTelegramPrivateChatId ] = React . useState ( ( ) = > settings ? . telegramPrivateChatId || "" )
const [ telegramBodyMode , setTelegramBodyMode ] = React . useState < "summary" | "full" > ( "summary" )
const [ telegramBodyMode , setTelegramBodyMode ] = React . useState < "summary" | "full" > ( ( ) = > settings ? . telegramBodyMode === "full" ? "full" : "summary" )
const [ telegramMailboxIds , setTelegramMailboxIds ] = React . useState < string [ ] > ( [ ] )
const [ telegramMailboxIds , setTelegramMailboxIds ] = React . useState < string [ ] > ( ( ) = > settings ? . telegramMailboxIds || [ ] )
const [ telegramIncludeUnregistered , setTelegramIncludeUnregistered ] = React . useState ( false )
const [ telegramIncludeUnregistered , setTelegramIncludeUnregistered ] = React . useState ( ( ) = > settings ? . telegramIncludeUnregistered ? ? false )
const [ telegramPairing , setTelegramPairing ] = React . useState < TelegramPairing | null > ( null )
const [ telegramPairing , setTelegramPairing ] = React . useState < TelegramPairing | null > ( null )
React . useEffect ( ( ) = > {
React . useEffect ( ( ) = > {
if ( ! settings ) return
if ( ! settings ) return
@@ -2569,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 } ) } )
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 >
if ( ! domain ) return < Card > < CardContent className = "p-6 text-muted-foreground" > 请 选 择 域 名 < / CardContent > < / Card >
const content = < >
const content = < >
< p className = "mb-3 text-sm text-muted-foreground" > 以 下 为 需 要 在 域 名 DNS 管 理 中 添 加 的 记 录 : < / p >
< 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 . isPending && < DNSCheckPending / > }
{ check . data && < >
{ check . isError && < DNSCheckFailure error = { check . error } / > }
< Separator className = "my-4" / >
{ check . data && ! check . isPending && < DNSCheckSummary checks = { check . data . checks } / > }
< div className = "flex items-center gap-2 text-sm font-medium text-muted-foreground" > < CheckCircle2 className = "h-4 w-4" / > 检 测 结 果 < / div >
{ 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 > }
< 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 = { cn ( "h-4 w-4" , check . isPending && "animate-spin" ) } / > { check . isPending ? "检测中" : check . data ? "重新检测" : "检测" } < / Button > : null
const checkButton = canCheckDNS ? < Button variant = "outline" size = "sm" onClick = { ( ) = > check . mutate ( ) } disabled = { check . isPending } > < RefreshCcw className = "h-4 w-4" / > 检 测 < / Button > : null
const header = < div className = "flex items-center justify-between" > < CardTitle > DNS 记 录 < / CardTitle > { checkButton } < / div >
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 >
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 [ ] } } ) {
function DNSCheckRow ( { name , check } : { name : string ; check : { ok : boolean ; message : string ; found? : string [ ] } } ) {
const visibleRecords = check . found ? . filter ( Boolean ) ? ? [ ]
const visibleRecords = check . found ? . filter ( Boolean ) ? ? [ ]
return < div className = "space-y-1 text-sm" >
const meta = dnsCheckMeta [ name . toLowerCase ( ) ] || { label : name.toUpperCase ( ) , description : "DNS 记录" }
< div className = "flex items-start gap-2" >
return < div className = { cn ( "space-y-1 border-t py-2.5 text-sm" , check . ok ? "border-emerald-500/20" : "border-destructive/20" ) } >
< CheckCircle2 className = { ` mt-0.5 h-4 w-4 shrink-0 ${ check . ok ? "text-green-600" : "text-destructive" } ` } / >
< div className = "flex items-start gap-2.5" >
< div className = "min-w-0" > < span className = "font-medium" > { name . toUpperCase ( ) } : < / span > { check . message } < / div >
{ 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-muted/60 px-3 py-2 font-mono text-xs text-muted-foreground" >
< / div >
{ ! check . ok && visibleRecords . length > 0 && < div className = "ml-6 rounded-md bg-background/70 px-3 py-2 font-mono text-xs text-muted-foreground" >
< div className = "mb-1 font-sans text-foreground" > 当 前 解 析 < / div >
< 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 className = "space-y-1" > { visibleRecords . map ( ( record , index ) = > < div key = { ` ${ name } - ${ index } ` } className = "break-all" > { record } < / div > ) } < / div >
< / div > }
< / div > }
@@ -2600,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 . name . startsWith ( "_dmarc" ) ) return "声明域名的 DMARC 策略(如何处理未通过 SPF/DKIM 验证的邮件)。"
if ( record . type === "TXT" && record . value . includes ( "DKIM1" ) ) return "DKIM 公钥。收件服务器用此密钥验证邮件是否由你发出。"
if ( record . type === "TXT" && record . value . includes ( "DKIM1" ) ) return "DKIM 公钥。收件服务器用此密钥验证邮件是否由你发出。"
if ( record . type === "TXT" && record . value . includes ( "spf1" ) ) return "声明哪些服务器有权使用你的域名发件,防止伪造。"
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 ""
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 { toast } = useToast ( )
const desc = dnsDescription ( record )
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 ) {
async function copyField ( label : string , value : string ) {
await navigator . clipboard . writeText ( value )
await navigator . clipboard . writeText ( value )
toast ( { title : ` ${ label } 已复制 ` } )
toast ( { title : ` ${ label } 已复制 ` } )
}
}
return < div className = "rounded-lg border bg-card p-3" >
return < div className = "flex h-full flex-col rounded-lg border bg-card p-3" >
< div className = "mb-2 flex items-center" >
< div className = "mb-2 flex min-h-7 items-center justify-between gap-2 " >
< Badge variant = "outline" className = "font-mono" > { record . type } < / Badge >
< 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 >
< / div >
{ desc && < p className = "mb-2 text-xs text-muted-foreground" > { desc } < / p > }
{ desc && < p className = "mb-2 line-clamp-2 min-h-9 text-xs leading-[1.125rem] text-muted-foreground" > { desc } < / p > }
< div className = "space-y-1 font-mono text-xs text-muted-foreground" >
< 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" >
< div className = "grid grid-cols-[4.5rem_minmax(0,1fr)_1.75rem] items-start gap-2" >
< span className = "pt-1 text-foreground" > 主 机 记 录 < / span >
< span className = "pt-1 text-foreground" > 主 机 记 录 < / span >
< code className = "break-all pt-1 font-mono" > { record . name } < / code >
< 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 ( "主机记录" , record . name ) } > < Copy className = "h-3.5 w-3.5" / > < / Button >
< 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 >
< div className = "grid grid-cols-[4.5rem_minmax(0,1fr)_1.75rem] items-start gap-2" >
< div className = "grid grid-cols-[4.5rem_minmax(0,1fr)_1.75rem] items-start gap-2" >
< span className = "pt-1 text-foreground" > 记 录 值 < / span >
< span className = "pt-1 text-foreground" > 记 录 值 < / span >
< code className = "break-all pt-1 font-mono" > { record . value } < / code >
< 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 ( "记录值" , record . value ) } > < Copy className = "h-3.5 w-3.5" / > < / Button >
< 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 >
< div className = "grid grid-cols-[4.5rem_minmax(0,1fr)] gap-2 " >
< div className = "grid grid-cols-2 gap-4 " >
< span className = "text-foreground" > TTL < / span >
{ 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 > }
< code className = "font-mono" > { record . ttl } 秒 < / code >
< 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 >
< / div >
< / div >
< / div >