Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f15a3d3222 |
@@ -0,0 +1,4 @@
|
||||
- 将邮箱、个人设置和后台管理的当前选中项统一为清晰的淡蓝色,并补齐悬停、二级标签和邮件列表选中状态。
|
||||
- 优化后台数据总览:合并重复指标,改为四项核心数据、紧凑首次配置和统一系统状态布局,并确保公网地址完整显示。
|
||||
|
||||
**完整更新日志**:[v1.2.41...v1.2.42](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.41...v1.2.42)
|
||||
@@ -519,7 +519,7 @@ const SidebarMenuItem = React.forwardRef<
|
||||
SidebarMenuItem.displayName = "SidebarMenuItem"
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-[hsl(var(--sidebar-active))] data-[active=true]:font-medium data-[active=true]:text-[hsl(var(--sidebar-active-foreground))] data-[active=true]:hover:bg-[hsl(var(--sidebar-active))] data-[active=true]:hover:text-[hsl(var(--sidebar-active-foreground))] data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -621,7 +621,7 @@ const SidebarMenuAction = React.forwardRef<
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-[hsl(var(--sidebar-active-foreground))] md:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -639,7 +639,7 @@ const SidebarMenuBadge = React.forwardRef<
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-[hsl(var(--sidebar-active-foreground))]",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
@@ -730,7 +730,7 @@ const SidebarMenuSubButton = React.forwardRef<
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
"data-[active=true]:bg-[hsl(var(--sidebar-active))] data-[active=true]:text-[hsl(var(--sidebar-active-foreground))]",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
--sidebar-accent-foreground: 0 0% 12%;
|
||||
--sidebar-border: 0 0% 89.8%;
|
||||
--sidebar-ring: 0 0% 42%;
|
||||
--sidebar-active: 207 100% 92%;
|
||||
--sidebar-active-foreground: 202 100% 30%;
|
||||
--mail-selected: 210 100% 96%;
|
||||
--compose-send: 217 89% 43%;
|
||||
--compose-send-hover: 216 94% 32%;
|
||||
}
|
||||
@@ -86,6 +89,9 @@
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
--sidebar-active: 210 48% 24%;
|
||||
--sidebar-active-foreground: 210 100% 88%;
|
||||
--mail-selected: 210 38% 20%;
|
||||
--compose-send: 217 89% 52%;
|
||||
--compose-send-hover: 214 94% 60%;
|
||||
}
|
||||
@@ -124,6 +130,11 @@
|
||||
max-width: 384px;
|
||||
}
|
||||
|
||||
.mail-selected-row {
|
||||
background-color: hsl(var(--mail-selected));
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.mail-content-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { ArrowRight, 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 { 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 { cn, decodeMimeHeader, formatBytes, formatDate } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -135,18 +135,18 @@ export function AdminPage() {
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-[calc(100svh-3rem)] md:h-svh">
|
||||
<main className="admin-page mx-auto w-full max-w-[1180px] px-3 pb-10 pt-3 sm:px-4 sm:pt-4">
|
||||
<main className="admin-page mx-auto w-full max-w-[1440px] px-3 pb-8 pt-3 sm:px-4 sm:pt-4">
|
||||
<AdminPageHeader section={section} refreshing={refreshing} onRefresh={refreshAdminPage} />
|
||||
|
||||
{sectionQuery?.isError && <QueryFailure error={sectionQuery.error} onRetry={() => { void sectionQuery.refetch() }} />}
|
||||
|
||||
{section === "overview" && canOverview && (
|
||||
<div className="mb-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Stat icon={<Users />} label="账号" value={overview.data?.users || 0} />
|
||||
<Stat icon={<Globe2 />} label="邮件域名" value={overview.data?.domains || 0} />
|
||||
<Stat icon={<Mailbox />} label="邮箱" value={overview.data?.mailboxes || 0} />
|
||||
<Stat icon={<ShieldCheck />} label="存储用量" value={formatBytes(overview.data?.storageBytes || 0)} />
|
||||
</div>
|
||||
<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} 个转发`} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{section === "overview" && <OverviewSection overview={overview.data} domains={domainItems} settings={settings.data} visibleSections={visibleSections} onSectionChange={(next) => setParams(next === "overview" ? {} : { section: next })} />}
|
||||
@@ -191,51 +191,43 @@ 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))
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>系统状态</CardTitle></CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<InfoBox label="活跃账号" value={overview?.activeUsers || 0} />
|
||||
<InfoBox label="活跃邮箱" value={overview?.activeMailboxes || 0} />
|
||||
<InfoBox label="邮件转发" value={overview?.aliases || 0} />
|
||||
<InfoBox label="未读邮件" value={overview?.unreadMessages || 0} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle>首次配置</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<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 w-full justify-start gap-3 px-3 py-2 text-left font-normal" onClick={() => onSectionChange(item.section)}>
|
||||
<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>
|
||||
<ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>DNS 状态</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
</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="暂无域名" />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle>运行提示</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
||||
</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 ? "已启用" : "关闭"} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -255,7 +247,7 @@ function setupChecklist(overview: { activeUsers: number; activeMailboxes: number
|
||||
}
|
||||
|
||||
function InfoLine({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return <div className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"><span>{label}</span><span className="min-w-0 truncate font-medium text-foreground">{value}</span></div>
|
||||
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>
|
||||
}
|
||||
|
||||
function generateBackupPassword(length = 24) {
|
||||
@@ -2223,8 +2215,8 @@ function sendAuditBadgeVariant(event?: string) {
|
||||
return "secondary"
|
||||
}
|
||||
|
||||
function Stat({ icon, label, value }: { icon: React.ReactNode; label: string; value: React.ReactNode }) {
|
||||
return <Card><CardContent className="flex items-center gap-3 p-4 sm:gap-4 sm:p-5"><div className="grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-muted text-foreground sm:h-10 sm:w-10">{icon}</div><div className="min-w-0"><div className="truncate text-xl font-semibold tracking-tight sm:text-2xl">{value}</div><div className="text-xs text-muted-foreground">{label}</div></div></CardContent></Card>
|
||||
function Stat({ icon, label, value, detail }: { icon: React.ReactNode; label: string; value: React.ReactNode; detail: string }) {
|
||||
return <Card><CardContent className="flex min-h-[84px] 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 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> }
|
||||
|
||||
@@ -3393,7 +3393,7 @@ function CompactMessageRow({ message, active, checked, scheduled, onCheckedChang
|
||||
<div onClick={onClick} onContextMenu={onContextMenu} className={cn(
|
||||
"cursor-pointer border-b border-l-2 px-3 py-2.5 text-[13px] transition-colors sm:grid sm:grid-cols-[28px_24px_minmax(112px,180px)_minmax(0,1fr)_86px_30px] sm:items-center sm:gap-2 sm:px-3 sm:py-2",
|
||||
message.isRead ? "border-l-transparent hover:bg-accent/50" : "border-l-primary bg-primary/5 font-semibold hover:bg-primary/10",
|
||||
active && "bg-accent"
|
||||
active && "mail-selected-row"
|
||||
)}>
|
||||
<div className="flex gap-3 sm:contents">
|
||||
<Checkbox aria-label="选择邮件" checked={checked} onCheckedChange={(value) => onCheckedChange(value === true)} onClick={(event) => event.stopPropagation()} className="mt-0.5 shrink-0 sm:mt-0" />
|
||||
@@ -3830,8 +3830,8 @@ function MessageRow({
|
||||
return <div onClick={onClick} onContextMenu={onContextMenu} className={cn(
|
||||
"group cursor-pointer border-b border-l-2 px-3 py-2.5 transition-colors",
|
||||
message.isRead ? "border-l-transparent hover:bg-accent/60" : "border-l-primary bg-primary/5 font-semibold hover:bg-primary/10",
|
||||
active && "bg-accent",
|
||||
checked && "bg-accent/70"
|
||||
active && "mail-selected-row",
|
||||
checked && "mail-selected-row"
|
||||
)}>
|
||||
<div className="flex gap-2.5">
|
||||
<Checkbox
|
||||
|
||||
@@ -356,11 +356,11 @@ export function ProfilePage() {
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-sm transition-colors",
|
||||
tab === key ? "bg-muted font-semibold text-foreground" : "text-muted-foreground hover:bg-muted/70 hover:text-foreground",
|
||||
tab === key ? "bg-[hsl(var(--sidebar-active))] font-semibold text-[hsl(var(--sidebar-active-foreground))]" : "text-muted-foreground hover:bg-muted/70 hover:text-foreground",
|
||||
)}
|
||||
onClick={() => setTab(key)}
|
||||
>
|
||||
<span className={cn("text-muted-foreground [&>svg]:h-4 [&>svg]:w-4 [&>svg]:stroke-[1.8]", tab === key && "text-foreground/70")}>{tabs[key].icon}</span>
|
||||
<span className={cn("text-muted-foreground [&>svg]:h-4 [&>svg]:w-4 [&>svg]:stroke-[1.8]", tab === key && "text-[hsl(var(--sidebar-active-foreground))]")}>{tabs[key].icon}</span>
|
||||
<span className="truncate">{tabs[key].label}</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -563,7 +563,7 @@ function SettingsPageHeader({ title, subtitle, action, activeTab, onAccountTabCh
|
||||
type="button"
|
||||
className={cn(
|
||||
"h-[38px] shrink-0 border-b-2 px-4 text-sm font-medium transition-colors",
|
||||
activeTab === item.key ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground",
|
||||
activeTab === item.key ? "border-[hsl(var(--sidebar-active-foreground))] bg-[hsl(var(--sidebar-active))] text-[hsl(var(--sidebar-active-foreground))]" : "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
onClick={() => onAccountTabChange(item.key)}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user