@@ -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 , Circle , ClipboardList , Copy , ExternalLink , GitBranch , Github , Globe2 , Mailbox , MoreHorizontal , Plus , RefreshCcw , Scale , Search , ShieldCheck , Star , Trash2 , Users } from "lucide-react"
import { ArrowRight , BookOpen , CheckCircle2 , ChevronDown , Circle , ClipboardList , Copy , ExternalLink , GitBranch , Github , Globe2 , Mail , Mailbox , MoreHorizontal , Plus , RefreshCcw , Scale , Search , 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"
@@ -28,17 +28,18 @@ import type { PermissionKey } from "@/lib/api-types"
type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "sendAudit" | "settings"
type PendingConfirm = { title : string ; description? : string ; confirmText : string ; onConfirm : ( ) = > void }
const sectionLabels : Record < Section , string > = {
overview : "概览" ,
users : "用户" ,
permissionGroups : "权限组" ,
domains : "域名" ,
mailboxes : "邮箱账号" ,
aliases : "别名转发" ,
messages : "全部邮件" ,
sendAudit : "发送审计" ,
settings : "系统设置" ,
const sectionMeta : Record < Section , { label : string ; frontLabel : string ; description : string } > = {
overview : { label : "数据总览" , frontLabel : "数据统计" , description : "系统运行、DNS、邮箱和消息状态集中查看。" } ,
users : { label : "账号管理" , frontLabel : "账号设置" , description : "管理登录账号、身份状态、邮箱数量上限和绑定邮箱。" } ,
permissionGroups : { label : "权限配额" , frontLabel : "账号配额" , description : "配置前台菜单权限、发信频率、附件和邮箱创建额度。" } ,
domains : { label : "域名管理" , frontLabel : "邮箱地址" , description : "维护邮件域名、DKIM 和 DNS 检测。" } ,
mailboxes : { label : "邮箱管理" , frontLabel : "邮箱管理" , description : "创建、分配、停用邮箱,保持与前台邮箱列表一致。" } ,
aliases : { label : "邮件转发" , frontLabel : "邮件转发" , description : "管理域名转发规则。" } ,
messages : { label : "全部邮件" , frontLabel : "全部邮箱" , description : "按邮箱、文件夹和关键词查看全站邮件。" } ,
sendAudit : { label : "发送队列" , frontLabel : "发送队列" , description : "查看发信投递、重试和失败记录。" } ,
settings : { label : "系统设置" , frontLabel : "账号设置" , description : "管理站点、发信、存储、注册、安全和邮件模板。" } ,
}
const sectionLabels = Object . fromEntries ( Object . entries ( sectionMeta ) . map ( ( [ key , value ] ) = > [ key , value . label ] ) ) as Record < Section , string >
const sectionKeys = Object . keys ( sectionLabels ) as Section [ ]
const sectionPermissions : Record < Section , PermissionKey [ ] > = {
overview : [ "admin.overview.view" ] ,
@@ -51,11 +52,12 @@ const sectionPermissions: Record<Section, PermissionKey[]> = {
sendAudit : [ "admin.messages.view" ] ,
settings : [ "admin.settings.view" , "admin.templates.view" ] ,
}
const projectRepositoryUrl = "https://github.com/LanQin996/LanQi n-Email"
const projectRepositoryUrl = "https://github.com/zxyszx/NewSzxc n-Email"
const projectTelegramUrl = "https://t.me/+EhII7MSyi3QwNDQ5"
const projectTag = import . meta . env . VITE_APP_VERSION || ""
const projectReleaseUrl = import . meta . env . VITE_RELEASE_URL || ( projectTag ? ` ${ projectRepositoryUrl } /releases/tag/ ${ projectTag } ` : "" )
const defaultPermissionLimits : PermissionLimits = { maxAttachmentMb : 25 , smtpDailyLimit : 200 , smtpMinuteLimit : 20 , imapMinuteLimit : 200 , pop3MinuteLimit : 150 }
const defaultPermissionLimits : PermissionLimits = { maxAttachmentMb : 25 , maxMailboxCount : 9 , smtpDailyLimit : 200 , smtpMinuteLimit : 20 , imapMinuteLimit : 200 , pop3MinuteLimit : 150 }
const defaultMailboxLimitOverride = 9
export function AdminPage() {
const me = useMe ( )
@@ -90,17 +92,15 @@ export function AdminPage() {
return (
< ScrollArea className = "h-[calc(100svh-3rem)] md:h-svh" >
< main className = "p -4 sm:p-6 " >
< div className = "mb-6 flex flex-col gap-4 md:flex-row md:items-center md:justify-between" >
< h1 className = "text-2xl font-semibold tracking-tight" > { sectionLabels [ section ] } < / h1 >
< / div >
< main className = "mx-auto w-full max-w-[1180px] px-3 pb-10 pt-3 sm:px -4 sm:pt-4 " >
< AdminPageHeader section = { section } / >
{ section === "overview" && canOverview && (
< div className = "mb-6 grid gap-4 md :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 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 >
) }
@@ -117,6 +117,27 @@ export function AdminPage() {
< / ScrollArea >
)
}
function AdminPageHeader ( { section } : { section : Section } ) {
const meta = sectionMeta [ section ]
return (
< div className = "mb-4 border-b pb-3" >
< div className = "flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between" >
< div className = "min-w-0" >
< div className = "mb-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground" >
< span > 后 台 管 理 < / span >
< span className = "h-1 w-1 rounded-full bg-muted-foreground/50" / >
< span > 前 台 : { meta . frontLabel } < / span >
< / div >
< h1 className = "text-[20px] font-semibold leading-7 tracking-tight" > { meta . label } < / h1 >
< p className = "mt-1 text-sm leading-5 text-muted-foreground" > { meta . description } < / p >
< / div >
< Badge variant = "outline" className = "h-7 rounded-md px-2.5 font-normal" > NewSzxcn < / Badge >
< / div >
< / div >
)
}
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 (
@@ -125,9 +146,9 @@ function OverviewSection({ overview, domains, settings, visibleSections, onSecti
< 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 ? . activeUsers || 0 } / >
< InfoBox label = "活跃邮箱" value = { overview ? . activeMailboxes || 0 } / >
< InfoBox label = "别名 转发" value = { overview ? . aliases || 0 } / >
< InfoBox label = "邮件 转发" value = { overview ? . aliases || 0 } / >
< InfoBox label = "未读邮件" value = { overview ? . unreadMessages || 0 } / >
< / CardContent >
< / Card >
@@ -161,7 +182,7 @@ function OverviewSection({ overview, domains, settings, visibleSections, onSecti
< 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 ? "已启用" : "关闭" } / >
< InfoLine label = "自助申请邮箱 " value = { settings ? . userMailboxApplyEnabled ? "已启用" : "关闭" } / >
< / CardContent >
< / Card >
< / div >
@@ -177,7 +198,7 @@ function setupChecklist(overview: { activeUsers: number; activeMailboxes: number
return [
{ key : "domain" , title : "添加邮件域名" , detail : hasDomain ? ` ${ domains . length } 个域名已添加 ` : "先添加 example.com 这样的邮件域名" , done : hasDomain , section : "domains" as Section } ,
{ key : "dns" , title : "完成 DNS 检测" , detail : dnsReady ? "至少一个域名 DNS 正常" : "配置 MX、SPF、DKIM、DMARC 后执行检测" , done : dnsReady , section : "domains" as Section } ,
{ key : "mailbox" , title : "创建邮箱账号 " , detail : hasMailbox ? ` ${ overview ? . activeMailboxes || 0 } 个活跃邮箱 ` : "给超级 管理员或普通用户 创建第一个邮箱" , done : hasMailbox , section : "mailboxes" as Section } ,
{ key : "mailbox" , title : "创建邮箱" , detail : hasMailbox ? ` ${ overview ? . activeMailboxes || 0 } 个活跃邮箱 ` : "给管理员或普通账号 创建第一个邮箱" , done : hasMailbox , section : "mailboxes" as Section } ,
{ key : "smtp" , title : "确认发信链路" , detail : settings?.smtpHost ? ` 内置 Postfix: ${ settings . smtpHost } : ${ settings . smtpPort } ` : "默认使用内置 Postfix" , done : true , section : "settings" as Section } ,
{ key : "mail" , title : "完成收发测试" , detail : hasMail ? ` ${ overview ? . messages || 0 } 封邮件已入库 ` : "发送或接收一封测试邮件" , done : hasMail , section : "messages" as Section } ,
]
@@ -205,12 +226,12 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
const matchesStatus = statusFilter === "all" || ( statusFilter === "active" ? ! user.disabled : user.disabled )
return matchesKeyword && matchesRole && matchesStatus
} )
const remove = useMutation ( { mutationFn : api.deleteUser , onSuccess : ( ) = > { setPendingConfirm ( null ) ; invalidateAdmin ( qc ) ; toast ( { title : "用户 已删除" } ) } , onError : ( e ) = > toast ( { title : "删除失败" , description : e.message } ) } )
const remove = useMutation ( { mutationFn : api.deleteUser , onSuccess : ( ) = > { setPendingConfirm ( null ) ; invalidateAdmin ( qc ) ; toast ( { title : "账号 已删除" } ) } , onError : ( e ) = > toast ( { title : "删除失败" , description : e.message } ) } )
return (
< Card >
< CardHeader >
< div className = "flex flex-col gap-3 md:flex-row md:items-center md:justify-between" >
< CardTitle > 用 户 管 理 < / CardTitle >
< CardTitle > 账 号 管 理 < / CardTitle >
{ canCreate && < CreateUserDialog permissionGroups = { permissionGroups } / > }
< / div >
< / CardHeader >
@@ -218,13 +239,13 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
< div className = "flex flex-col gap-3 lg:flex-row" >
< div className = "relative flex-1" >
< Search className = "absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" / >
< Input value = { query } onChange = { ( event ) = > setQuery ( event . target . value ) } placeholder = "搜索用户 、邮箱、显示名称" className = "pl-9" / >
< Input value = { query } onChange = { ( event ) = > setQuery ( event . target . value ) } placeholder = "搜索账号 、邮箱、显示名称" className = "pl-9" / >
< / div >
< Select value = { roleFilter } onValueChange = { setRoleFilter } >
< SelectTrigger className = "lg:w-36" > < SelectValue / > < / SelectTrigger >
< SelectContent >
< SelectItem value = "all" > 全 部 角 色 < / SelectItem >
< SelectItem value = "admin" > 超 级 管 理 员 < / SelectItem >
< SelectItem value = "admin" > 管 理 员 < / SelectItem >
< SelectItem value = "user" > 普 通 用 户 < / SelectItem >
< / SelectContent >
< / Select >
@@ -245,7 +266,7 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
< div className = "truncate font-medium" > { user . displayName } < / div >
< div className = "truncate text-xs text-muted-foreground" > { user . email } < / div >
< / div >
< UserActions user = { user } permissionGroups = { permissionGroups } onDelete = { canDelete ? ( ) = > setPendingConfirm ( { title : "删除用户 ? " , description : ` 将删除 ${ user . email } 及其关联数据。 ` , confirmText : "删除用户 " , onConfirm : ( ) = > remove . mutate ( user . id ) } ) : undefined } / >
< UserActions user = { user } permissionGroups = { permissionGroups } onDelete = { canDelete ? ( ) = > setPendingConfirm ( { title : "删除账号 ? " , description : ` 将删除 ${ user . email } 及其关联数据。 ` , confirmText : "删除账号 " , onConfirm : ( ) = > remove . mutate ( user . id ) } ) : undefined } / >
< / div >
< div className = "mt-3 flex flex-wrap gap-2" >
< RoleBadge user = { user } / >
@@ -259,7 +280,7 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
< / div >
< div className = "hidden md:block" >
< Table >
< TableHeader > < TableRow > < TableHead > 用 户 < / TableHead > < TableHead > 身 份 < / TableHead > < TableHead > 权 限 组 < / TableHead > < TableHead > 邮 箱 < / TableHead > < TableHead > 状 态 < / TableHead > < TableHead > 创 建 时 间 < / TableHead > < TableHead className = "w-16" > < / TableHead > < / TableRow > < / TableHeader >
< TableHeader > < TableRow > < TableHead > 账 号 < / TableHead > < TableHead > 身 份 < / TableHead > < TableHead > 权 限 配 额 < / TableHead > < TableHead className = "w-[22rem]" > 邮 箱 < / TableHead > < TableHead > 状 态 < / TableHead > < TableHead > 创 建 < / TableHead > < TableHead className = "w-16" > < / TableHead > < / TableRow > < / TableHeader >
< TableBody >
{ filteredUsers . map ( ( user ) = > (
< TableRow key = { user . id } >
@@ -269,16 +290,16 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
< / TableCell >
< TableCell > < RoleBadge user = { user } / > < / TableCell >
< TableCell > < UserPermissionGroupsCell user = { user } / > < / TableCell >
< TableCell > < UserMailboxCell user = { user } / > < / TableCell >
< TableCell className = "w-[22rem] max-w-[22rem]" > < UserMailboxCell user = { user } / > < / TableCell >
< TableCell > < Badge variant = { user . disabled ? "secondary" : "default" } > { user . disabled ? "停用" : "正常" } < / Badge > < / TableCell >
< TableCell className = "text-muted-foreground" > { new Date ( user . createdAt ) . toLocaleDateString ( ) } < / TableCell >
< TableCell > < UserActions user = { user } permissionGroups = { permissionGroups } onDelete = { canDelete ? ( ) = > setPendingConfirm ( { title : "删除用户 ? " , description : ` 将删除 ${ user . email } 及其关联数据。 ` , confirmText : "删除用户 " , onConfirm : ( ) = > remove . mutate ( user . id ) } ) : undefined } / > < / TableCell >
< TableCell > < UserActions user = { user } permissionGroups = { permissionGroups } onDelete = { canDelete ? ( ) = > setPendingConfirm ( { title : "删除账号 ? " , description : ` 将删除 ${ user . email } 及其关联数据。 ` , confirmText : "删除账号 " , onConfirm : ( ) = > remove . mutate ( user . id ) } ) : undefined } / > < / TableCell >
< / TableRow >
) ) }
< / TableBody >
< / Table >
< / div >
{ filteredUsers . length === 0 && < Empty text = "没有匹配的用户 " / > }
{ filteredUsers . length === 0 && < Empty text = "没有匹配的账号 " / > }
< / CardContent >
< ConfirmDialog open = { ! ! pendingConfirm } title = { pendingConfirm ? . title || "" } description = { pendingConfirm ? . description } confirmText = { pendingConfirm ? . confirmText || "删除" } destructive pending = { remove . isPending } onOpenChange = { ( open ) = > { if ( ! open ) setPendingConfirm ( null ) } } onConfirm = { ( ) = > pendingConfirm ? . onConfirm ( ) } / >
< / Card >
@@ -301,7 +322,7 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
onSuccess : ( ) = > {
setPendingConfirm ( null )
invalidateAdmin ( qc )
toast ( { title : "权限组 已删除" } )
toast ( { title : "权限配额 已删除" } )
} ,
onError : ( e ) = > toast ( { title : "删除失败" , description : e.message } ) ,
} )
@@ -316,14 +337,14 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
< Card >
< CardHeader >
< div className = "flex flex-col gap-3 md:flex-row md:items-center md:justify-between" >
< CardTitle > 权 限 组 管 理 < / CardTitle >
< CardTitle > 权 限 配 额 < / CardTitle >
{ canCreate && < PermissionGroupDialog catalog = { catalog } / > }
< / div >
< / CardHeader >
< CardContent className = "space-y-4" >
< div className = "relative" >
< Search className = "absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" / >
< Input value = { query } onChange = { ( event ) = > setQuery ( event . target . value ) } placeholder = "搜索权限组 、说明或权限键" className = "pl-9" / >
< Input value = { query } onChange = { ( event ) = > setQuery ( event . target . value ) } placeholder = "搜索权限配额 、说明或权限键" className = "pl-9" / >
< / div >
< div className = "grid gap-3 lg:grid-cols-2" >
{ filtered . map ( ( group ) = > (
@@ -341,14 +362,14 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
{ ( canUpdate || canDelete ) && < DropdownMenu >
< DropdownMenuTrigger asChild > < Button variant = "ghost" size = "icon" > < MoreHorizontal className = "h-4 w-4" / > < / Button > < / DropdownMenuTrigger >
< DropdownMenuContent align = "end" >
< DropdownMenuItem disabled = { ! isEditable ( group ) || ! canUpdate } onSelect = { ( ) = > setEditing ( group ) } > 编 辑 权 限 组 < / DropdownMenuItem >
< DropdownMenuItem disabled = { ! isEditable ( group ) || ! canUpdate } onSelect = { ( ) = > setEditing ( group ) } > 编 辑 权 限 配 额 < / DropdownMenuItem >
< DropdownMenuSeparator / >
< DropdownMenuItem
className = "text-destructive"
disabled = { ! isDeletable ( group ) || ! canDelete }
onSelect = { ( ) = > setPendingConfirm ( { title : "删除权限组 ? " , description : ` ${ group . name } 删除后不能再分配给用户 。 ` , confirmText : "删除权限组 " , onConfirm : ( ) = > remove . mutate ( group . id ) } ) }
onSelect = { ( ) = > setPendingConfirm ( { title : "删除权限配额 ? " , description : ` ${ group . name } 删除后不能再分配给账号 。 ` , confirmText : "删除权限配额 " , onConfirm : ( ) = > remove . mutate ( group . id ) } ) }
>
删 除 权 限 组
删 除 权 限 配 额
< / DropdownMenuItem >
< / DropdownMenuContent >
< / DropdownMenu > }
@@ -358,7 +379,7 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
< / div >
) ) }
< / div >
{ filtered . length === 0 && < Empty text = "暂无匹配的权限组 " / > }
{ filtered . length === 0 && < Empty text = "暂无匹配的权限配额 " / > }
< / CardContent >
{ editing && < PermissionGroupDialog group = { editing } catalog = { catalog } open = { ! ! editing } onOpenChange = { ( open ) = > { if ( ! open ) setEditing ( null ) } } / > }
< ConfirmDialog open = { ! ! pendingConfirm } title = { pendingConfirm ? . title || "" } description = { pendingConfirm ? . description } confirmText = { pendingConfirm ? . confirmText || "删除" } destructive pending = { remove . isPending } onOpenChange = { ( open ) = > { if ( ! open ) setPendingConfirm ( null ) } } onConfirm = { ( ) = > pendingConfirm ? . onConfirm ( ) } / >
@@ -395,20 +416,20 @@ function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?:
onSuccess : ( ) = > {
invalidateAdmin ( qc )
setDialogOpen ( false )
toast ( { title : group ? "权限组 已更新" : "权限组 已创建" } )
toast ( { title : group ? "权限配额 已更新" : "权限配额 已创建" } )
} ,
onError : ( e ) = > toast ( { title : group ? "更新失败" : "创建失败" , description : e.message } ) ,
} )
const trigger = group ? null : (
< DialogTrigger asChild >
< Button size = "sm" > < Plus className = "h-4 w-4" / > 权 限 组 < / Button >
< Button size = "sm" > < Plus className = "h-4 w-4" / > 权 限 配 额 < / Button >
< / DialogTrigger >
)
return (
< Dialog open = { dialogOpen } onOpenChange = { setDialogOpen } >
{ trigger }
< DialogContent className = "max-h-[86vh] overflow-y-auto sm:max-w-3xl" >
< DialogHeader > < DialogTitle > { group ? "编辑权限组 " : "创建权限组 " } < / DialogTitle > < / DialogHeader >
< DialogHeader > < DialogTitle > { group ? "编辑权限配额 " : "创建权限配额 " } < / DialogTitle > < / DialogHeader >
< form className = "space-y-4" onSubmit = { ( event ) = > { event . preventDefault ( ) ; mutation . mutate ( new FormData ( event . currentTarget ) ) } } >
< div className = "grid gap-4 md:grid-cols-2" >
< Field name = "name" label = "名称" defaultValue = { group ? . name || "" } placeholder = "例如:客服主管" / >
@@ -481,6 +502,10 @@ function PermissionLimitEditor({ value, onChange }: { value: PermissionLimits; o
< span className = "text-xs text-muted-foreground" > 填 0 表 示 不 限 制 < / span >
< / div >
< div className = "grid gap-3 md:grid-cols-3" >
< div className = "space-y-2" >
< Label > 邮 箱 数 量 上 限 < / Label >
< Input type = "number" min = { 0 } value = { value . maxMailboxCount } onChange = { ( event ) = > update ( "maxMailboxCount" , event . target . value ) } / >
< / div >
< div className = "space-y-2" >
< Label > 附 件 上 限 MB < / Label >
< Input type = "number" min = { 0 } value = { value . maxAttachmentMb } onChange = { ( event ) = > update ( "maxAttachmentMb" , event . target . value ) } / >
@@ -525,6 +550,7 @@ function PermissionLimitBadges({ limits }: { limits?: PermissionLimits }) {
return (
< div className = "mt-3 flex flex-wrap gap-1.5" >
< Badge variant = "secondary" className = "font-normal" > 附 件 { limitText ( value . maxAttachmentMb , "MB" ) } < / Badge >
< Badge variant = "secondary" className = "font-normal" > 邮 箱 { limitText ( value . maxMailboxCount , "个" ) } < / Badge >
< Badge variant = "secondary" className = "font-normal" > SMTP 每 日 { limitText ( value . smtpDailyLimit , "封" ) } < / Badge >
< Badge variant = "secondary" className = "font-normal" > SMTP 每 分 钟 { limitText ( value . smtpMinuteLimit , "封" ) } < / Badge >
< Badge variant = "secondary" className = "font-normal" > IMAP 每 分 钟 { limitText ( value . imapMinuteLimit , "次" ) } < / Badge >
@@ -582,7 +608,7 @@ function DomainsSection({ domains }: { domains: Domain[] }) {
< Badge variant = { domain . dnsStatus === "ok" ? "default" : "secondary" } > { domain . dnsStatus === "ok" ? "DNS 正常" : domain . dnsStatus } < / Badge >
{ canViewDNS && < DomainDNSDialog domain = { domain } / > }
{ canUpdate && < Button variant = "outline" size = "sm" onClick = { ( ) = > update . mutate ( { id : domain.id , status : domain.status === "active" ? "disabled" : "active" } ) } > { domain . status === "active" ? "停用" : "启用" } < / Button > }
{ canDelete && < Button variant = "outline" size = "sm" onClick = { ( ) = > setPendingConfirm ( { title : "删除域名?" , description : ` 将删除 ${ domain . name } ,相关邮箱、别名 和邮件也可能受影响。 ` , confirmText : "删除域名" , onConfirm : ( ) = > remove . mutate ( domain . id ) } ) } > < Trash2 className = "h-4 w-4" / > 删 除 < / Button > }
{ canDelete && < Button variant = "outline" size = "sm" onClick = { ( ) = > setPendingConfirm ( { title : "删除域名?" , description : ` 将删除 ${ domain . name } ,相关邮箱、转发 和邮件也可能受影响。 ` , confirmText : "删除域名" , onConfirm : ( ) = > remove . mutate ( domain . id ) } ) } > < Trash2 className = "h-4 w-4" / > 删 除 < / Button > }
< / div >
< / div >
) ) }
@@ -621,7 +647,7 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
< Card >
< CardHeader >
< div className = "flex flex-col gap-3 md:flex-row md:items-center md:justify-between" >
< CardTitle > 邮 箱 账 号 管 理 < / CardTitle >
< CardTitle > 邮 箱 管 理 < / CardTitle >
{ canCreate && < CreateMailboxDialog domains = { domains } users = { users } / > }
< / div >
< / CardHeader >
@@ -646,7 +672,7 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
< / div >
< div className = "hidden md:block" >
< Table >
< TableHeader > < TableRow > < TableHead > 地 址 < / TableHead > < TableHead > 归 属 用 户 < / TableHead > < TableHead > 名 称 < / TableHead > < TableHead > 配 额 < / TableHead > < TableHead > 状 态 < / TableHead > < TableHead className = "w-16" > < / TableHead > < / TableRow > < / TableHeader >
< TableHeader > < TableRow > < TableHead > 地 址 < / TableHead > < TableHead > 归 属 账 号 < / TableHead > < TableHead > 名 称 < / TableHead > < TableHead > 配 额 < / TableHead > < TableHead > 状 态 < / TableHead > < TableHead className = "w-16" > < / TableHead > < / TableRow > < / TableHeader >
< TableBody >
{ mailboxes . map ( ( mailbox ) = > (
< TableRow key = { mailbox . id } >
@@ -661,7 +687,7 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
< / TableBody >
< / Table >
< / div >
{ mailboxes . length === 0 && < Empty text = "暂无邮箱账号 " / > }
{ mailboxes . length === 0 && < Empty text = "暂无邮箱" / > }
< / CardContent >
< ConfirmDialog open = { ! ! pendingConfirm } title = { pendingConfirm ? . title || "" } description = { pendingConfirm ? . description } confirmText = { pendingConfirm ? . confirmText || "删除" } destructive pending = { remove . isPending } onOpenChange = { ( open ) = > { if ( ! open ) setPendingConfirm ( null ) } } onConfirm = { ( ) = > pendingConfirm ? . onConfirm ( ) } / >
< / Card >
@@ -677,13 +703,13 @@ function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domai
const canCreate = hasPermission ( user , "admin.aliases.create" )
const canUpdate = hasPermission ( user , "admin.aliases.update" )
const canDelete = hasPermission ( user , "admin.aliases.delete" )
const update = useMutation ( { mutationFn : ( { id , payload } : { id : string ; payload : { source : string ; destination : string ; enabled : boolean } } ) = > api . updateAlias ( id , payload ) , onSuccess : ( ) = > { invalidateAdmin ( qc ) ; toast ( { title : "别名 已更新" } ) } , onError : ( e ) = > toast ( { title : "更新失败" , description : e.message } ) } )
const remove = useMutation ( { mutationFn : api.deleteAlias , onSuccess : ( ) = > { setPendingConfirm ( null ) ; invalidateAdmin ( qc ) ; toast ( { title : "别名 已删除" } ) } , onError : ( e ) = > toast ( { title : "删除失败" , description : e.message } ) } )
const update = useMutation ( { mutationFn : ( { id , payload } : { id : string ; payload : { source : string ; destination : string ; enabled : boolean } } ) = > api . updateAlias ( id , payload ) , onSuccess : ( ) = > { invalidateAdmin ( qc ) ; toast ( { title : "转发 已更新" } ) } , onError : ( e ) = > toast ( { title : "更新失败" , description : e.message } ) } )
const remove = useMutation ( { mutationFn : api.deleteAlias , onSuccess : ( ) = > { setPendingConfirm ( null ) ; invalidateAdmin ( qc ) ; toast ( { title : "转发 已删除" } ) } , onError : ( e ) = > toast ( { title : "删除失败" , description : e.message } ) } )
return (
< Card >
< CardHeader >
< div className = "flex flex-col gap-3 md:flex-row md:items-center md:justify-between" >
< CardTitle > 别 名 / 转 发 管 理 < / CardTitle >
< CardTitle > 邮 件 转 发 < / CardTitle >
{ canCreate && < CreateAliasDialog domains = { domains } / > }
< / div >
< / CardHeader >
@@ -696,7 +722,7 @@ function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domai
< div className = "truncate font-medium" > { alias . source } < / div >
< div className = "truncate text-xs text-muted-foreground" > { alias . destination } < / div >
< / div >
< AliasActions alias = { alias } onToggle = { canUpdate ? ( ) = > update . mutate ( { id : alias.id , payload : { source : alias.source , destination : alias.destination , enabled : ! alias . enabled } } ) : undefined } onDelete = { canDelete ? ( ) = > setPendingConfirm ( { title : "删除别名 ? " , description : ` ${ alias . source } 将不再转发到 ${ alias . destination } 。 ` , confirmText : "删除别名 " , onConfirm : ( ) = > remove . mutate ( alias . id ) } ) : undefined } / >
< AliasActions alias = { alias } onToggle = { canUpdate ? ( ) = > update . mutate ( { id : alias.id , payload : { source : alias.source , destination : alias.destination , enabled : ! alias . enabled } } ) : undefined } onDelete = { canDelete ? ( ) = > setPendingConfirm ( { title : "删除转发 ? " , description : ` ${ alias . source } 将不再转发到 ${ alias . destination } 。 ` , confirmText : "删除转发 " , onConfirm : ( ) = > remove . mutate ( alias . id ) } ) : undefined } / >
< / div >
< div className = "mt-3 flex flex-wrap gap-2" >
< Badge variant = { alias . enabled ? "default" : "secondary" } > { alias . enabled ? "启用" : "停用" } < / Badge >
@@ -715,13 +741,13 @@ function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domai
< TableCell > { alias . destination } < / TableCell >
< TableCell className = "text-muted-foreground" > { domains . find ( ( d ) = > d . id === alias . domainId ) ? . name || alias . domainId } < / TableCell >
< TableCell > < Badge variant = { alias . enabled ? "default" : "secondary" } > { alias . enabled ? "启用" : "停用" } < / Badge > < / TableCell >
< TableCell > < AliasActions alias = { alias } onToggle = { canUpdate ? ( ) = > update . mutate ( { id : alias.id , payload : { source : alias.source , destination : alias.destination , enabled : ! alias . enabled } } ) : undefined } onDelete = { canDelete ? ( ) = > setPendingConfirm ( { title : "删除别名 ? " , description : ` ${ alias . source } 将不再转发到 ${ alias . destination } 。 ` , confirmText : "删除别名 " , onConfirm : ( ) = > remove . mutate ( alias . id ) } ) : undefined } / > < / TableCell >
< TableCell > < AliasActions alias = { alias } onToggle = { canUpdate ? ( ) = > update . mutate ( { id : alias.id , payload : { source : alias.source , destination : alias.destination , enabled : ! alias . enabled } } ) : undefined } onDelete = { canDelete ? ( ) = > setPendingConfirm ( { title : "删除转发 ? " , description : ` ${ alias . source } 将不再转发到 ${ alias . destination } 。 ` , confirmText : "删除转发 " , onConfirm : ( ) = > remove . mutate ( alias . id ) } ) : undefined } / > < / TableCell >
< / TableRow >
) ) }
< / TableBody >
< / Table >
< / div >
{ aliases . length === 0 && < Empty text = "暂无别名 转发" / > }
{ aliases . length === 0 && < Empty text = "暂无邮件 转发" / > }
< / CardContent >
< ConfirmDialog open = { ! ! pendingConfirm } title = { pendingConfirm ? . title || "" } description = { pendingConfirm ? . description } confirmText = { pendingConfirm ? . confirmText || "删除" } destructive pending = { remove . isPending } onOpenChange = { ( open ) = > { if ( ! open ) setPendingConfirm ( null ) } } onConfirm = { ( ) = > pendingConfirm ? . onConfirm ( ) } / >
< / Card >
@@ -880,7 +906,7 @@ function AdminSendAuditSection({ mailboxes }: { mailboxes: MailboxType[] }) {
< Card >
< CardHeader >
< div className = "flex flex-col gap-3 md:flex-row md:items-center md:justify-between" >
< CardTitle className = "flex items-center gap-2" > < ClipboardList className = "h-5 w-5" / > 发 送 审 计 < / CardTitle >
< CardTitle className = "flex items-center gap-2" > < ClipboardList className = "h-5 w-5" / > 发 送 队 列 < / CardTitle >
< Button variant = "outline" size = "sm" onClick = { ( ) = > qc . invalidateQueries ( { queryKey : [ "admin" , "send-audit" ] } ) } >
< RefreshCcw className = "h-4 w-4" / > 刷 新
< / Button >
@@ -955,7 +981,7 @@ function AdminSendAuditSection({ mailboxes }: { mailboxes: MailboxType[] }) {
< / Table >
< / div >
{ audit . isLoading && < Empty text = "加载中..." / > }
{ ! audit . isLoading && items . length === 0 && < Empty text = "暂无发送审计 " / > }
{ ! audit . isLoading && items . length === 0 && < Empty text = "暂无发送记录 " / > }
{ ! audit . isLoading && audit . hasNextPage && (
< div className = "flex justify-center" >
< Button variant = "outline" size = "sm" disabled = { audit . isFetchingNextPage } onClick = { ( ) = > audit . fetchNextPage ( ) } >
@@ -1157,7 +1183,7 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
< CardContent className = "space-y-5" >
< SwitchRow label = "无人收件" checked = { catchAllEnabled } onCheckedChange = { setCatchAllEnabled } / >
< Separator / >
< SwitchRow label = "用户 自助申请邮箱" checked = { userMailboxApplyEnabled } onCheckedChange = { setUserMailboxApplyEnabled } / >
< SwitchRow label = "账号 自助申请邮箱" checked = { userMailboxApplyEnabled } onCheckedChange = { setUserMailboxApplyEnabled } / >
{ userMailboxApplyEnabled && (
< div className = "space-y-5 border-t pt-5" >
< div className = "space-y-3" >
@@ -1202,7 +1228,7 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
< / CardHeader >
< CardContent className = "space-y-5" >
< div className = "rounded-lg border bg-muted/30 p-4 text-sm text-muted-foreground" >
默 认 关 闭 。 关 闭 后 用 户 端 会 隐 藏 外 部 IMAP 接 入 , 相 关 后 端 接 口 也 会 返 回 禁 用 。
默 认 关 闭 。 关 闭 后 前 台 会 隐 藏 外 部 IMAP 接 入 , 相 关 后 端 接 口 也 会 返 回 禁 用 。
< / div >
< SwitchRow label = "启用外部 IMAP" checked = { externalImapEnabled } onCheckedChange = { setExternalImapEnabled } / >
{ externalImapEnabled && (
@@ -1372,7 +1398,7 @@ function AboutProjectCard() {
const latestRelease = useQuery ( {
queryKey : [ "github" , "latest-release" ] ,
queryFn : async ( ) = > {
const res = await fetch ( "https://api.github.com/repos/LanQin996/LanQi n-Email/releases/latest" )
const res = await fetch ( "https://api.github.com/repos/zxyszx/NewSzxc n-Email/releases/latest" )
if ( ! res . ok ) throw new Error ( "rate limited or unavailable" )
return res . json ( ) as Promise < { tag_name : string ; html_url : string } >
} ,
@@ -1601,7 +1627,7 @@ function AdminMessageDialog({ message, loading, open, onOpenChange }: { message?
< div className = "space-y-5" >
< div className = "grid gap-3 rounded-lg border p-4 text-sm md:grid-cols-2" >
< MessageMeta label = "所属邮箱" value = { message . mailboxAddress || message . recipientAddress || "" } / >
< MessageMeta label = "所属用户 " value = { message . ownerEmail || "" } / >
< MessageMeta label = "所属账号 " value = { message . ownerEmail || "" } / >
< MessageMeta label = "发件人" value = { adminSenderTitle ( message ) } / >
< MessageMeta label = "收件人" value = { message . recipientAddress || message . to ? . join ( ", " ) || "" } / >
< MessageMeta label = "文件夹" value = { folderName ( message . folder ) } / >
@@ -1687,12 +1713,80 @@ function DomainBadgeRow({ domain }: { domain: Domain }) { return <div className=
function invalidateAdmin ( qc : ReturnType < typeof useQueryClient > ) { qc . invalidateQueries ( { queryKey : [ "admin" ] } ) ; qc . invalidateQueries ( { queryKey : [ "mailboxes" ] } ) ; qc . invalidateQueries ( { queryKey : [ "me" ] } ) }
function UserMailboxCell ( { user } : { user : AdminUser } ) {
const { toast } = useToast ( )
const loginAddress = user . email
const mailboxes = user . mailboxes || [ ]
if ( mailboxes . length === 0 ) return < span className = "text-muted-foreground" > 未 绑 定 < / span >
const [ mailboxQuery , setMailboxQuery ] = React . useState ( "" )
const normalizedQuery = mailboxQuery . trim ( ) . toLowerCase ( )
const sortedMailboxes = React . useMemo ( ( ) = > {
return Array . from ( new Set ( mailboxes ) ) . sort ( ( a , b ) = > a . localeCompare ( b , "en" , { sensitivity : "base" } ) )
} , [ mailboxes ] )
const [ selectedAddress , setSelectedAddress ] = React . useState ( loginAddress )
React . useEffect ( ( ) = > {
if ( selectedAddress === loginAddress || sortedMailboxes . includes ( selectedAddress ) ) return
setSelectedAddress ( loginAddress )
} , [ loginAddress , selectedAddress , sortedMailboxes ] )
const filteredMailboxes = React . useMemo ( ( ) = > {
if ( ! normalizedQuery ) return sortedMailboxes
return sortedMailboxes . filter ( ( mailbox ) = > mailbox . toLowerCase ( ) . includes ( normalizedQuery ) )
} , [ normalizedQuery , sortedMailboxes ] )
const limit = user . role === "admin" ? "不限" : limitText ( user . limits ? . maxMailboxCount ? ? defaultMailboxLimitOverride , "个" )
const quota = < div className = "text-[11px] text-muted-foreground" > 邮 箱 { user . mailboxCount } / { limit } < / div >
async function copyMailbox ( address : string ) {
if ( ! address ) return
await navigator . clipboard . writeText ( address )
toast ( { title : "邮箱地址已复制" , description : address } )
}
return (
< div className = "flex max-w-md flex-wrap gap -1" >
{ mailboxes . slice ( 0 , 2 ) . map ( ( mailbox ) = > < Badge key = { mailbox } variant = "outline" className = "font-normal" > { mailbox } < / Badge > ) }
{ mailboxes . length > 2 && < Badge variant = "secondary" > + { mailboxes . length - 2 } < / Badge > }
< div className = "w-full max-w-[21rem] space-y -1" >
< div className = "flex min-w-0 items-center gap-1.5" >
< DropdownMenu onOpenChange = { ( open ) = > { if ( ! open ) setMailboxQuery ( "" ) } } >
< DropdownMenuTrigger asChild >
< Button
type = "button"
variant = "outline"
className = "h-8 min-w-0 flex-1 justify-start gap-1.5 overflow-hidden rounded-md border-input bg-background px-2 text-left font-normal shadow-none hover:bg-background"
title = { selectedAddress }
>
< Mail className = "h-3.5 w-3.5 shrink-0 text-muted-foreground" / >
< span className = "min-w-0 flex-1 truncate text-[13px] font-medium" > { selectedAddress } < / span >
{ sortedMailboxes . length > 0 && < span className = "shrink-0 rounded bg-muted px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground" > { sortedMailboxes . length } 个 < / span > }
< ChevronDown className = "h-3.5 w-3.5 shrink-0 text-muted-foreground" / >
< / Button >
< / DropdownMenuTrigger >
< DropdownMenuContent align = "start" className = "w-[21rem] max-w-[calc(100vw-32px)] p-1" >
< div className = "px-1 pb-1" >
< div className = "relative" >
< Search className = "absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" / >
< Input
autoFocus
value = { mailboxQuery }
onChange = { ( event ) = > setMailboxQuery ( event . target . value ) }
onKeyDown = { ( event ) = > event . stopPropagation ( ) }
placeholder = "搜索邮箱..."
className = "h-8 rounded-md bg-background pl-8 pr-2 text-[13px] shadow-none"
/ >
< / div >
< / div >
{ filteredMailboxes . map ( ( mailbox ) = > (
< DropdownMenuItem
key = { mailbox }
onSelect = { ( ) = > setSelectedAddress ( mailbox ) }
className = { cn ( "h-8 min-w-0 gap-2 rounded-sm px-2 text-[13px] font-normal" , selectedAddress === mailbox && "bg-accent text-accent-foreground" ) }
>
< Mail className = "h-3.5 w-3.5 shrink-0 text-muted-foreground" / >
< span className = "min-w-0 flex-1 truncate" title = { mailbox } > { mailbox } < / span >
< / DropdownMenuItem >
) ) }
{ sortedMailboxes . length === 0 && < DropdownMenuItem disabled className = "h-8 px-2 text-[13px] font-normal" > 暂 无 创 建 邮 箱 < / DropdownMenuItem > }
{ sortedMailboxes . length > 0 && filteredMailboxes . length === 0 && < DropdownMenuItem disabled className = "h-8 px-2 text-[13px] font-normal" > 没 有 匹 配 邮 箱 < / DropdownMenuItem > }
< / DropdownMenuContent >
< / DropdownMenu >
< Button type = "button" variant = "outline" size = "icon" className = "h-8 w-8 shrink-0 rounded-md bg-background shadow-none hover:bg-background" disabled = { ! selectedAddress } onClick = { ( ) = > copyMailbox ( selectedAddress ) } aria-label = "复制邮箱地址" title = "复制邮箱地址" >
< Copy className = "h-3.5 w-3.5" / >
< / Button >
< / div >
{ quota }
< / div >
)
}
@@ -1721,7 +1815,7 @@ function PermissionGroupPicker({ groups, value, onChange }: { groups: Permission
}
return (
< div className = "space-y-2" >
< Label > 权 限 组 < / Label >
< Label > 权 限 配 额 < / Label >
< div className = "grid gap-2 md:grid-cols-2" >
{ groups . map ( ( group ) = > {
const checked = value . includes ( group . id )
@@ -1736,7 +1830,7 @@ function PermissionGroupPicker({ groups, value, onChange }: { groups: Permission
)
} ) }
< / div >
{ groups . length === 0 && < Empty text = "暂无可分配权限组 " / > }
{ groups . length === 0 && < Empty text = "暂无可分配权限配额 " / > }
< / div >
)
}
@@ -1744,7 +1838,7 @@ function PermissionGroupPicker({ groups, value, onChange }: { groups: Permission
function RoleBadge ( { user } : { user : AdminUser } ) {
return (
< div className = "flex flex-wrap gap-1" >
< Badge variant = { user . role === "admin" ? "default" : "secondary" } > { user . role === "admin" ? "超级 管理员" : "普通用户" } < / Badge >
< Badge variant = { user . role === "admin" ? "default" : "secondary" } > { user . role === "admin" ? "管理员" : "普通用户" } < / Badge >
{ user . protected && < Badge variant = "outline" > 默 认 账 号 < / Badge > }
< / div >
)
@@ -1761,7 +1855,7 @@ function UserActions({ user, permissionGroups, onDelete }: { user: AdminUser; pe
const canResetPassword = hasPermission ( currentUser , "admin.users.reset_password" )
const update = useMutation ( {
mutationFn : ( payload : { displayName : string ; role : "admin" | "user" ; disabled : boolean ; permissionGroupIds? : string [ ] } ) = > api . updateUser ( user . id , payload ) ,
onSuccess : ( ) = > { invalidateAdmin ( qc ) ; toast ( { title : "用户 已更新" } ) } ,
onSuccess : ( ) = > { invalidateAdmin ( qc ) ; toast ( { title : "账号 已更新" } ) } ,
onError : ( e ) = > toast ( { title : "更新失败" , description : e.message } ) ,
} )
function quickPatch ( patch : Partial < { role : "admin" | "user" ; disabled : boolean } > ) {
@@ -1774,7 +1868,7 @@ function UserActions({ user, permissionGroups, onDelete }: { user: AdminUser; pe
} )
}
if ( ! canUpdate && ! canResetPassword && ! onDelete ) return null
return < > < DropdownMenu > < DropdownMenuTrigger asChild > < Button variant = "ghost" size = "icon" > < MoreHorizontal className = "h-4 w-4" / > < / Button > < / DropdownMenuTrigger > < DropdownMenuContent align = "end" > { canUpdate && < DropdownMenuItem onSelect = { ( ) = > setEditOpen ( true ) } > 编 辑 用 户 < / DropdownMenuItem > } { canResetPassword && < DropdownMenuItem onSelect = { ( ) = > setPasswordOpen ( true ) } > 重 置 密 码 < / DropdownMenuItem > } { ! user . protected && canUpdate && < > < DropdownMenuSeparator / > < DropdownMenuItem onSelect = { ( ) = > quickPatch ( { disabled : ! user . disabled } ) } > { user . disabled ? "启用用户 " : "停用用户 " } < / DropdownMenuItem > < DropdownMenuItem onSelect = { ( ) = > quickPatch ( { role : user.role === "admin" ? "user" : "admin" } ) } > { user . role === "admin" ? "设为普通用户" : "设为超级 管理员" } < / DropdownMenuItem > < / > } { ! user . protected && onDelete && < > < DropdownMenuSeparator / > < DropdownMenuItem className = "text-destructive" onSelect = { onDelete } > 删 除 用 户 < / DropdownMenuItem > < / > } < / DropdownMenuContent > < / DropdownMenu > { canUpdate && < EditUserDialog user = { user } permissionGroups = { permissionGroups } open = { editOpen } onOpenChange = { setEditOpen } / > } { canResetPassword && < ResetPasswordDialog user = { user } open = { passwordOpen } onOpenChange = { setPasswordOpen } / > } < / >
return < > < DropdownMenu > < DropdownMenuTrigger asChild > < Button variant = "ghost" size = "icon" > < MoreHorizontal className = "h-4 w-4" / > < / Button > < / DropdownMenuTrigger > < DropdownMenuContent align = "end" > { canUpdate && < DropdownMenuItem onSelect = { ( ) = > setEditOpen ( true ) } > 编 辑 账 号 < / DropdownMenuItem > } { canResetPassword && < DropdownMenuItem onSelect = { ( ) = > setPasswordOpen ( true ) } > 重 置 密 码 < / DropdownMenuItem > } { ! user . protected && canUpdate && < > < DropdownMenuSeparator / > < DropdownMenuItem onSelect = { ( ) = > quickPatch ( { disabled : ! user . disabled } ) } > { user . disabled ? "启用账号 " : "停用账号 " } < / DropdownMenuItem > < DropdownMenuItem onSelect = { ( ) = > quickPatch ( { role : user.role === "admin" ? "user" : "admin" } ) } > { user . role === "admin" ? "设为普通用户" : "设为管理员" } < / DropdownMenuItem > < / > } { ! user . protected && onDelete && < > < DropdownMenuSeparator / > < DropdownMenuItem className = "text-destructive" onSelect = { onDelete } > 删 除 账 号 < / DropdownMenuItem > < / > } < / DropdownMenuContent > < / DropdownMenu > { canUpdate && < EditUserDialog user = { user } permissionGroups = { permissionGroups } open = { editOpen } onOpenChange = { setEditOpen } / > } { canResetPassword && < ResetPasswordDialog user = { user } open = { passwordOpen } onOpenChange = { setPasswordOpen } / > } < / >
}
function CreateUserDialog ( { permissionGroups } : { permissionGroups : PermissionGroup [ ] } ) {
@@ -1785,23 +1879,32 @@ function CreateUserDialog({ permissionGroups }: { permissionGroups: PermissionGr
const [ status , setStatus ] = React . useState ( "active" )
const [ permissionGroupIds , setPermissionGroupIds ] = React . useState < string [ ] > ( [ ] )
const create = useMutation ( {
mutationFn : ( form : FormData ) = > api . createUser ( { email : String ( form . get ( "email" ) || "" ) , displayName : String ( form . get ( "displayName" ) || "" ) , password : String ( form . get ( "password" ) || "" ) , role , disabled : status === "disabled" , permissionGroupIds : role === "user" ? permissionGroupIds : [ ] } ) ,
onSuccess : ( ) = > { invalidateAdmin ( qc ) ; setOpen ( false ) ; setPermissionGroupIds ( [ ] ) ; toast ( { title : "用户已创建" } ) } ,
mutationFn : ( form : FormData ) = > api . createUser ( {
email : String ( form . get ( "email" ) || "" ) ,
displayName : String ( form . get ( "displayName" ) || "" ) ,
password : String ( form . get ( "password" ) || "" ) ,
role ,
disabled : status === "disabled" ,
mailboxLimitOverride : role === "user" ? mailboxLimitFromForm ( form ) : undefined ,
permissionGroupIds : role === "user" ? permissionGroupIds : [ ] ,
} ) ,
onSuccess : ( ) = > { invalidateAdmin ( qc ) ; setOpen ( false ) ; setPermissionGroupIds ( [ ] ) ; toast ( { title : "账号已创建" } ) } ,
onError : ( e ) = > toast ( { title : "创建失败" , description : e.message } ) ,
} )
return (
< Dialog open = { open } onOpenChange = { setOpen } >
< DialogTrigger asChild > < Button size = "sm" > < Plus className = "h-4 w-4" / > 用 户 < / Button > < / DialogTrigger >
< DialogTrigger asChild > < Button size = "sm" > < Plus className = "h-4 w-4" / > 账 号 < / Button > < / DialogTrigger >
< DialogContent >
< DialogHeader > < DialogTitle > 创 建 用 户 < / DialogTitle > < / DialogHeader >
< DialogHeader > < DialogTitle > 创 建 账 号 < / DialogTitle > < / DialogHeader >
< form className = "space-y-4" onSubmit = { ( event ) = > { event . preventDefault ( ) ; create . mutate ( new FormData ( event . currentTarget ) ) } } >
< Field name = "email" label = "登录邮箱" type = "email" placeholder = "user@example.com" / >
< Field name = "displayName" label = "显示名称" placeholder = "用户 名称" / >
< Field name = "displayName" label = "显示名称" placeholder = "账号 名称" / >
< Field name = "password" label = "初始密码" type = "password" minLength = { 8 } / >
< div className = "grid grid-cols-2 gap-3" >
< SelectField label = "身份" value = { role } onValueChange = { ( value ) = > setRole ( value as "admin" | "user" ) } items = { [ [ "user" , "普通用户" ] , [ "admin" , "超级 管理员" ] ] } / >
< SelectField label = "身份" value = { role } onValueChange = { ( value ) = > setRole ( value as "admin" | "user" ) } items = { [ [ "user" , "普通用户" ] , [ "admin" , "管理员" ] ] } / >
< SelectField label = "状态" value = { status } onValueChange = { setStatus } items = { [ [ "active" , "正常" ] , [ "disabled" , "停用" ] ] } / >
< / div >
{ role === "user" && < MailboxLimitField defaultValue = { defaultMailboxLimitOverride } / > }
{ role === "user" && < PermissionGroupPicker groups = { permissionGroups } value = { permissionGroupIds } onChange = { setPermissionGroupIds } / > }
< DialogFooter > < Button disabled = { create . isPending } > { create . isPending ? "创建中..." : "创建" } < / Button > < / DialogFooter >
< / form >
@@ -1818,26 +1921,61 @@ function MailboxActions({ mailbox, users, canUpdate, onDelete }: { mailbox: Mail
function AliasActions ( { alias , onToggle , onDelete } : { alias : Alias ; onToggle ? : ( ) = > void ; onDelete ? : ( ) = > void } ) {
if ( ! onToggle && ! onDelete ) return null
return < DropdownMenu > < DropdownMenuTrigger asChild > < Button variant = "ghost" size = "icon" > < MoreHorizontal className = "h-4 w-4" / > < / Button > < / DropdownMenuTrigger > < DropdownMenuContent align = "end" > { onToggle && < DropdownMenuItem onSelect = { onToggle } > { alias . enabled ? "停用" : "启用" } < / DropdownMenuItem > } { onToggle && onDelete && < DropdownMenuSeparator / > } { onDelete && < DropdownMenuItem className = "text-destructive" onSelect = { onDelete } > 删 除 别 名 < / DropdownMenuItem > } < / DropdownMenuContent > < / DropdownMenu >
return < DropdownMenu > < DropdownMenuTrigger asChild > < Button variant = "ghost" size = "icon" > < MoreHorizontal className = "h-4 w-4" / > < / Button > < / DropdownMenuTrigger > < DropdownMenuContent align = "end" > { onToggle && < DropdownMenuItem onSelect = { onToggle } > { alias . enabled ? "停用" : "启用" } < / DropdownMenuItem > } { onToggle && onDelete && < DropdownMenuSeparator / > } { onDelete && < DropdownMenuItem className = "text-destructive" onSelect = { onDelete } > 删 除 转 发 < / DropdownMenuItem > } < / DropdownMenuContent > < / DropdownMenu >
}
function EditUserDialog ( { user , permissionGroups , open , onOpenChange } : { user : AdminUser ; permissionGroups : PermissionGroup [ ] ; open : boolean ; onOpenChange : ( open : boolean ) = > void } ) {
const qc = useQueryClient ( ) ; const { toast } = useToast ( ) ; const [ role , setRole ] = React . useState ( user . role ) ; const [ disabled , setDisabled ] = React . useState ( user . disabled ? "disabled" : "active" ) ; const [ permissionGroupIds , setPermissionGroupIds ] = React . useState < string [ ] > ( assignableUserGroupIDs ( user ) )
React . useEffect ( ( ) = > { setRole ( user . role ) ; setDisabled ( user . disabled ? "disabled" : "active" ) ; setPermissionGroupIds ( assignableUserGroupIDs ( user ) ) } , [ user , open ] )
const mut = useMutation ( { mutationFn : ( form : FormData ) = > api . updateUser ( user . id , { displayName : String ( form . get ( "displayName" ) || "" ) , role , disabled : disabled === "disabled" , permissionGroupIds : role === "user" ? permissionGroupIds : [ ] } ) , onSuccess : ( ) = > { invalidateAdmin ( qc ) ; onOpenChange ( false ) ; toast ( { title : "用户已更新" } ) } , onError : ( e ) = > toast ( { title : "更新失败" , description : e.message } ) } )
return < Dialog open = { open } onOpenChange = { onOpenChange } > < DialogContent > < DialogHeader > < DialogTitle > 编 辑 用 户 < / DialogTitle > < / DialogHeader > < form className = "space-y-4" onSubmit = { ( e ) = > { e . preventDefault ( ) ; mut . mutate ( new FormData ( e . currentTarget ) ) } } > < Field name = "email" label = "登录邮箱" value = { user . email } readOnly / > < Field name = "displayName" label = "显示名称" defaultValue = { user . displayName } / > < div className = "grid grid-cols-2 gap-3" > < SelectField label = "身份" value = { role } onValueChange = { ( value ) = > setRole ( value as "admin " | "user" ) } items = { [ [ 'user' , '普通用户' ] , [ 'admin' , '超级管理员' ] ] } disabled = { user . protected } / > < SelectField label = "状态" value = { disabled } onValueChange = { setDisabled } items = { [ [ 'active' , '正常' ] , [ 'disabled' , '停用' ] ] } disabled = { user . protected } / > < / div > { role === "user" && ! user . protected && < PermissionGroupPicker groups = { permissionGroups } value = { permissionGroupIds } onChange = { setPermissionGroupIds } / > } < DialogFooter > < Button disabled = { mut . isPending } > { mut . isPending ? "保存中..." : "保存" } < / Button > < / DialogFooter > < / form > < / DialogContent > < / Dialog >
const qc = useQueryClient ( )
const { toast } = useToast ( )
const [ role , setRole ] = React . useState ( user . role )
const [ disabled , setDisabled ] = React . useState ( user . disabled ? "disabled " : "active" )
const [ permissionGroupIds , setPermissionGroupIds ] = React . useState < string [ ] > ( assignableUserGroupIDs ( user ) )
React . useEffect ( ( ) = > {
setRole ( user . role )
setDisabled ( user . disabled ? "disabled" : "active" )
setPermissionGroupIds ( assignableUserGroupIDs ( user ) )
} , [ user , open ] )
const mut = useMutation ( {
mutationFn : ( form : FormData ) = > api . updateUser ( user . id , {
displayName : String ( form . get ( "displayName" ) || "" ) ,
role ,
disabled : disabled === "disabled" ,
mailboxLimitOverride : role === "user" ? mailboxLimitFromForm ( form , effectiveMailboxLimit ( user ) ) : undefined ,
permissionGroupIds : role === "user" ? permissionGroupIds : [ ] ,
} ) ,
onSuccess : ( ) = > { invalidateAdmin ( qc ) ; onOpenChange ( false ) ; toast ( { title : "账号已更新" } ) } ,
onError : ( e ) = > toast ( { title : "更新失败" , description : e.message } ) ,
} )
return (
< Dialog open = { open } onOpenChange = { onOpenChange } >
< DialogContent >
< DialogHeader > < DialogTitle > 编 辑 账 号 < / DialogTitle > < / DialogHeader >
< form className = "space-y-4" onSubmit = { ( e ) = > { e . preventDefault ( ) ; mut . mutate ( new FormData ( e . currentTarget ) ) } } >
< Field name = "email" label = "登录邮箱" value = { user . email } readOnly / >
< Field name = "displayName" label = "显示名称" defaultValue = { user . displayName } / >
< div className = "grid grid-cols-2 gap-3" >
< SelectField label = "身份" value = { role } onValueChange = { ( value ) = > setRole ( value as "admin" | "user" ) } items = { [ [ "user" , "普通用户" ] , [ "admin" , "管理员" ] ] } disabled = { user . protected } / >
< SelectField label = "状态" value = { disabled } onValueChange = { setDisabled } items = { [ [ "active" , "正常" ] , [ "disabled" , "停用" ] ] } disabled = { user . protected } / >
< / div >
{ role === "user" && ! user . protected && < MailboxLimitField defaultValue = { effectiveMailboxLimit ( user ) } / > }
{ role === "user" && ! user . protected && < PermissionGroupPicker groups = { permissionGroups } value = { permissionGroupIds } onChange = { setPermissionGroupIds } / > }
< DialogFooter > < Button disabled = { mut . isPending } > { mut . isPending ? "保存中..." : "保存" } < / Button > < / DialogFooter >
< / form >
< / DialogContent >
< / Dialog >
)
}
function ResetPasswordDialog ( { user , open , onOpenChange } : { user : AdminUser ; open : boolean ; onOpenChange : ( open : boolean ) = > void } ) {
const { toast } = useToast ( ) ; const mut = useMutation ( { mutationFn : ( form : FormData ) = > api . resetUserPassword ( user . id , String ( form . get ( "password" ) || "" ) ) , onSuccess : ( ) = > { onOpenChange ( false ) ; toast ( { title : "密码已重置" } ) } , onError : ( e ) = > toast ( { title : "重置失败" , description : e.message } ) } )
return < Dialog open = { open } onOpenChange = { onOpenChange } > < DialogContent > < DialogHeader > < DialogTitle > 重 置 密 码 < / DialogTitle > < / DialogHeader > < form className = "space-y-4" onSubmit = { ( e ) = > { e . preventDefault ( ) ; mut . mutate ( new FormData ( e . currentTarget ) ) ; e . currentTarget . reset ( ) } } > < Field name = "email" label = "用户 " value = { user . email } readOnly / > < Field name = "password" label = "新密码" type = "password" minLength = { 8 } / > < DialogFooter > < Button disabled = { mut . isPending } > { mut . isPending ? "重置中..." : "重置" } < / Button > < / DialogFooter > < / form > < / DialogContent > < / Dialog >
return < Dialog open = { open } onOpenChange = { onOpenChange } > < DialogContent > < DialogHeader > < DialogTitle > 重 置 密 码 < / DialogTitle > < / DialogHeader > < form className = "space-y-4" onSubmit = { ( e ) = > { e . preventDefault ( ) ; mut . mutate ( new FormData ( e . currentTarget ) ) ; e . currentTarget . reset ( ) } } > < Field name = "email" label = "账号 " value = { user . email } readOnly / > < Field name = "password" label = "新密码" type = "password" minLength = { 8 } / > < DialogFooter > < Button disabled = { mut . isPending } > { mut . isPending ? "重置中..." : "重置" } < / Button > < / DialogFooter > < / form > < / DialogContent > < / Dialog >
}
function EditMailboxDialog ( { mailbox , users , open , onOpenChange } : { mailbox : MailboxType ; users : AdminUser [ ] ; open : boolean ; onOpenChange : ( open : boolean ) = > void } ) {
const qc = useQueryClient ( ) ; const { toast } = useToast ( ) ; const [ userId , setUserId ] = React . useState ( mailbox . userId ) ; const [ status , setStatus ] = React . useState ( mailbox . status )
React . useEffect ( ( ) = > { setUserId ( mailbox . userId ) ; setStatus ( mailbox . status ) } , [ mailbox , open ] )
const mut = useMutation ( { mutationFn : ( form : FormData ) = > api . updateMailbox ( mailbox . id , { userId , displayName : String ( form . get ( "displayName" ) || "" ) , quotaMb : Number ( form . get ( "quotaMb" ) || 1024 ) , status } ) , onSuccess : ( ) = > { invalidateAdmin ( qc ) ; onOpenChange ( false ) ; toast ( { title : "邮箱已更新" } ) } , onError : ( e ) = > toast ( { title : "更新失败" , description : e.message } ) } )
return < Dialog open = { open } onOpenChange = { onOpenChange } > < DialogContent > < DialogHeader > < DialogTitle > 编 辑 邮 箱 < / DialogTitle > < / DialogHeader > < form className = "space-y-4" onSubmit = { ( e ) = > { e . preventDefault ( ) ; mut . mutate ( new FormData ( e . currentTarget ) ) } } > < Field name = "address" label = "邮箱地址" value = { mailbox . address } readOnly / > < SelectField label = "归属用户 " value = { userId } onValueChange = { setUserId } items = { users . filter ( ( u ) = > ! u . disabled ) . map ( ( u ) = > [ u . id , u . email ] ) } / > < div className = "grid grid-cols-2 gap-3" > < Field name = "displayName" label = "显示名称" defaultValue = { mailbox . displayName } / > < Field name = "quotaMb" label = "配额 MB" type = "number" defaultValue = { String ( mailbox . quotaMb ) } / > < / div > < SelectField label = "状态" value = { status } onValueChange = { setStatus } items = { [ [ 'active' , '启用' ] , [ 'disabled' , '停用' ] ] } / > < DialogFooter > < Button disabled = { mut . isPending } > { mut . isPending ? "保存中..." : "保存" } < / Button > < / DialogFooter > < / form > < / DialogContent > < / Dialog >
return < Dialog open = { open } onOpenChange = { onOpenChange } > < DialogContent > < DialogHeader > < DialogTitle > 编 辑 邮 箱 < / DialogTitle > < / DialogHeader > < form className = "space-y-4" onSubmit = { ( e ) = > { e . preventDefault ( ) ; mut . mutate ( new FormData ( e . currentTarget ) ) } } > < Field name = "address" label = "邮箱地址" value = { mailbox . address } readOnly / > < SelectField label = "归属账号 " value = { userId } onValueChange = { setUserId } items = { users . filter ( ( u ) = > ! u . disabled ) . map ( ( u ) = > [ u . id , u . email ] ) } / > < div className = "grid grid-cols-2 gap-3" > < Field name = "displayName" label = "显示名称" defaultValue = { mailbox . displayName } / > < Field name = "quotaMb" label = "配额 MB" type = "number" defaultValue = { String ( mailbox . quotaMb ) } / > < / div > < SelectField label = "状态" value = { status } onValueChange = { setStatus } items = { [ [ 'active' , '启用' ] , [ 'disabled' , '停用' ] ] } / > < DialogFooter > < Button disabled = { mut . isPending } > { mut . isPending ? "保存中..." : "保存" } < / Button > < / DialogFooter > < / form > < / DialogContent > < / Dialog >
}
function CreateDomainDialog() {
@@ -1850,14 +1988,14 @@ function CreateMailboxDialog({ domains, users }: { domains: Domain[]; users: Adm
const qc = useQueryClient ( ) ; const { toast } = useToast ( ) ; const [ open , setOpen ] = React . useState ( false ) ; const [ domainId , setDomainId ] = React . useState ( "" ) ; const [ role , setRole ] = React . useState ( "user" ) ; const [ ownerMode , setOwnerMode ] = React . useState ( "new" ) ; const [ userId , setUserId ] = React . useState ( "" )
React . useEffect ( ( ) = > { if ( ! domainId && domains [ 0 ] ) setDomainId ( domains [ 0 ] . id ) ; if ( ! userId && users [ 0 ] ) setUserId ( users [ 0 ] . id ) } , [ domains , domainId , users , userId ] )
const mut = useMutation ( { mutationFn : ( form : FormData ) = > api . createMailbox ( { domainId , localPart : String ( form . get ( "localPart" ) ) , displayName : String ( form . get ( "displayName" ) ) , password : String ( form . get ( "password" ) ) , quotaMb : Number ( form . get ( "quotaMb" ) || 1024 ) , role : role as "admin" | "user" , ownerEmail : String ( form . get ( "ownerEmail" ) || "" ) , userId : ownerMode === "existing" ? userId : "" } ) , onSuccess : ( ) = > { invalidateAdmin ( qc ) ; setOpen ( false ) ; toast ( { title : "邮箱已创建" } ) } , onError : ( e ) = > toast ( { title : "创建失败" , description : e.message } ) } )
return < Dialog open = { open } onOpenChange = { setOpen } > < DialogTrigger asChild > < Button > < Plus className = "h-4 w-4" / > 邮 箱 < / Button > < / DialogTrigger > < DialogContent > < DialogHeader > < DialogTitle > 创 建 邮 箱 账 号 < / DialogTitle > < / DialogHeader > < form className = "space-y-4" onSubmit = { ( e ) = > { e . preventDefault ( ) ; mut . mutate ( new FormData ( e . currentTarget ) ) } } > < DomainSelect domains = { domains } value = { domainId } onChange = { setDomainId } / > < div className = "grid grid-cols-2 gap-3" > < Field name = "localPart" label = "账号" placeholder = "alice" / > < Field name = "displayName" label = "显示名" placeholder = "Alice" / > < / div > < SelectField label = "归属方式" value = { ownerMode } onValueChange = { setOwnerMode } items = { [ [ 'new' , '新建/按邮箱匹配用户 ' ] , [ 'existing' , '追加到已有用户 ' ] ] } / > { ownerMode === "existing" ? < SelectField label = "已有用户 " value = { userId } onValueChange = { setUserId } items = { users . filter ( ( u ) = > ! u . disabled ) . map ( ( u ) = > [ u . id , u . email ] ) } / > : < Field name = "ownerEmail" label = "归属用户 邮箱" placeholder = "留空则使用新邮箱" required = { false } / > } < div className = "grid grid-cols-2 gap-3" > < Field name = "password" label = "密码" type = "password" placeholder = "至少 8 位" / > < Field name = "quotaMb" label = "配额 MB" type = "number" defaultValue = "1024" / > < / div > < SelectField label = "身份" value = { role } onValueChange = { setRole } items = { [ [ 'user' , '普通用户' ] , [ 'admin' , '超级 管理员' ] ] } / > < DialogFooter > < Button disabled = { mut . isPending || ! domainId } > 创 建 < / Button > < / DialogFooter > < / form > < / DialogContent > < / Dialog >
return < Dialog open = { open } onOpenChange = { setOpen } > < DialogTrigger asChild > < Button > < Plus className = "h-4 w-4" / > 邮 箱 < / Button > < / DialogTrigger > < DialogContent > < DialogHeader > < DialogTitle > 创 建 邮 箱 < / DialogTitle > < / DialogHeader > < form className = "space-y-4" onSubmit = { ( e ) = > { e . preventDefault ( ) ; mut . mutate ( new FormData ( e . currentTarget ) ) } } > < DomainSelect domains = { domains } value = { domainId } onChange = { setDomainId } / > < div className = "grid grid-cols-2 gap-3" > < Field name = "localPart" label = "账号" placeholder = "alice" / > < Field name = "displayName" label = "显示名" placeholder = "Alice" / > < / div > < SelectField label = "归属方式" value = { ownerMode } onValueChange = { setOwnerMode } items = { [ [ 'new' , '新建/按邮箱匹配账号 ' ] , [ 'existing' , '追加到已有账号 ' ] ] } / > { ownerMode === "existing" ? < SelectField label = "已有账号 " value = { userId } onValueChange = { setUserId } items = { users . filter ( ( u ) = > ! u . disabled ) . map ( ( u ) = > [ u . id , u . email ] ) } / > : < Field name = "ownerEmail" label = "归属账号 邮箱" placeholder = "留空则使用新邮箱" required = { false } / > } < div className = "grid grid-cols-2 gap-3" > < Field name = "password" label = "密码" type = "password" placeholder = "至少 8 位" / > < Field name = "quotaMb" label = "配额 MB" type = "number" defaultValue = "1024" / > < / div > < SelectField label = "身份" value = { role } onValueChange = { setRole } items = { [ [ 'user' , '普通用户' ] , [ 'admin' , '管理员' ] ] } / > < DialogFooter > < Button disabled = { mut . isPending || ! domainId } > 创 建 < / Button > < / DialogFooter > < / form > < / DialogContent > < / Dialog >
}
function CreateAliasDialog ( { domains } : { domains : Domain [ ] } ) {
const qc = useQueryClient ( ) ; const { toast } = useToast ( ) ; const [ open , setOpen ] = React . useState ( false ) ; const [ domainId , setDomainId ] = React . useState ( "" )
React . useEffect ( ( ) = > { if ( ! domainId && domains [ 0 ] ) setDomainId ( domains [ 0 ] . id ) } , [ domains , domainId ] )
const mut = useMutation ( { mutationFn : ( form : FormData ) = > api . createAlias ( { domainId , source : String ( form . get ( "source" ) ) , destination : String ( form . get ( "destination" ) ) , enabled : true } ) , onSuccess : ( ) = > { invalidateAdmin ( qc ) ; setOpen ( false ) ; toast ( { title : "别名 已创建" } ) } , onError : ( e ) = > toast ( { title : "创建失败" , description : e.message } ) } )
return < Dialog open = { open } onOpenChange = { setOpen } > < DialogTrigger asChild > < Button variant = "outline" > < Plus className = "h-4 w-4" / > 别 名 < / Button > < / DialogTrigger > < DialogContent > < DialogHeader > < DialogTitle > 创 建 别 名 / 转 发 < / DialogTitle > < / DialogHeader > < form className = "space-y-4" onSubmit = { ( e ) = > { e . preventDefault ( ) ; mut . mutate ( new FormData ( e . currentTarget ) ) } } > < DomainSelect domains = { domains } value = { domainId } onChange = { setDomainId } / > < Field name = "source" label = "来源" placeholder = "sales 或 sales@example.com" / > < Field name = "destination" label = "目标邮箱" placeholder = "alice@example.com" / > < DialogFooter > < Button disabled = { mut . isPending || ! domainId } > 创 建 < / Button > < / DialogFooter > < / form > < / DialogContent > < / Dialog >
const mut = useMutation ( { mutationFn : ( form : FormData ) = > api . createAlias ( { domainId , source : String ( form . get ( "source" ) ) , destination : String ( form . get ( "destination" ) ) , enabled : true } ) , onSuccess : ( ) = > { invalidateAdmin ( qc ) ; setOpen ( false ) ; toast ( { title : "转发 已创建" } ) } , onError : ( e ) = > toast ( { title : "创建失败" , description : e.message } ) } )
return < Dialog open = { open } onOpenChange = { setOpen } > < DialogTrigger asChild > < Button variant = "outline" > < Plus className = "h-4 w-4" / > 转 发 < / Button > < / DialogTrigger > < DialogContent > < DialogHeader > < DialogTitle > 创 建 邮 件 转 发 < / DialogTitle > < / DialogHeader > < form className = "space-y-4" onSubmit = { ( e ) = > { e . preventDefault ( ) ; mut . mutate ( new FormData ( e . currentTarget ) ) } } > < DomainSelect domains = { domains } value = { domainId } onChange = { setDomainId } / > < Field name = "source" label = "来源" placeholder = "sales 或 sales@example.com" / > < Field name = "destination" label = "目标邮箱" placeholder = "alice@example.com" / > < DialogFooter > < Button disabled = { mut . isPending || ! domainId } > 创 建 < / Button > < / DialogFooter > < / form > < / DialogContent > < / Dialog >
}
function DNSPanel ( { domain , embedded = false } : { domain? : Domain ; embedded? : boolean } ) {
@@ -1930,8 +2068,21 @@ function SwitchRow({ label, checked, onCheckedChange, className = "" }: { label:
)
}
function Field ( { label , required = true , . . . props } : React . InputHTMLAttributes < HTMLInputElement > & { label : string } ) { return < div className = "space-y-2" > < Label > { label } < / Label > < Input required = { required } { ...props } / > < / div > }
function MailboxLimitField ( { defaultValue } : { defaultValue : number } ) {
return (
< div className = "space-y-2" >
< Label > 邮 箱 数 量 上 限 < / Label >
< Input name = "mailboxLimitOverride" type = "number" min = { 0 } step = { 1 } defaultValue = { String ( defaultValue ) } / >
< div className = "text-xs text-muted-foreground" > 普 通 用 户 默 认 9 个 , 填 0 表 示 不 限 制 。 < / div >
< / div >
)
}
function mailboxLimitFromForm ( form : FormData , fallback = defaultMailboxLimitOverride ) {
const value = Number ( form . get ( "mailboxLimitOverride" ) || fallback )
return Number . isFinite ( value ) && value >= 0 ? Math . floor ( value ) : fallback
}
function effectiveMailboxLimit ( user : AdminUser ) {
return user . mailboxLimitOverride ? ? user . limits ? . maxMailboxCount ? ? defaultMailboxLimitOverride
}
function SelectField ( { label , value , onValueChange , items , disabled = false } : { label : string ; value : string ; onValueChange : ( value : string ) = > void ; items : string [ ] [ ] ; disabled? : boolean } ) { return < div className = "space-y-2" > < Label > { label } < / Label > < Select value = { value } onValueChange = { onValueChange } disabled = { disabled } > < SelectTrigger > < SelectValue / > < / SelectTrigger > < SelectContent > { items . map ( ( [ value , label ] ) = > < SelectItem key = { value } value = { value } > { label } < / SelectItem > ) } < / SelectContent > < / Select > < / div > }
function DomainSelect ( { domains , value , onChange } : { domains : Domain [ ] ; value : string ; onChange : ( value : string ) = > void } ) { return < div className = "space-y-2" > < Label > 域 名 < / Label > < Select value = { value } onValueChange = { onChange } > < SelectTrigger > < SelectValue placeholder = "选择域名" / > < / SelectTrigger > < SelectContent > { domains . map ( ( d ) = > < SelectItem key = { d . id } value = { d . id } > { d . name } < / SelectItem > ) } < / SelectContent > < / Select > < / div > }