Compare commits

...

3 Commits

Author SHA1 Message Date
zxyszx 75c399e178 refine DNS record guidance
AI PR Review / AI PR Review (pull_request) Waiting to run
CI / Check web and api (pull_request) Waiting to run
2026-08-14 13:14:56 +08:00
zxyszx d345ae7e08 release: prepare v1.2.44 2026-08-14 07:28:40 +08:00
云逸 bd6edf353d Merge pull request #2 from zxyszx/codex/release-v1.2.43
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
release: v1.2.43
2026-08-14 05:29:58 +08:00
4 changed files with 193 additions and 79 deletions
+5
View File
@@ -0,0 +1,5 @@
- 修复刷新后台仪表盘时,注册、自助申请邮箱等功能在设置读取完成前短暂显示为关闭的问题。
- 为后台统计、系统状态、域名与列表页面、备份设置和个人中心补充统一加载状态,避免将未加载数据误显示为 0、未配置或暂无数据。
- 优化受限权限账号的状态展示:无权读取的系统设置明确显示为不可查看,不再误判为关闭或未配置。
**完整更新日志**[v1.2.43...v1.2.44](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.43...v1.2.44)
+1 -1
View File
@@ -1 +1 @@
1.2.43 1.2.44
+172 -76
View File
@@ -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-3xl"> <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-5xl">
<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> </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"> {!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>
+15 -2
View File
@@ -26,13 +26,14 @@ import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/s
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
import { Skeleton } from "@/components/ui/skeleton"
import { ConfirmDialog } from "@/components/confirm-dialog" import { ConfirmDialog } from "@/components/confirm-dialog"
import { useToast } from "@/hooks/use-toast" import { useToast } from "@/hooks/use-toast"
type Tab = "profile" | "mailboxes" | "contacts" | "cleanup" | "cleanupQueue" | "rules" | "blocked" | "stats" | "apiTokens" type Tab = "profile" | "mailboxes" | "contacts" | "cleanup" | "cleanupQueue" | "rules" | "blocked" | "stats" | "apiTokens"
type AccountSettingsTab = "account" | "mail" | "clients" | "security" type AccountSettingsTab = "account" | "mail" | "clients" | "security"
type PendingConfirm = { title: string; description?: string; confirmText: string; destructive?: boolean; onConfirm: () => void } type PendingConfirm = { title: string; description?: string; confirmText: string; destructive?: boolean; onConfirm: () => void }
type RetryableQuery = { isError: boolean; error: Error | null; refetch: () => Promise<unknown> } type RetryableQuery = { isLoading: boolean; isError: boolean; error: Error | null; refetch: () => Promise<unknown> }
const tabs: Record<Tab, { label: string; icon: React.ReactNode }> = { const tabs: Record<Tab, { label: string; icon: React.ReactNode }> = {
profile: { label: "账号设置", icon: <Settings className="h-4 w-4" /> }, profile: { label: "账号设置", icon: <Settings className="h-4 w-4" /> },
mailboxes: { label: "邮箱管理", icon: <Mail className="h-4 w-4" /> }, mailboxes: { label: "邮箱管理", icon: <Mail className="h-4 w-4" /> },
@@ -434,7 +435,8 @@ export function ProfilePage() {
</div> </div>
) )
function renderTab() { function renderTab() {
const failedQueries = visibleTabQueries().filter((query) => query.isError && query.error) const visibleQueries = visibleTabQueries()
const failedQueries = visibleQueries.filter((query) => query.isError && query.error)
if (failedQueries.length > 0) { if (failedQueries.length > 0) {
return ( return (
<ProfileQueryFailure <ProfileQueryFailure
@@ -443,6 +445,7 @@ export function ProfilePage() {
/> />
) )
} }
if (visibleQueries.some((query) => query.isLoading)) return <ProfileSectionLoading />
if (tab === "profile") return ( if (tab === "profile") return (
<AccountSettingsSection <AccountSettingsSection
activeTab={accountTab} activeTab={accountTab}
@@ -534,6 +537,16 @@ export function ProfilePage() {
} }
} }
function ProfileSectionLoading() {
return (
<div className="space-y-4" aria-label="正在加载页面数据" aria-busy="true">
<Skeleton className="h-40 w-full" />
<Skeleton className="h-56 w-full" />
<span className="sr-only">...</span>
</div>
)
}
function contentFrameClass(tab: Tab) { function contentFrameClass(tab: Tab) {
return cn( return cn(
"w-full", "w-full",