Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6021e04f0d |
@@ -1,4 +0,0 @@
|
||||
- 将邮箱、个人设置和后台管理的当前选中项统一为清晰的淡蓝色,并补齐悬停、二级标签和邮件列表选中状态。
|
||||
- 优化后台数据总览:合并重复指标,改为四项核心数据、紧凑首次配置和统一系统状态布局,并确保公网地址完整显示。
|
||||
|
||||
**完整更新日志**:[v1.2.41...v1.2.42](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.41...v1.2.42)
|
||||
@@ -1,6 +0,0 @@
|
||||
- 将后台首页升级为紧凑的邮件系统仪表盘,优化核心指标、邮件运行概览、系统健康、域名状态和首次配置入口。
|
||||
- 优化后台、登录与邮箱界面细节,统一品牌图标、通知位置、选中状态、按钮边框与移动端布局,并修复全部邮件页面横向溢出。
|
||||
- 修复 Apple 等邮件的 GB2312、GBK、GB18030 标题乱码,改进账号切换后的邮箱文件夹显示与创建范围提示。
|
||||
- 精简系统设置,移除“关于”标签和相关内容;版本检查仍可通过左侧版本号入口使用。
|
||||
|
||||
**完整更新日志**:[v1.2.42...v1.2.43](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.42...v1.2.43)
|
||||
@@ -1,5 +0,0 @@
|
||||
- 修复刷新后台仪表盘时,注册、自助申请邮箱等功能在设置读取完成前短暂显示为关闭的问题。
|
||||
- 为后台统计、系统状态、域名与列表页面、备份设置和个人中心补充统一加载状态,避免将未加载数据误显示为 0、未配置或暂无数据。
|
||||
- 优化受限权限账号的状态展示:无权读取的系统设置明确显示为不可查看,不再误判为关闭或未配置。
|
||||
|
||||
**完整更新日志**:[v1.2.43...v1.2.44](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.43...v1.2.44)
|
||||
@@ -0,0 +1,10 @@
|
||||
# Third-party notices
|
||||
|
||||
## PurCarte theme background
|
||||
|
||||
`apps/web/public/purcarte-moonlit.webp` is distributed with
|
||||
[komari-theme-purcarte](https://github.com/Montia37/komari-theme-purcarte),
|
||||
copyright (c) 2025 Montia37, under the MIT License.
|
||||
|
||||
The project uses the asset as a replaceable interface background and does not
|
||||
include the PurCarte application code.
|
||||
@@ -24,36 +24,23 @@ func (a *App) handleAdminOverview(w http.ResponseWriter, r *http.Request) {
|
||||
Messages int64 `json:"messages"`
|
||||
UnreadMessages int64 `json:"unreadMessages"`
|
||||
StorageBytes int64 `json:"storageBytes"`
|
||||
TodaySent int64 `json:"todaySent"`
|
||||
TodayReceived int64 `json:"todayReceived"`
|
||||
SendDelivered int64 `json:"sendDelivered"`
|
||||
SendFailed int64 `json:"sendFailed"`
|
||||
QueueMessages int64 `json:"queueMessages"`
|
||||
}
|
||||
now := a.now().UTC()
|
||||
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)
|
||||
queries := []struct {
|
||||
q string
|
||||
dest *int64
|
||||
args []any
|
||||
}{
|
||||
{q: `SELECT COUNT(*) FROM users`, dest: &out.Users},
|
||||
{q: `SELECT COUNT(*) FROM users WHERE disabled=0`, dest: &out.ActiveUsers},
|
||||
{q: `SELECT COUNT(*) FROM domains`, dest: &out.Domains},
|
||||
{q: `SELECT COUNT(*) FROM mailboxes`, dest: &out.Mailboxes},
|
||||
{q: `SELECT COUNT(*) FROM mailboxes WHERE status='active'`, dest: &out.ActiveMailboxes},
|
||||
{q: `SELECT COUNT(*) FROM aliases`, dest: &out.Aliases},
|
||||
{q: `SELECT COUNT(*) FROM messages`, dest: &out.Messages},
|
||||
{q: `SELECT COUNT(*) FROM messages WHERE is_read=0`, dest: &out.UnreadMessages},
|
||||
{q: `SELECT COALESCE(SUM(size_bytes),0) FROM messages`, dest: &out.StorageBytes},
|
||||
{q: `SELECT COUNT(m.id) FROM messages m JOIN folders f ON f.id=m.folder_id WHERE f.role='sent' AND m.sent_at>=?`, dest: &out.TodaySent, args: []any{todayStart}},
|
||||
{q: `SELECT COUNT(m.id) FROM messages m JOIN folders f ON f.id=m.folder_id WHERE f.role NOT IN ('sent','drafts') AND m.received_at>=?`, dest: &out.TodayReceived, args: []any{todayStart}},
|
||||
{q: `SELECT COUNT(*) FROM send_queue WHERE status=? AND created_at>=?`, dest: &out.SendDelivered, args: []any{sendQueueStatusDelivered, todayStart}},
|
||||
{q: `SELECT COUNT(*) FROM send_queue WHERE status=? AND created_at>=?`, dest: &out.SendFailed, args: []any{sendQueueStatusFailed, todayStart}},
|
||||
{q: `SELECT COUNT(*) FROM send_queue WHERE status IN (?,?)`, dest: &out.QueueMessages, args: []any{sendQueueStatusQueued, sendQueueStatusSending}},
|
||||
{`SELECT COUNT(*) FROM users`, &out.Users},
|
||||
{`SELECT COUNT(*) FROM users WHERE disabled=0`, &out.ActiveUsers},
|
||||
{`SELECT COUNT(*) FROM domains`, &out.Domains},
|
||||
{`SELECT COUNT(*) FROM mailboxes`, &out.Mailboxes},
|
||||
{`SELECT COUNT(*) FROM mailboxes WHERE status='active'`, &out.ActiveMailboxes},
|
||||
{`SELECT COUNT(*) FROM aliases`, &out.Aliases},
|
||||
{`SELECT COUNT(*) FROM messages`, &out.Messages},
|
||||
{`SELECT COUNT(*) FROM messages WHERE is_read=0`, &out.UnreadMessages},
|
||||
{`SELECT COALESCE(SUM(size_bytes),0) FROM messages`, &out.StorageBytes},
|
||||
}
|
||||
for _, item := range queries {
|
||||
if err := a.db.QueryRowContext(r.Context(), item.q, item.args...).Scan(item.dest); err != nil {
|
||||
if err := a.db.QueryRowContext(r.Context(), item.q).Scan(item.dest); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load overview")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
|
||||
"golang.org/x/text/encoding"
|
||||
"golang.org/x/text/encoding/ianaindex"
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
)
|
||||
|
||||
type maildirMailbox struct {
|
||||
@@ -823,14 +822,6 @@ func charsetReader(charset string, input io.Reader) (io.Reader, error) {
|
||||
if charset == "utf-8" || charset == "us-ascii" {
|
||||
return input, nil
|
||||
}
|
||||
// GB2312 is commonly used as a label for GBK-compatible mail content.
|
||||
// ianaindex does not consistently resolve these real-world aliases.
|
||||
switch charset {
|
||||
case "gb2312", "gb_2312-80", "x-gbk", "euc-cn", "cp936", "ms936", "windows-936":
|
||||
return simplifiedchinese.GBK.NewDecoder().Reader(input), nil
|
||||
case "gb18030":
|
||||
return simplifiedchinese.GB18030.NewDecoder().Reader(input), nil
|
||||
}
|
||||
enc, err := ianaindex.IANA.Encoding(charset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unsupported charset %q: %w", charset, err)
|
||||
|
||||
@@ -2,7 +2,6 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -356,37 +355,6 @@ func TestTelegramMailboxScopeAndOriginalRecipient(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMaildirMessageDecodesAppleGB2312(t *testing.T) {
|
||||
subject := "验证 Apple 账户电子邮件地址"
|
||||
body := "你的 Apple 验证码是 978534"
|
||||
encodedSubject, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(subject))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encodedBody, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw := []byte("From: Apple <appleid@id.apple.com>\r\n" +
|
||||
"To: admin@example.com\r\n" +
|
||||
"Subject: =?gb2312?B?" + base64.StdEncoding.EncodeToString(encodedSubject) + "?=\r\n" +
|
||||
"Content-Type: text/plain; charset=gb2312\r\n" +
|
||||
"Content-Transfer-Encoding: base64\r\n\r\n" +
|
||||
base64.StdEncoding.EncodeToString(encodedBody))
|
||||
a := newTestApp(t)
|
||||
stopTestWorkers(a)
|
||||
msg, _, err := a.parseMaildirMessage(raw, "admin@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msg.Subject != subject {
|
||||
t.Fatalf("GB2312 subject was not decoded: %q", msg.Subject)
|
||||
}
|
||||
if msg.BodyText != body {
|
||||
t.Fatalf("GB2312 body was not decoded: %q", msg.BodyText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramBadRequestFallsBackToPlainText(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
@@ -1,11 +0,0 @@
|
||||
import { Mail } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function BrandMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<span className={cn("grid size-9 shrink-0 place-items-center rounded-md border border-primary/20 bg-primary/[0.03] text-primary", className)} aria-hidden="true">
|
||||
<Mail className="size-6 stroke-[1.8]" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import * as React from "react"
|
||||
import { Outlet, Link, useLocation } from "react-router-dom"
|
||||
import { ArchiveRestore, ClipboardList, Forward, Globe2, Inbox, LayoutDashboard, LogOut, Mailbox, Settings, ShieldCheck, UserCog } from "lucide-react"
|
||||
import { ArchiveRestore, BarChart3, ClipboardList, Forward, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, ShieldCheck, UserCog } from "lucide-react"
|
||||
import { useMe } from "@/hooks/use-me"
|
||||
import { useLogout } from "@/hooks/use-logout"
|
||||
import { AuthGuard } from "@/components/auth-guard"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import { SystemVersionDialog } from "@/components/system-version-dialog"
|
||||
import { BrandMark } from "@/components/brand-mark"
|
||||
import { hasAnyPermission } from "@/lib/permissions"
|
||||
import type { PermissionKey } from "@/lib/api-types"
|
||||
import {
|
||||
@@ -22,13 +21,12 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
const adminSections: { key: string; label: string; icon: React.ReactNode; permissions: PermissionKey[] }[] = [
|
||||
{ key: "overview", label: "仪表盘", icon: <LayoutDashboard />, permissions: ["admin.overview.view"] },
|
||||
{ key: "overview", label: "数据总览", icon: <BarChart3 />, permissions: ["admin.overview.view"] },
|
||||
{ key: "users", label: "账号管理", icon: <UserCog />, permissions: ["admin.users.view"] },
|
||||
{ key: "permissionGroups", label: "权限配置", icon: <ShieldCheck />, permissions: ["admin.permission_groups.view"] },
|
||||
{ key: "domains", label: "域名管理", icon: <Globe2 />, permissions: ["admin.domains.view", "admin.dns.view"] },
|
||||
@@ -65,15 +63,17 @@ function ProtectedContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarProvider className="admin-app-shell">
|
||||
<Sidebar collapsible="none" className="admin-sidebar-panel">
|
||||
<SidebarHeader className="border-b">
|
||||
<div className="space-y-1 group-data-[collapsible=icon]:space-y-0">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" asChild>
|
||||
<Link to="/">
|
||||
<BrandMark className="size-8 rounded-md [&>svg]:size-5" />
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<Mail className="size-4" />
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">NewSzxcn 邮箱</span>
|
||||
</div>
|
||||
@@ -98,7 +98,7 @@ function ProtectedContent() {
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" className="group-data-[collapsible=icon]:!p-0" asChild>
|
||||
<SidebarMenuButton size="lg" asChild>
|
||||
<Link to="/profile">
|
||||
<Avatar className="h-8 w-8 rounded-lg">
|
||||
<AvatarFallback className="rounded-lg bg-muted text-foreground">
|
||||
@@ -114,15 +114,14 @@ function ProtectedContent() {
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
<div className="p-2">
|
||||
<Button variant="outline" size="sm" className="w-full gap-2 border-destructive/35 text-xs text-destructive shadow-none hover:border-destructive/55 hover:bg-destructive/10 hover:text-destructive dark:border-destructive/45 dark:hover:bg-destructive/15" onClick={logout}>
|
||||
<LogOut className="h-3.5 w-3.5" />退出登录
|
||||
<Button variant="destructive" size="sm" className="w-full gap-2 text-xs" onClick={logout} aria-label="退出登录" title="退出登录">
|
||||
<LogOut className="h-3.5 w-3.5" /><span>退出登录</span>
|
||||
</Button>
|
||||
</div>
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
<SidebarInset>
|
||||
<div className="flex min-h-svh flex-col bg-muted/20">
|
||||
<SidebarInset className="admin-content-panel">
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-transparent">
|
||||
<div className="flex h-12 items-center gap-3 border-b bg-background px-3 md:hidden">
|
||||
<SidebarTrigger aria-label="打开导航" />
|
||||
<div className="min-w-0 flex-1 truncate text-sm font-semibold">
|
||||
|
||||
@@ -36,7 +36,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card text-card-foreground p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -180,21 +180,6 @@ const Sidebar = React.forwardRef<
|
||||
) => {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
@@ -219,6 +204,22 @@ const Sidebar = React.forwardRef<
|
||||
)
|
||||
}
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
className={cn(
|
||||
"hidden min-h-svh w-[--sidebar-width] shrink-0 self-stretch flex-col bg-sidebar text-sidebar-foreground md:flex",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -519,7 +520,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-[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",
|
||||
"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-[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 +622,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-[hsl(var(--sidebar-active-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-sidebar-accent-foreground md:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -639,7 +640,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-[hsl(var(--sidebar-active-foreground))]",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-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",
|
||||
|
||||
@@ -16,7 +16,7 @@ const ToastViewport = React.forwardRef<
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-x-0 top-0 z-[100] flex max-h-screen w-full flex-col items-end gap-2 p-3 pt-[max(0.75rem,env(safe-area-inset-top))] sm:left-auto sm:right-0 sm:max-w-[420px] sm:p-4",
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -25,11 +25,11 @@ const ToastViewport = React.forwardRef<
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-start justify-between gap-3 overflow-hidden rounded-md border bg-popover p-4 pr-9 text-popover-foreground shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-3 sm:data-[state=open]:slide-in-from-right-full",
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-border/80 bg-popover text-popover-foreground",
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
@@ -81,7 +81,7 @@ const ToastClose = React.forwardRef<
|
||||
props.onClick?.(event)
|
||||
}}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 transition-colors hover:bg-accent hover:text-foreground focus:outline-none focus:ring-1 focus:ring-ring group-[.destructive]:text-red-200 group-[.destructive]:hover:bg-red-950/20 group-[.destructive]:hover:text-white group-[.destructive]:focus:ring-red-300",
|
||||
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
@@ -98,7 +98,7 @@ const ToastTitle = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("break-words text-sm font-semibold leading-5", className)}
|
||||
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
@@ -110,7 +110,7 @@ const ToastDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("line-clamp-3 break-all text-sm leading-5 text-muted-foreground group-[.destructive]:text-destructive-foreground/90", className)}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -14,11 +14,11 @@ export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider duration={5000} swipeDirection="right">
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
|
||||
@@ -5,8 +5,8 @@ import type {
|
||||
ToastProps,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 3
|
||||
const TOAST_REMOVE_DELAY = 1000
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
|
||||
+245
-37
@@ -37,9 +37,9 @@
|
||||
--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%;
|
||||
--mail-selected: 210 100% 96%;
|
||||
}
|
||||
|
||||
* { @apply border-border; }
|
||||
@@ -60,41 +60,42 @@
|
||||
html.dark { color-scheme: dark; }
|
||||
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--action-primary: 240 4% 24%;
|
||||
--action-primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 72%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--background: 220 4% 14%;
|
||||
--foreground: 0 0% 94%;
|
||||
--card: 220 4% 16%;
|
||||
--card-foreground: 0 0% 94%;
|
||||
--popover: 220 4% 18%;
|
||||
--popover-foreground: 0 0% 94%;
|
||||
--primary: 204 100% 55%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--action-primary: 220 4% 25%;
|
||||
--action-primary-foreground: 0 0% 94%;
|
||||
--secondary: 220 4% 21%;
|
||||
--secondary-foreground: 0 0% 94%;
|
||||
--muted: 220 4% 21%;
|
||||
--muted-foreground: 220 3% 72%;
|
||||
--accent: 220 4% 25%;
|
||||
--accent-foreground: 0 0% 96%;
|
||||
--destructive: 359 74% 47%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 0 0% 98%;
|
||||
--sidebar-primary-foreground: 240 5.9% 10%;
|
||||
--sidebar-accent: 240 3.7% 15.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%;
|
||||
--border: 0 0% 100% / 0.12;
|
||||
--input: 0 0% 100% / 0.16;
|
||||
--ring: 204 100% 55%;
|
||||
--sidebar-background: 220 4% 19%;
|
||||
--sidebar-foreground: 0 0% 86%;
|
||||
--sidebar-primary: 204 100% 55%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 220 4% 25%;
|
||||
--sidebar-accent-foreground: 0 0% 96%;
|
||||
--sidebar-border: 0 0% 100% / 0.1;
|
||||
--sidebar-ring: 204 100% 55%;
|
||||
--sidebar-active: 204 100% 29%;
|
||||
--sidebar-active-foreground: 204 100% 76%;
|
||||
--compose-send: 204 100% 40%;
|
||||
--compose-send-hover: 204 100% 47%;
|
||||
--mail-selected: 220 5% 24%;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@layer components {
|
||||
@@ -130,10 +131,166 @@
|
||||
max-width: 384px;
|
||||
}
|
||||
|
||||
.mail-selected-row {
|
||||
background-color: hsl(var(--mail-selected));
|
||||
.dark .mail-list-pane {
|
||||
border-color: hsl(0 0% 100% / 0.16);
|
||||
}
|
||||
|
||||
.dark .mail-sidebar-pane {
|
||||
border-color: hsl(0 0% 100% / 0.14);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.mail-app-shell,
|
||||
.settings-app-shell,
|
||||
.admin-app-shell {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
background-color: hsl(var(--background));
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.mail-sidebar-wrap,
|
||||
.mail-list-pane,
|
||||
.mail-detail-pane,
|
||||
.settings-sidebar-panel,
|
||||
.settings-content-panel,
|
||||
.admin-content-panel {
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
background-color: rgb(255 255 255) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.mail-sidebar-wrap,
|
||||
.settings-sidebar-panel {
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.admin-sidebar-panel,
|
||||
.admin-sidebar-panel [data-sidebar="sidebar"] {
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
background-color: rgb(255 255 255) !important;
|
||||
box-shadow: none;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.mail-sidebar-pane {
|
||||
background-color: transparent !important;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.mail-list-surface,
|
||||
.mail-detail-surface,
|
||||
.mail-content-grid,
|
||||
.compact-mail-surface {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.settings-content-panel > main,
|
||||
.admin-content-panel > div {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.settings-glass-content [data-slot="card"],
|
||||
.settings-glass-content main > div > section.bg-card,
|
||||
.settings-glass-content main > div > div > section.bg-card {
|
||||
background-color: rgb(255 255 255) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.dark .mail-app-shell,
|
||||
.dark .settings-app-shell,
|
||||
.dark .admin-app-shell {
|
||||
background-color: hsl(220 4% 13%);
|
||||
background-image:
|
||||
linear-gradient(hsl(220 20% 4% / 0.34), hsl(220 20% 4% / 0.34)),
|
||||
url("/purcarte-moonlit.webp");
|
||||
}
|
||||
|
||||
.dark .mail-sidebar-wrap,
|
||||
.dark .mail-list-pane,
|
||||
.dark .mail-detail-pane,
|
||||
.dark .settings-sidebar-panel,
|
||||
.dark .settings-content-panel,
|
||||
.dark .admin-content-panel {
|
||||
border-color: hsl(0 0% 100% / 0.14);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.dark .mail-sidebar-wrap {
|
||||
background-color: rgb(0 0 0 / 0.5) !important;
|
||||
}
|
||||
|
||||
.dark .mail-sidebar-pane {
|
||||
background-color: transparent !important;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.dark .mail-list-pane {
|
||||
background-color: rgb(0 0 0 / 0.5) !important;
|
||||
}
|
||||
|
||||
.dark .mail-list-surface {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.dark .mail-detail-pane,
|
||||
.dark .mail-detail-surface {
|
||||
background-color: rgb(0 0 0 / 0.5) !important;
|
||||
}
|
||||
|
||||
.dark .mail-content-grid {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.dark .compact-mail-surface {
|
||||
background-color: rgb(0 0 0 / 0.5) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.dark .settings-sidebar-panel,
|
||||
.dark .settings-content-panel,
|
||||
.dark .admin-content-panel {
|
||||
background-color: rgb(0 0 0 / 0.5) !important;
|
||||
}
|
||||
|
||||
.dark .admin-sidebar-panel,
|
||||
.dark .admin-sidebar-panel [data-sidebar="sidebar"] {
|
||||
background-color: rgb(0 0 0 / 0.5) !important;
|
||||
}
|
||||
|
||||
.dark .settings-glass-content [data-slot="card"],
|
||||
.dark .settings-glass-content main > div > section.bg-card,
|
||||
.dark .settings-glass-content main > div > div > section.bg-card {
|
||||
background-color: rgb(0 0 0 / 0.5) !important;
|
||||
}
|
||||
|
||||
.dark .admin-page [data-slot="card"],
|
||||
.dark .admin-page .overview-setup-panel {
|
||||
background-color: rgb(0 0 0 / 0.5) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.mail-shell-grid,
|
||||
.settings-shell-grid {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mail-content-grid {
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.mail-content-grid {
|
||||
@@ -180,7 +337,58 @@
|
||||
|
||||
.admin-page [data-slot="card"] {
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: none;
|
||||
border-color: hsl(var(--border));
|
||||
background-color: rgb(255 255 255) !important;
|
||||
box-shadow: 0 1px 2px hsl(0 0% 0% / 0.04);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.admin-page .overview-setup-panel {
|
||||
background-color: rgb(255 255 255) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.overview-stat {
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.overview-stat:nth-child(2n) {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.overview-stat:nth-last-child(-n + 2) {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.overview-stat:nth-child(2n) {
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.overview-stat:nth-child(4n) {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.overview-stat:nth-last-child(-n + 4) {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.overview-stat {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.overview-stat:nth-child(4n) {
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.overview-stat:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-page [data-slot="card-header"] {
|
||||
|
||||
@@ -53,11 +53,7 @@ export type PermissionGroup = { id: string; name: string; description: string; p
|
||||
export type User = { id: string; loginName?: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; protected: boolean; twoFactorEnabled: boolean; mailboxLimitOverride?: number | null; permissions: PermissionKey[]; limits: PermissionLimits; permissionGroupIds: string[]; permissionGroups: PermissionGroupSummary[]; createdAt: string }
|
||||
export type APIToken = { id: string; name: string; lastUsedAt?: string; expiresAt?: string; disabled: boolean; scopes: string[]; createdAt: string; updatedAt: string }
|
||||
export type AdminUser = User & { mailboxCount: number; mailboxes?: string[]; storageQuotaMb: number }
|
||||
export type AdminOverview = {
|
||||
users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number
|
||||
aliases: number; messages: number; unreadMessages: number; storageBytes: number
|
||||
todaySent: number; todayReceived: number; sendDelivered: number; sendFailed: number; queueMessages: number
|
||||
}
|
||||
export type AdminOverview = { users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number; storageBytes: number }
|
||||
export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string }
|
||||
export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; primary?: boolean; unreadCount?: number; createdAt: string }
|
||||
export type Alias = { id: string; domainId: string; source: string; destination: string; enabled: boolean; createdAt: string }
|
||||
|
||||
+237
-346
@@ -2,8 +2,8 @@ import * as React from "react"
|
||||
import DOMPurify from "dompurify"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { AlertCircle, CheckCircle2, ChevronDown, ChevronRight, Circle, ClipboardList, Clock3, Cloud, Copy, Database, Download, ExternalLink, Eye, EyeOff, Globe2, HardDrive, KeyRound, Loader2, Mail, MoreHorizontal, RefreshCcw, Search, Send, ShieldCheck, Trash2, UserRound } from "lucide-react"
|
||||
import { api, AdminOverview, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, MaildirSyncHealth, PermissionGroup, PermissionInfo, PermissionLimits, SystemSettings } from "@/lib/api"
|
||||
import { 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"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -17,30 +17,30 @@ import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog"
|
||||
import { SystemVersionDialog } from "@/components/system-version-dialog"
|
||||
import { useMe } from "@/hooks/use-me"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import { hasAnyPermission, hasPermission } from "@/lib/permissions"
|
||||
import type { BackupTransfer, PermissionKey, TelegramPairing } from "@/lib/api-types"
|
||||
|
||||
type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "sendAudit" | "backups" | "settings"
|
||||
type SettingsTab = "base" | "smtp" | "storage" | "mail" | "notifications" | "externalImap" | "templates" | "security"
|
||||
type SettingsTab = "base" | "smtp" | "storage" | "mail" | "notifications" | "externalImap" | "templates" | "security" | "about"
|
||||
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
|
||||
|
||||
const sectionMeta: Record<Section, { label: string; description: string }> = {
|
||||
overview: { label: "仪表盘", description: "邮件运行、域名与系统状态集中查看。" },
|
||||
users: { label: "账号管理", description: "管理登录账号、身份状态、邮箱数量上限和共享存储容量。" },
|
||||
permissionGroups: { label: "权限配置", description: "配置自定义权限、发信频率、附件和邮箱创建额度。" },
|
||||
domains: { label: "域名管理", description: "维护邮件域名、DKIM 和 DNS 检测。" },
|
||||
mailboxes: { label: "邮箱管理", description: "按归属账号查看和管理子邮箱,默认邮箱受保护。" },
|
||||
aliases: { label: "邮件转发", description: "管理域名转发规则。" },
|
||||
messages: { label: "全部邮件", description: "按邮箱、文件夹和关键词查看全站邮件。" },
|
||||
sendAudit: { label: "发送队列", description: "查看发信投递、重试和失败记录。" },
|
||||
backups: { label: "备份与恢复", description: "创建、校验和下载可迁移的加密完整备份。" },
|
||||
settings: { label: "系统设置", description: "管理站点、发信、存储、注册、安全和邮件模板。" },
|
||||
const sectionMeta: Record<Section, { label: string; frontLabel: string; description: string }> = {
|
||||
overview: { label: "数据总览", frontLabel: "数据统计", description: "系统运行、DNS、邮箱和消息状态集中查看。" },
|
||||
users: { label: "账号管理", frontLabel: "账号设置", description: "管理登录账号、身份状态、邮箱数量上限和共享存储容量。" },
|
||||
permissionGroups: { label: "权限配置", frontLabel: "账号权限", description: "配置自定义权限、发信频率、附件和邮箱创建额度。" },
|
||||
domains: { label: "域名管理", frontLabel: "邮箱地址", description: "维护邮件域名、DKIM 和 DNS 检测。" },
|
||||
mailboxes: { label: "邮箱管理", frontLabel: "邮箱管理", description: "按归属账号查看和管理子邮箱,默认邮箱受保护。" },
|
||||
aliases: { label: "邮件转发", frontLabel: "邮件转发", description: "管理域名转发规则。" },
|
||||
messages: { label: "全部邮件", frontLabel: "全部邮箱", description: "按邮箱、文件夹和关键词查看全站邮件。" },
|
||||
sendAudit: { label: "发送队列", frontLabel: "发送队列", description: "查看发信投递、重试和失败记录。" },
|
||||
backups: { label: "备份与恢复", frontLabel: "数据保护", description: "创建、校验和下载可迁移的加密完整备份。" },
|
||||
settings: { label: "系统设置", frontLabel: "账号设置", description: "管理站点、发信、存储、注册、安全和邮件模板。" },
|
||||
}
|
||||
const sectionLabels = Object.fromEntries(Object.entries(sectionMeta).map(([key, value]) => [key, value.label])) as Record<Section, string>
|
||||
const sectionKeys = Object.keys(sectionLabels) as Section[]
|
||||
@@ -56,6 +56,8 @@ const sectionPermissions: Record<Section, PermissionKey[]> = {
|
||||
backups: ["admin.settings.view"],
|
||||
settings: ["admin.settings.view", "admin.templates.view"],
|
||||
}
|
||||
const projectRepositoryUrl = "https://github.com/zxyszx/NewSzxcn-Email"
|
||||
const projectTelegramUrl = "https://t.me/+EhII7MSyi3QwNDQ5"
|
||||
const defaultPermissionLimits: PermissionLimits = { maxAttachmentMb: 25, maxMailboxCount: 9, smtpDailyLimit: 200, smtpMinuteLimit: 20, imapMinuteLimit: 200, pop3MinuteLimit: 150 }
|
||||
const defaultMailboxLimitOverride = 9
|
||||
const defaultUserStorageQuotaMb = 100
|
||||
@@ -87,13 +89,11 @@ export function AdminPage() {
|
||||
const canMessagesView = hasPermission(user, "admin.messages.view")
|
||||
const canSettingsView = hasPermission(user, "admin.settings.view")
|
||||
const canTemplatesView = hasPermission(user, "admin.templates.view")
|
||||
const canLoadDomains = canUsersView || canDomainsView || canDNSView || canMailboxesView || canAliasesView || canSettingsView || canTemplatesView
|
||||
const canLoadMailboxes = canMailboxesView || canMessagesView || canSettingsView
|
||||
const overview = useQuery({ queryKey: ["admin", "overview"], queryFn: api.adminOverview, enabled: !!user && canOverview })
|
||||
const users = useQuery({ queryKey: ["admin", "users"], queryFn: api.users, enabled: !!user && (canUsersView || canMailboxesView) })
|
||||
const permissionGroups = useQuery({ queryKey: ["admin", "permission-groups"], queryFn: api.permissionGroups, enabled: !!user && (canPermissionGroupsView || canUsersView) })
|
||||
const domains = useQuery({ queryKey: ["admin", "domains"], queryFn: api.domains, enabled: !!user && canLoadDomains })
|
||||
const mailboxes = useQuery({ queryKey: ["admin", "mailboxes"], queryFn: api.mailboxes, enabled: !!user && canLoadMailboxes })
|
||||
const domains = useQuery({ queryKey: ["admin", "domains"], queryFn: api.domains, enabled: !!user && (canUsersView || canDomainsView || canDNSView || canMailboxesView || canAliasesView || canSettingsView || canTemplatesView) })
|
||||
const mailboxes = useQuery({ queryKey: ["admin", "mailboxes"], queryFn: api.mailboxes, enabled: !!user && (canMailboxesView || canMessagesView || canSettingsView) })
|
||||
const aliases = useQuery({ queryKey: ["admin", "aliases"], queryFn: api.aliases, enabled: !!user && canAliasesView })
|
||||
const settings = useQuery({ queryKey: ["admin", "settings"], queryFn: api.systemSettings, enabled: !!user && canSettingsView })
|
||||
const [params, setParams] = useSearchParams()
|
||||
@@ -107,18 +107,14 @@ export function AdminPage() {
|
||||
const visibleSections = sectionKeys.filter((key) => hasAnyPermission(user, sectionPermissions[key]) && (key !== "backups" || user?.role === "admin"))
|
||||
const rawSection = params.get("section") as Section | null
|
||||
const section: Section = rawSection && visibleSections.includes(rawSection) ? rawSection : visibleSections[0] || "overview"
|
||||
const sectionQueries = section === "overview" ? [overview, ...(canLoadDomains ? [domains] : []), ...(canSettingsView ? [settings] : [])]
|
||||
: section === "users" ? [users, permissionGroups, domains]
|
||||
: section === "permissionGroups" ? [permissionGroups]
|
||||
: section === "domains" ? [domains]
|
||||
: section === "mailboxes" ? [mailboxes, users, domains]
|
||||
: section === "aliases" ? [aliases, domains]
|
||||
: section === "messages" || section === "sendAudit" ? [mailboxes]
|
||||
: section === "settings" ? [domains, ...(canLoadMailboxes ? [mailboxes] : []), ...(canSettingsView ? [settings] : [])]
|
||||
: []
|
||||
const sectionLoading = sectionQueries.some((query) => query.isPending)
|
||||
const sectionError = sectionQueries.find((query) => query.isError)
|
||||
const sectionReady = !sectionLoading && !sectionError
|
||||
const sectionQuery = section === "overview" ? overview
|
||||
: section === "users" ? users
|
||||
: section === "permissionGroups" ? permissionGroups
|
||||
: section === "domains" ? domains
|
||||
: section === "mailboxes" ? mailboxes
|
||||
: section === "aliases" ? aliases
|
||||
: section === "settings" ? settings
|
||||
: null
|
||||
|
||||
async function refreshAdminPage() {
|
||||
if (refreshing) return
|
||||
@@ -137,68 +133,58 @@ export function AdminPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const overviewChecklist = sectionReady && section === "overview" ? setupChecklist(overview.data, domainItems, settings.data).filter((item) => visibleSections.includes(item.section)) : undefined
|
||||
const changeSection = (next: Section) => setParams(next === "overview" ? {} : { section: next })
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-[calc(100svh-3rem)] md:h-svh">
|
||||
<main className="admin-page mx-auto w-full max-w-[1320px] px-3 pb-8 pt-3 sm:px-4 sm:pt-4">
|
||||
<AdminPageHeader section={section} refreshing={refreshing} onRefresh={refreshAdminPage} checklist={overviewChecklist} onSectionChange={changeSection} />
|
||||
<ScrollArea className="h-[calc(100svh-3rem)] md:h-full">
|
||||
<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} />
|
||||
|
||||
{sectionError && <QueryFailure error={sectionError.error} onRetry={() => { void Promise.all(sectionQueries.map((query) => query.refetch())) }} />}
|
||||
{sectionLoading && <AdminSectionLoading overview={section === "overview"} />}
|
||||
{sectionQuery?.isError && <QueryFailure error={sectionQuery.error} onRetry={() => { void sectionQuery.refetch() }} />}
|
||||
|
||||
{sectionReady && section === "overview" && canOverview && (
|
||||
<section className="mb-3 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Stat icon={<UserRound />} tone="primary" label="账号" value={overview.data?.users || 0} detail={`${overview.data?.activeUsers || 0} 个活跃`} />
|
||||
<Stat icon={<Globe2 />} tone="cyan" label="邮件域名" value={overview.data?.domains || 0} detail={domainItems.some((domain) => domain.dnsStatus === "ok") ? `${domainItems.filter((domain) => domain.dnsStatus === "ok").length} 个 DNS 正常` : "待检测"} />
|
||||
<Stat icon={<Mail />} tone="sky" label="邮箱" value={overview.data?.mailboxes || 0} detail={`${overview.data?.activeMailboxes || 0} 个活跃`} />
|
||||
<Stat icon={<Database />} tone="violet" label="存储用量" value={formatBytes(overview.data?.storageBytes || 0)} detail={`${overview.data?.unreadMessages || 0} 封未读 · ${overview.data?.aliases || 0} 个转发`} />
|
||||
{section === "overview" && canOverview && (
|
||||
<section className="overview-stats mb-3 grid grid-cols-2 overflow-hidden rounded-lg border bg-card md:grid-cols-4 xl:grid-cols-8">
|
||||
<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)} />
|
||||
<Stat label="活跃账号" value={overview.data?.activeUsers || 0} />
|
||||
<Stat label="活跃邮箱" value={overview.data?.activeMailboxes || 0} />
|
||||
<Stat label="邮件转发" value={overview.data?.aliases || 0} />
|
||||
<Stat label="未读邮件" value={overview.data?.unreadMessages || 0} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{sectionReady && section === "overview" && <OverviewSection overview={overview.data} domains={domainItems} domainsAvailable={canLoadDomains} settings={settings.data} settingsAvailable={canSettingsView} visibleSections={visibleSections} onSectionChange={changeSection} />}
|
||||
{sectionReady && section === "users" && <UsersSection users={userItems} permissionGroups={assignablePermissionGroups} domains={domainItems} />}
|
||||
{sectionReady && section === "permissionGroups" && <PermissionGroupsSection groups={permissionGroups.data?.items || []} catalog={permissionGroups.data?.catalog || []} />}
|
||||
{sectionReady && section === "domains" && <DomainsSection domains={domainItems} />}
|
||||
{sectionReady && section === "mailboxes" && <MailboxesSection mailboxes={mailboxItems} users={userItems} domains={domainItems} />}
|
||||
{sectionReady && section === "aliases" && <AliasesSection aliases={aliasItems} domains={domainItems} />}
|
||||
{sectionReady && section === "messages" && <AdminMessagesSection mailboxes={mailboxItems} systemAdmin={user?.role === "admin"} />}
|
||||
{sectionReady && section === "sendAudit" && <AdminSendAuditSection mailboxes={mailboxItems} />}
|
||||
{sectionReady && section === "backups" && <BackupsSection />}
|
||||
{sectionReady && section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} mailboxes={mailboxItems} initialTab={params.get("settingsTab")} />}
|
||||
{section === "overview" && <OverviewSection overview={overview.data} domains={domainItems} settings={settings.data} visibleSections={visibleSections} onSectionChange={(next) => setParams(next === "overview" ? {} : { section: next })} />}
|
||||
{section === "users" && <UsersSection users={userItems} permissionGroups={assignablePermissionGroups} domains={domainItems} />}
|
||||
{section === "permissionGroups" && <PermissionGroupsSection groups={permissionGroups.data?.items || []} catalog={permissionGroups.data?.catalog || []} />}
|
||||
{section === "domains" && <DomainsSection domains={domainItems} />}
|
||||
{section === "mailboxes" && <MailboxesSection mailboxes={mailboxItems} users={userItems} domains={domainItems} />}
|
||||
{section === "aliases" && <AliasesSection aliases={aliasItems} domains={domainItems} />}
|
||||
{section === "messages" && <AdminMessagesSection mailboxes={mailboxItems} systemAdmin={user?.role === "admin"} />}
|
||||
{section === "sendAudit" && <AdminSendAuditSection mailboxes={mailboxItems} />}
|
||||
{section === "backups" && <BackupsSection />}
|
||||
{section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} mailboxes={mailboxItems} initialTab={params.get("settingsTab")} />}
|
||||
</main>
|
||||
</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]
|
||||
|
||||
function AdminPageHeader({ section, refreshing, onRefresh, checklist, onSectionChange }: { section: Section; refreshing: boolean; onRefresh: () => void; checklist?: SetupChecklistItem[]; onSectionChange: (section: Section) => void }) {
|
||||
function AdminPageHeader({ section, refreshing, onRefresh }: { section: Section; refreshing: boolean; onRefresh: () => void }) {
|
||||
const meta = sectionMeta[section]
|
||||
return (
|
||||
<div className="mb-4 border-b border-border/80 pb-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<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/80">{meta.description}</p>
|
||||
<p className="mt-1 text-sm leading-5 text-muted-foreground">{meta.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{checklist && <SetupChecklistDialog checklist={checklist} onSectionChange={onSectionChange} />}
|
||||
<Button type="button" variant="outline" size="sm" className="h-9 gap-2 shadow-none" onClick={onRefresh} disabled={refreshing} aria-label="刷新后台数据" title="刷新后台数据">
|
||||
<Button type="button" variant="outline" size="icon" className="h-8 w-8 shadow-none" onClick={onRefresh} disabled={refreshing} aria-label="刷新后台数据" title="刷新后台数据">
|
||||
<RefreshCcw className={cn("h-4 w-4", refreshing && "animate-spin")} />
|
||||
<span className="hidden sm:inline">刷新</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -206,107 +192,47 @@ function AdminPageHeader({ section, refreshing, onRefresh, checklist, onSectionC
|
||||
)
|
||||
}
|
||||
|
||||
function SetupChecklistDialog({ checklist, onSectionChange }: { checklist: SetupChecklistItem[]; onSectionChange: (section: Section) => void }) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const completed = checklist.filter((item) => item.done).length
|
||||
const complete = checklist.length > 0 && completed === checklist.length
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="outline" size="sm" className="h-9 gap-2 shadow-none">
|
||||
{complete ? <CheckCircle2 className="h-4 w-4 text-emerald-600" /> : <Circle className="h-4 w-4 text-amber-600" />}
|
||||
<span>{complete ? "初始化完成" : `初始化 ${completed}/${checklist.length}`}</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-xl gap-3 p-5">
|
||||
<DialogHeader><DialogTitle>首次配置</DialogTitle></DialogHeader>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{checklist.map((item) => (
|
||||
<Button key={item.key} type="button" variant="outline" className="h-auto min-h-[62px] justify-start gap-3 px-3 py-2 text-left font-normal last:sm:col-span-2" onClick={() => { setOpen(false); onSectionChange(item.section) }}>
|
||||
{item.done ? <CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-600" /> : <Circle className="h-4 w-4 shrink-0 text-muted-foreground" />}
|
||||
<span className="min-w-0 flex-1"><span className="block font-medium">{item.title}</span><span className="block truncate text-xs text-muted-foreground">{item.detail}</span></span>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function OverviewSection({ overview, domains, domainsAvailable, settings, settingsAvailable, visibleSections, onSectionChange }: { overview?: AdminOverview; domains: Domain[]; domainsAvailable: boolean; settings?: SystemSettings; settingsAvailable: boolean; visibleSections: Section[]; onSectionChange: (section: Section) => void }) {
|
||||
const { toast } = useToast()
|
||||
const dnsOK = domains.length > 0 && domains.every((domain) => domain.dnsStatus === "ok")
|
||||
const dnsWarning = domains.length > 0 && domains.some((domain) => domain.dnsStatus === "ok")
|
||||
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-3">
|
||||
<Card className="border-border/80">
|
||||
<CardHeader className="px-4 pb-2 pt-3 sm:px-4"><CardTitle className="text-base">邮件运行概览</CardTitle></CardHeader>
|
||||
<CardContent className="px-4 pb-3 sm:px-4">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<OverviewMetric icon={<Send />} label="今日发送" value={overview?.todaySent || 0} tone="primary" />
|
||||
<OverviewMetric icon={<Download />} label="今日接收" value={overview?.todayReceived || 0} tone="success" />
|
||||
<OverviewMetric icon={<CheckCircle2 />} label="发送成功" value={overview?.sendDelivered || 0} tone="success" />
|
||||
<OverviewMetric icon={<AlertCircle />} label="发送失败" value={overview?.sendFailed || 0} tone={(overview?.sendFailed || 0) > 0 ? "danger" : "muted"} />
|
||||
<OverviewMetric icon={<Clock3 />} label="队列邮件" value={overview?.queueMessages || 0} tone={(overview?.queueMessages || 0) > 0 ? "warning" : "muted"} />
|
||||
<OverviewMetric icon={<Mail />} label="未读邮件" value={overview?.unreadMessages || 0} tone={(overview?.unreadMessages || 0) > 0 ? "primary" : "muted"} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/80">
|
||||
<CardHeader className="px-4 pb-2 pt-3"><CardTitle className="text-base">系统状态</CardTitle></CardHeader>
|
||||
<CardContent className="grid gap-2.5 px-4 pb-3 md:grid-cols-3">
|
||||
<div className="rounded-md border border-border/80 px-3 py-2">
|
||||
<DashboardGroupTitle>系统健康</DashboardGroupTitle>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<DashboardStatusItem label="系统" status={<LightStatus state="success" label="运行中" />} />
|
||||
<DashboardStatusItem label="DNS" status={!domainsAvailable ? <LightStatus state="muted" label="不可查看" /> : <LightStatus state={dnsOK ? "success" : dnsWarning ? "warning" : "muted"} label={dnsOK ? "正常" : dnsWarning ? "部分正常" : domains.length ? "未检测" : "未配置"} />} />
|
||||
<DashboardStatusItem label="SMTP" status={!settingsAvailable ? <LightStatus state="muted" label="不可查看" /> : <LightStatus state={settings?.smtpHost ? "success" : "warning"} label={settings?.smtpHost ? "已配置" : "未配置"} />} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border/80 px-3 py-2">
|
||||
<DashboardGroupTitle>服务信息</DashboardGroupTitle>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<DashboardInfoItem label="公网地址" value={!settingsAvailable ? "不可查看" : settings?.publicBaseUrl || "-"} onCopy={settings?.publicBaseUrl ? () => copyOverviewValue(settings.publicBaseUrl, "公网地址", toast) : undefined} />
|
||||
<DashboardInfoItem label="SMTP" value={!settingsAvailable ? "不可查看" : settings?.smtpHost ? `${settings.smtpHost}:${settings.smtpPort}` : "未配置"} onCopy={settings?.smtpHost ? () => copyOverviewValue(`${settings.smtpHost}:${settings.smtpPort}`, "SMTP 地址", toast) : undefined} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border/80 px-3 py-2">
|
||||
<DashboardGroupTitle>功能状态</DashboardGroupTitle>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<DashboardStatusItem label="注册" status={!settingsAvailable ? <LightStatus state="muted" label="不可查看" /> : <LightStatus state={settings?.openRegistration ? "success" : "muted"} label={settings?.openRegistration ? "已开放" : "关闭"} />} />
|
||||
<DashboardStatusItem label="自助申请邮箱" status={!settingsAvailable ? <LightStatus state="muted" label="不可查看" /> : <LightStatus state={settings?.userMailboxApplyEnabled ? "success" : "muted"} label={settings?.userMailboxApplyEnabled ? "已启用" : "关闭"} />} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/80">
|
||||
<CardHeader className="flex-row items-center justify-between space-y-0 px-4 pb-2 pt-3"><div className="flex items-baseline gap-2"><CardTitle className="text-base">域名状态</CardTitle><span className="text-xs text-muted-foreground">{domains.length} 个域名</span></div>{visibleSections.includes("domains") && <Button type="button" variant="ghost" size="sm" className="h-7 gap-1 px-2 text-xs" onClick={() => onSectionChange("domains")}>管理域名<ChevronRight className="h-3.5 w-3.5" /></Button>}</CardHeader>
|
||||
<CardContent className="px-4 pb-3">
|
||||
{!domainsAvailable ? <Empty text="没有权限查看域名详情" /> : domains.length > 0 ? <div className="overflow-hidden rounded-md border border-border/80">
|
||||
<div className="hidden grid-cols-[minmax(0,1fr)_120px_140px_150px_24px] items-center gap-3 border-b bg-muted/20 px-3 py-1.5 text-[11px] font-medium text-muted-foreground md:grid"><span>邮件域名</span><span>使用状态</span><span>DNS 状态</span><span>最近检测</span><span /></div>
|
||||
<div className="divide-y">{domains.slice(0, 5).map((domain) => {
|
||||
const dnsDisplay = dnsStatusDisplay(domain.dnsStatus)
|
||||
return <Button key={domain.id} type="button" variant="ghost" className="grid h-auto min-h-12 w-full grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-none px-3 py-2 text-left font-normal transition-colors hover:bg-muted/35 md:grid-cols-[minmax(0,1fr)_120px_140px_150px_24px]" onClick={() => onSectionChange("domains")}>
|
||||
<span className="min-w-0 truncate text-sm font-medium">{domain.name}</span>
|
||||
<span className="hidden md:block"><LightStatus state={domain.status === "active" ? "success" : "muted"} label={domain.status === "active" ? "已启用" : "已停用"} /></span>
|
||||
<span className="justify-self-end md:justify-self-start"><LightStatus state={dnsDisplay.state} label={dnsDisplay.label} /></span>
|
||||
<span className="hidden text-xs text-muted-foreground md:block">{domain.dnsCheckedAt ? formatDate(domain.dnsCheckedAt) : "尚未检测"}</span>
|
||||
<ChevronRight className="hidden h-4 w-4 text-muted-foreground md:block" />
|
||||
<span className="col-span-2 flex items-center gap-2 text-[11px] text-muted-foreground md:hidden"><span>{domain.status === "active" ? "已启用" : "已停用"}</span><span>·</span><span>{domain.dnsCheckedAt ? `检测于 ${formatDate(domain.dnsCheckedAt)}` : "尚未检测"}</span></span>
|
||||
<section className="overview-setup-panel rounded-lg border bg-card p-4 text-card-foreground">
|
||||
<h2 className="mb-3 text-sm font-semibold">首次配置</h2>
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-5">
|
||||
{checklist.map((item) => (
|
||||
<Button key={item.key} type="button" variant="outline" className="h-auto min-h-[62px] w-full justify-start gap-2.5 px-3 py-2 text-left font-normal" onClick={() => onSectionChange(item.section)}>
|
||||
{item.done ? <CheckCircle2 className="h-4 w-4 shrink-0 text-green-600" /> : <Circle className="h-4 w-4 shrink-0 text-muted-foreground" />}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-medium">{item.title}</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">{item.detail}</span>
|
||||
</span>
|
||||
</Button>
|
||||
})}</div>
|
||||
{domains.length > 5 && <Button type="button" variant="ghost" className="h-auto w-full justify-start rounded-none border-t px-3 py-2 text-left text-xs font-normal text-muted-foreground transition-colors hover:bg-muted/35 hover:text-foreground" onClick={() => onSectionChange("domains")}>还有 {domains.length - 5} 个域名,查看全部</Button>}
|
||||
</div> : <Empty text="暂无邮件域名" />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<div className="grid items-start gap-3 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="pb-3"><CardTitle>DNS 状态</CardTitle></CardHeader>
|
||||
<CardContent className="grid gap-2 sm:grid-cols-2">
|
||||
{domains.map((domain) => <DomainBadgeRow key={domain.id} domain={domain} />)}
|
||||
{domains.length === 0 && <Empty text="暂无域名" />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3"><CardTitle>运行信息</CardTitle></CardHeader>
|
||||
<CardContent className="grid gap-2 text-sm text-muted-foreground sm:grid-cols-2">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
function setupChecklist(overview: AdminOverview | undefined, domains: Domain[], settings?: SystemSettings) {
|
||||
function setupChecklist(overview: { activeUsers: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number } | undefined, domains: Domain[], settings?: SystemSettings) {
|
||||
const hasDomain = domains.length > 0
|
||||
const dnsReady = domains.some((domain) => domain.dnsStatus === "ok")
|
||||
const hasMailbox = (overview?.activeMailboxes || 0) > 0
|
||||
@@ -320,40 +246,8 @@ function setupChecklist(overview: AdminOverview | undefined, domains: Domain[],
|
||||
]
|
||||
}
|
||||
|
||||
function OverviewMetric({ icon, label, value, tone }: { icon: React.ReactNode; label: string; value: number; tone: "primary" | "success" | "warning" | "danger" | "muted" }) {
|
||||
return <div className="flex min-h-[50px] items-center gap-2 rounded-md border border-border/80 px-2 py-1"><div className={cn("grid h-7 w-7 shrink-0 place-items-center rounded-full [&>svg]:h-3.5 [&>svg]:w-3.5", tone === "primary" && "bg-primary/5 text-primary", tone === "success" && "bg-emerald-500/10 text-emerald-600", tone === "warning" && "bg-amber-500/10 text-amber-600", tone === "danger" && "bg-destructive/10 text-destructive", tone === "muted" && "bg-muted text-muted-foreground")}>{icon}</div><div className="min-w-0"><div className="truncate text-[10px] leading-3 text-muted-foreground">{label}</div><div className={cn("text-base font-semibold leading-5 tabular-nums", tone === "success" && "text-emerald-700 dark:text-emerald-400", tone === "warning" && "text-amber-700 dark:text-amber-400", tone === "danger" && "text-destructive")}>{value}</div></div></div>
|
||||
}
|
||||
|
||||
function dnsStatusDisplay(status: string): { state: "success" | "warning" | "danger" | "muted"; label: string } {
|
||||
if (status === "ok") return { state: "success", label: "DNS 正常" }
|
||||
if (status === "error") return { state: "danger", label: "DNS 异常" }
|
||||
if (!status || status === "unchecked") return { state: "muted", label: "未检测" }
|
||||
return { state: "warning", label: "需检查" }
|
||||
}
|
||||
|
||||
function LightStatus({ state, label }: { state: "success" | "warning" | "danger" | "muted"; label: string }) {
|
||||
return <span className={cn("inline-flex h-6 items-center gap-1.5 whitespace-nowrap rounded-full px-1.5 text-xs font-medium", state === "success" && "bg-emerald-500/[0.07] text-emerald-700 dark:text-emerald-400", state === "warning" && "bg-amber-500/[0.07] text-amber-700 dark:text-amber-400", state === "danger" && "bg-destructive/[0.07] text-destructive", state === "muted" && "bg-muted/70 text-muted-foreground")}><span className={cn("h-1.5 w-1.5 rounded-full", state === "success" && "bg-emerald-600", state === "warning" && "bg-amber-500", state === "danger" && "bg-destructive", state === "muted" && "bg-muted-foreground/60")} />{label}</span>
|
||||
}
|
||||
|
||||
function DashboardGroupTitle({ children }: { children: React.ReactNode }) {
|
||||
return <div className="mb-1.5 text-[11px] font-medium text-muted-foreground">{children}</div>
|
||||
}
|
||||
|
||||
function DashboardStatusItem({ label, status }: { label: string; status: React.ReactNode }) {
|
||||
return <div className="min-w-0"><div className="mb-0.5 truncate text-[10px] text-muted-foreground">{label}</div>{status}</div>
|
||||
}
|
||||
|
||||
function DashboardInfoItem({ label, value, onCopy }: { label: string; value: string; onCopy?: () => void }) {
|
||||
return <div className="flex min-w-0 items-end gap-1"><div className="min-w-0 flex-1"><div className="text-[10px] text-muted-foreground">{label}</div><div className="truncate text-xs font-medium" title={value}>{value}</div></div>{onCopy && <Button type="button" variant="ghost" size="icon" className="h-6 w-6 shrink-0" onClick={onCopy} title={`复制${label}`} aria-label={`复制${label}`}><Copy className="h-3 w-3" /></Button>}</div>
|
||||
}
|
||||
|
||||
async function copyOverviewValue(value: string, label: string, toast: ReturnType<typeof useToast>["toast"]) {
|
||||
await navigator.clipboard.writeText(value)
|
||||
toast({ title: `${label}已复制` })
|
||||
}
|
||||
|
||||
function InfoLine({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return <div className="grid min-h-10 grid-cols-[auto_minmax(0,1fr)] items-center gap-3 rounded-md border px-3 py-2"><span className="whitespace-nowrap">{label}</span><span className="min-w-0 break-all text-right font-medium text-foreground">{value}</span></div>
|
||||
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>
|
||||
}
|
||||
|
||||
function generateBackupPassword(length = 24) {
|
||||
@@ -547,8 +441,6 @@ function BackupsSection() {
|
||||
;(groups[transfer.name] ||= []).push(transfer)
|
||||
return groups
|
||||
}, {})).sort((left, right) => Date.parse(right[0]?.startedAt || "") - Date.parse(left[0]?.startedAt || ""))
|
||||
if (backups.isPending) return <AdminSectionLoading />
|
||||
if (backups.isError) return <QueryFailure error={backups.error} onRetry={() => { void backups.refetch() }} compact />
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.35fr)_minmax(300px,.65fr)]">
|
||||
@@ -1104,11 +996,11 @@ function DomainsSection({ domains }: { domains: Domain[] }) {
|
||||
<div key={domain.id} className="flex flex-col gap-3 rounded-lg border p-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="font-medium">{domain.name}</div>
|
||||
<div className="text-xs text-muted-foreground">DKIM 选择器:{domain.dkimSelector}</div>
|
||||
<div className="text-xs text-muted-foreground">selector: {domain.dkimSelector}</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusText active={domain.status === "active"} activeLabel="启用" inactiveLabel="停用" />
|
||||
<LightStatus state={dnsStatusDisplay(domain.dnsStatus).state} label={dnsStatusDisplay(domain.dnsStatus).label} />
|
||||
<StatusText active={domain.dnsStatus === "ok"} activeLabel="DNS 正常" inactiveLabel={domain.dnsStatus || "未检测"} />
|
||||
{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>}
|
||||
@@ -1128,7 +1020,7 @@ function DomainDNSDialog({ domain }: { domain: Domain }) {
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">DNS</Button>
|
||||
</DialogTrigger>
|
||||
<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">
|
||||
<DialogContent className="max-h-[86vh] overflow-y-auto sm:max-w-3xl">
|
||||
<DialogHeader><DialogTitle>{domain.name} DNS</DialogTitle></DialogHeader>
|
||||
<DNSPanel domain={domain} embedded />
|
||||
</DialogContent>
|
||||
@@ -1171,7 +1063,7 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
|
||||
.filter((group) => !keyword || [group.owner ? accountPrimaryEmail(group.owner) : "", group.owner?.displayName || "", ...group.mailboxes.map((mailbox) => mailbox.address)].some((value) => value.toLowerCase().includes(keyword)))
|
||||
const toggleOwner = (ownerID: string) => setExpandedOwners((current) => current.includes(ownerID) ? current.filter((id) => id !== ownerID) : [...current, ownerID])
|
||||
return (
|
||||
<Card className="min-w-0">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<CardTitle>邮箱管理</CardTitle>
|
||||
@@ -1181,7 +1073,7 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="min-w-0 space-y-4">
|
||||
<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" />
|
||||
@@ -1327,7 +1219,7 @@ function AdminMessagesSection({ mailboxes, systemAdmin }: { mailboxes: MailboxTy
|
||||
const detail = useQuery({ queryKey: ["admin", "message", selectedId], queryFn: () => api.adminMessage(selectedId!), enabled: !!selectedId })
|
||||
const items = messages.data?.pages.flatMap((page) => page.items || []) || []
|
||||
return (
|
||||
<Card className="min-w-0">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<CardTitle>全部邮件</CardTitle>
|
||||
@@ -1336,7 +1228,7 @@ function AdminMessagesSection({ mailboxes, systemAdmin }: { mailboxes: MailboxTy
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="min-w-0 space-y-4">
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-col gap-3 xl:flex-row">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
@@ -1385,17 +1277,17 @@ function AdminMessagesSection({ mailboxes, systemAdmin }: { mailboxes: MailboxTy
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="min-w-0 overflow-hidden md:block max-md:hidden">
|
||||
<Table className="table-fixed">
|
||||
<div className="hidden md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[25%]">邮件</TableHead>
|
||||
<TableHead className="w-[17%]">邮箱</TableHead>
|
||||
<TableHead className="w-[15%]">发件人</TableHead>
|
||||
<TableHead className="w-[17%]">收件人</TableHead>
|
||||
<TableHead className="w-[9%]">文件夹</TableHead>
|
||||
<TableHead className="w-[10%]">时间</TableHead>
|
||||
<TableHead className="w-[7%]"></TableHead>
|
||||
<TableHead>邮件</TableHead>
|
||||
<TableHead>邮箱</TableHead>
|
||||
<TableHead>发件人</TableHead>
|
||||
<TableHead>收件人</TableHead>
|
||||
<TableHead>文件夹</TableHead>
|
||||
<TableHead>时间</TableHead>
|
||||
<TableHead className="w-20"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -1405,9 +1297,9 @@ function AdminMessagesSection({ mailboxes, systemAdmin }: { mailboxes: MailboxTy
|
||||
<div className="truncate font-medium">{message.subject}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{message.snippet}</div>
|
||||
</TableCell>
|
||||
<TableCell className="min-w-0">
|
||||
<div className="truncate font-medium" title={message.mailboxAddress || message.recipientAddress || "-"}>{message.mailboxAddress || message.recipientAddress || "-"}</div>
|
||||
{message.ownerEmail && <div className="truncate text-xs text-muted-foreground" title={message.ownerEmail}>{message.ownerEmail}</div>}
|
||||
<TableCell>
|
||||
<div className="font-medium">{message.mailboxAddress || message.recipientAddress || "-"}</div>
|
||||
{message.ownerEmail && <div className="text-xs text-muted-foreground">{message.ownerEmail}</div>}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[220px] truncate" title={adminSenderTitle(message)}>{adminSenderDisplayName(message)}</TableCell>
|
||||
<TableCell className="max-w-[220px] truncate">{message.recipientAddress || message.to?.join(", ") || ""}</TableCell>
|
||||
@@ -1561,25 +1453,25 @@ function SystemSettingsSection({ settings, domains, mailboxes, initialTab }: { s
|
||||
const canResetTemplates = hasPermission(user, "admin.templates.reset")
|
||||
const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates, enabled: canViewTemplates })
|
||||
const requestedTab = initialTab as SettingsTab | undefined
|
||||
const [settingsTab, setSettingsTab] = React.useState<SettingsTab>(() => requestedTab && ["base", "smtp", "storage", "mail", "notifications", "externalImap", "templates", "security"].includes(requestedTab) ? requestedTab : "base")
|
||||
const [settingsTab, setSettingsTab] = React.useState<SettingsTab>(() => requestedTab && ["base", "smtp", "storage", "mail", "notifications", "externalImap", "templates", "security", "about"].includes(requestedTab) ? requestedTab : "base")
|
||||
const maildirHealth = useQuery({ queryKey: ["admin", "maildir-sync", "health"], queryFn: api.maildirSyncHealth, enabled: canSettingsView && settingsTab === "storage" })
|
||||
const [smtpRequireTls, setSmtpRequireTls] = React.useState(() => settings?.smtpRequireTls ?? false)
|
||||
const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(() => settings?.allowInsecureHttp ?? true)
|
||||
const [openRegistration, setOpenRegistration] = React.useState(() => settings?.openRegistration ?? false)
|
||||
const [twoFactorEnabled, setTwoFactorEnabled] = React.useState(() => settings?.twoFactorEnabled ?? false)
|
||||
const [turnstileEnabled, setTurnstileEnabled] = React.useState(() => settings?.turnstileEnabled ?? false)
|
||||
const [catchAllEnabled, setCatchAllEnabled] = React.useState(() => settings?.catchAllEnabled ?? false)
|
||||
const [mailAutoRefresh, setMailAutoRefresh] = React.useState(() => settings?.mailAutoRefresh ?? true)
|
||||
const [userMailboxApplyEnabled, setUserMailboxApplyEnabled] = React.useState(() => settings?.userMailboxApplyEnabled ?? false)
|
||||
const [userMailboxDomainIds, setUserMailboxDomainIds] = React.useState<string[]>(() => settings?.userMailboxDomainIds || [])
|
||||
const [externalImapEnabled, setExternalImapEnabled] = React.useState(() => settings?.externalImapEnabled ?? false)
|
||||
const [externalImapAllowPrivateHosts, setExternalImapAllowPrivateHosts] = React.useState(() => settings?.externalImapAllowPrivateHosts ?? false)
|
||||
const [telegramMailEnabled, setTelegramMailEnabled] = React.useState(() => settings?.telegramMailEnabled ?? false)
|
||||
const [smtpRequireTls, setSmtpRequireTls] = React.useState(false)
|
||||
const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true)
|
||||
const [openRegistration, setOpenRegistration] = React.useState(false)
|
||||
const [twoFactorEnabled, setTwoFactorEnabled] = React.useState(false)
|
||||
const [turnstileEnabled, setTurnstileEnabled] = React.useState(false)
|
||||
const [catchAllEnabled, setCatchAllEnabled] = React.useState(false)
|
||||
const [mailAutoRefresh, setMailAutoRefresh] = React.useState(true)
|
||||
const [userMailboxApplyEnabled, setUserMailboxApplyEnabled] = React.useState(false)
|
||||
const [userMailboxDomainIds, setUserMailboxDomainIds] = React.useState<string[]>([])
|
||||
const [externalImapEnabled, setExternalImapEnabled] = React.useState(false)
|
||||
const [externalImapAllowPrivateHosts, setExternalImapAllowPrivateHosts] = React.useState(false)
|
||||
const [telegramMailEnabled, setTelegramMailEnabled] = React.useState(false)
|
||||
const [telegramBotToken, setTelegramBotToken] = React.useState("")
|
||||
const [telegramPrivateChatId, setTelegramPrivateChatId] = React.useState(() => settings?.telegramPrivateChatId || "")
|
||||
const [telegramBodyMode, setTelegramBodyMode] = React.useState<"summary" | "full">(() => settings?.telegramBodyMode === "full" ? "full" : "summary")
|
||||
const [telegramMailboxIds, setTelegramMailboxIds] = React.useState<string[]>(() => settings?.telegramMailboxIds || [])
|
||||
const [telegramIncludeUnregistered, setTelegramIncludeUnregistered] = React.useState(() => settings?.telegramIncludeUnregistered ?? false)
|
||||
const [telegramPrivateChatId, setTelegramPrivateChatId] = React.useState("")
|
||||
const [telegramBodyMode, setTelegramBodyMode] = React.useState<"summary" | "full">("summary")
|
||||
const [telegramMailboxIds, setTelegramMailboxIds] = React.useState<string[]>([])
|
||||
const [telegramIncludeUnregistered, setTelegramIncludeUnregistered] = React.useState(false)
|
||||
const [telegramPairing, setTelegramPairing] = React.useState<TelegramPairing | null>(null)
|
||||
React.useEffect(() => {
|
||||
if (!settings) return
|
||||
@@ -1721,10 +1613,11 @@ function SystemSettingsSection({ settings, domains, mailboxes, initialTab }: { s
|
||||
] : []),
|
||||
...(canViewTemplates ? [{ key: "templates" as const, label: "模板" }] : []),
|
||||
...(canSettingsView ? [{ key: "security" as const, label: "安全" }] : []),
|
||||
{ key: "about", label: "关于" },
|
||||
]
|
||||
React.useEffect(() => {
|
||||
if (tabs.some((tab) => tab.key === settingsTab)) return
|
||||
setSettingsTab(tabs[0]?.key || "base")
|
||||
setSettingsTab(tabs[0]?.key || "about")
|
||||
}, [settingsTab, tabs])
|
||||
return (
|
||||
<form key={formKey} onSubmit={(event) => { event.preventDefault(); if (canUpdateSettings) save.mutate(new FormData(event.currentTarget)) }} className="space-y-6">
|
||||
@@ -1967,7 +1860,9 @@ function SystemSettingsSection({ settings, domains, mailboxes, initialTab }: { s
|
||||
</CardContent>
|
||||
</Card>}
|
||||
|
||||
{canUpdateSettings && <div className="flex justify-end">
|
||||
{settingsTab === "about" && <AboutProjectCard />}
|
||||
|
||||
{settingsTab !== "about" && canUpdateSettings && <div className="flex justify-end">
|
||||
<Button disabled={save.isPending || !settings}>{save.isPending ? "保存中..." : "保存设置"}</Button>
|
||||
</div>}
|
||||
</form>
|
||||
@@ -2070,6 +1965,76 @@ function queryErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : "读取 Maildir 同步健康失败"
|
||||
}
|
||||
|
||||
function AboutProjectCard() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>关于</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm">
|
||||
<AboutRow label="版本">
|
||||
<SystemVersionDialog mode="inline" />
|
||||
</AboutRow>
|
||||
<AboutRow label="交流">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" asChild>
|
||||
<a href={projectRepositoryUrl} target="_blank" rel="noreferrer">
|
||||
<Github className="h-5 w-5" />
|
||||
GitHub
|
||||
</a>
|
||||
</Button>
|
||||
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" asChild>
|
||||
<a href={`${projectRepositoryUrl}/issues`} target="_blank" rel="noreferrer">
|
||||
<Circle className="h-5 w-5 text-muted-foreground" />
|
||||
Issues
|
||||
</a>
|
||||
</Button>
|
||||
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" asChild>
|
||||
<a href={projectTelegramUrl} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="h-5 w-5 text-sky-500" />
|
||||
Telegram 群组
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</AboutRow>
|
||||
<AboutRow label="支持">
|
||||
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" asChild>
|
||||
<a href={projectRepositoryUrl} target="_blank" rel="noreferrer">
|
||||
<Star className="h-5 w-5 text-yellow-500" />
|
||||
给项目点 Star
|
||||
</a>
|
||||
</Button>
|
||||
</AboutRow>
|
||||
<AboutRow label="帮助">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" asChild>
|
||||
<a href={`${projectRepositoryUrl}#readme`} target="_blank" rel="noreferrer">
|
||||
<BookOpen className="h-5 w-5 text-sky-500" />
|
||||
项目文档
|
||||
</a>
|
||||
</Button>
|
||||
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" asChild>
|
||||
<a href={`${projectRepositoryUrl}/blob/main/LICENSE`} target="_blank" rel="noreferrer">
|
||||
<Scale className="h-5 w-5 text-emerald-500" />
|
||||
开源协议
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</AboutRow>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function AboutRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-[4.5rem_minmax(0,1fr)] sm:items-center">
|
||||
<div className="font-medium text-muted-foreground">{label}:</div>
|
||||
<div className="min-w-0">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TestSMTPDialog({ disabled }: { disabled?: boolean }) {
|
||||
const { toast } = useToast()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
@@ -2250,8 +2215,8 @@ function sendAuditBadgeVariant(event?: string) {
|
||||
return "secondary"
|
||||
}
|
||||
|
||||
function Stat({ icon, tone, label, value, detail }: { icon: React.ReactNode; tone: "primary" | "cyan" | "sky" | "violet"; label: string; value: React.ReactNode; detail: string }) {
|
||||
return <Card className="border-border/80"><CardContent className="flex min-h-[88px] items-center gap-3 p-3 !pt-3"><div className={cn("grid h-10 w-10 shrink-0 place-items-center rounded-lg [&>svg]:h-5 [&>svg]:w-5", tone === "primary" && "bg-primary/5 text-primary", tone === "cyan" && "bg-cyan-500/10 text-cyan-600 dark:text-cyan-400", tone === "sky" && "bg-sky-500/10 text-sky-600 dark:text-sky-400", tone === "violet" && "bg-violet-500/10 text-violet-600 dark:text-violet-400")}>{icon}</div><div className="min-w-0"><div className="truncate text-xs font-medium">{label}</div><div className="truncate text-2xl font-semibold leading-7 tabular-nums">{value}</div><div className="truncate text-[11px] text-muted-foreground">{detail}</div></div></CardContent></Card>
|
||||
function Stat({ icon, label, value }: { icon?: React.ReactNode; label: string; value: React.ReactNode }) {
|
||||
return <div className="overview-stat flex min-h-[64px] items-center gap-2.5 p-3">{icon && <div className="grid h-8 w-8 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="truncate text-lg font-semibold leading-6">{value}</div><div className="truncate text-xs text-muted-foreground">{label}</div></div></div>
|
||||
}
|
||||
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> }
|
||||
@@ -2267,6 +2232,7 @@ function QueryFailure({ error, onRetry, compact = false }: { error: unknown; onR
|
||||
</div>
|
||||
)
|
||||
}
|
||||
function DomainBadgeRow({ domain }: { domain: Domain }) { return <div className="flex items-center justify-between rounded-lg border p-3"><span className="font-medium">{domain.name}</span><Badge variant={domain.dnsStatus === "ok" ? "default" : "secondary"}>{domain.dnsStatus === "ok" ? "正常" : domain.dnsStatus}</Badge></div> }
|
||||
function invalidateAdmin(qc: ReturnType<typeof useQueryClient>) { qc.invalidateQueries({ queryKey: ["admin"] }); qc.invalidateQueries({ queryKey: ["mailboxes"] }); qc.invalidateQueries({ queryKey: ["me"] }) }
|
||||
|
||||
function UserMailboxCell({ user }: { user: AdminUser }) {
|
||||
@@ -2590,77 +2556,27 @@ function DNSPanel({ domain, embedded = false }: { domain?: Domain; embedded?: bo
|
||||
const check = useMutation({ mutationFn: () => api.checkDns(domain!.id), onSuccess: (res) => { qc.invalidateQueries({ queryKey: ["admin", "domains"] }); toast({ title: res.status === "ok" ? "DNS 检测通过" : "DNS 检测未通过", description: Object.values(res.checks).map((c) => c.message).join(";") }) }, onError: (error) => toast({ title: "DNS 检测失败", description: error.message }) })
|
||||
if (!domain) return <Card><CardContent className="p-6 text-muted-foreground">请选择域名</CardContent></Card>
|
||||
const content = <>
|
||||
<p className="mb-3 text-sm text-muted-foreground">以下内容可直接填写到常见 DNS 控制台,根域名的主机记录使用 @。</p>
|
||||
{check.isPending && <DNSCheckPending />}
|
||||
{check.isError && <DNSCheckFailure error={check.error} />}
|
||||
{check.data && !check.isPending && <DNSCheckSummary checks={check.data.checks} />}
|
||||
{records.isError ? <QueryFailure error={records.error} onRetry={() => { void records.refetch() }} compact /> : <div className="grid gap-3 md:auto-rows-fr md:grid-cols-2">{records.data?.items.map((r) => <DNSRecordRow key={`${r.type}-${r.name}`} record={r} domainName={domain.name} />)}</div>}
|
||||
</>
|
||||
const checkButton = canCheckDNS ? <Button variant="outline" size="sm" onClick={() => check.mutate()} disabled={check.isPending}><RefreshCcw className={cn("h-4 w-4", check.isPending && "animate-spin")} />{check.isPending ? "检测中" : check.data ? "重新检测" : "检测"}</Button> : null
|
||||
<p className="mb-3 text-sm text-muted-foreground">以下为需要在域名 DNS 管理中添加的记录:</p>
|
||||
{records.isError ? <QueryFailure error={records.error} onRetry={() => { void records.refetch() }} compact /> : <div className="space-y-3">{records.data?.items.map((r) => <DNSRecordRow key={`${r.type}-${r.name}`} record={r} />)}</div>}
|
||||
{check.data && <>
|
||||
<Separator className="my-4" />
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground"><CheckCircle2 className="h-4 w-4" />检测结果</div>
|
||||
<div className="mt-2 space-y-2">{Object.entries(check.data.checks).map(([k, v]) => <DNSCheckRow key={k} name={k} check={v} />)}</div>
|
||||
</>}</>
|
||||
const checkButton = canCheckDNS ? <Button variant="outline" size="sm" onClick={() => check.mutate()} disabled={check.isPending}><RefreshCcw className="h-4 w-4" />检测</Button> : null
|
||||
const header = <div className="flex items-center justify-between"><CardTitle>DNS 记录</CardTitle>{checkButton}</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>
|
||||
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>
|
||||
return <Card><CardHeader>{header}</CardHeader><CardContent>{content}</CardContent></Card>
|
||||
}
|
||||
|
||||
const dnsCheckMeta: Record<string, { label: string; description: string }> = {
|
||||
mx: { label: "MX", description: "收信地址" },
|
||||
spf: { label: "SPF", description: "发信授权" },
|
||||
dkim: { label: "DKIM", description: "邮件签名" },
|
||||
dmarc: { label: "DMARC", description: "防伪策略" },
|
||||
}
|
||||
|
||||
function DNSCheckPending() {
|
||||
return <div className="mb-4 flex items-center gap-3 rounded-lg border border-primary/30 bg-primary/5 p-4" role="status" aria-live="polite">
|
||||
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-primary/10 text-primary"><Loader2 className="h-5 w-5 animate-spin" /></div>
|
||||
<div><div className="font-semibold text-foreground">正在检测 DNS</div><p className="mt-0.5 text-sm text-muted-foreground">正在查询最新解析结果,请稍候...</p></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
function DNSCheckFailure({ error }: { error: Error }) {
|
||||
return <div className="mb-4 flex items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-4" role="alert">
|
||||
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-destructive/10 text-destructive"><AlertCircle className="h-5 w-5" /></div>
|
||||
<div className="min-w-0"><div className="font-semibold text-destructive">DNS 检测失败</div><p className="mt-0.5 break-words text-sm text-muted-foreground">{error.message || "暂时无法查询 DNS,请稍后重新检测。"}</p></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
function DNSCheckSummary({ checks }: { checks: Record<string, { ok: boolean; message: string; found?: string[] }> }) {
|
||||
const entries = Object.entries(checks).sort(([left], [right]) => {
|
||||
const order = ["mx", "spf", "dkim", "dmarc"]
|
||||
return order.indexOf(left) - order.indexOf(right)
|
||||
})
|
||||
const passed = entries.filter(([, item]) => item.ok).length
|
||||
const allPassed = entries.length > 0 && passed === entries.length
|
||||
return <section className={cn("mb-4 overflow-hidden rounded-lg border p-4", allPassed ? "border-emerald-500/40 bg-emerald-500/[0.07]" : "border-destructive/40 bg-destructive/5")} role={allPassed ? "status" : "alert"} aria-live="polite" aria-label="DNS 检测结果">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn("grid h-10 w-10 shrink-0 place-items-center rounded-full", allPassed ? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400" : "bg-destructive/10 text-destructive")}>
|
||||
{allPassed ? <CheckCircle2 className="h-5 w-5" /> : <AlertCircle className="h-5 w-5" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className={cn("text-base font-semibold", allPassed ? "text-emerald-800 dark:text-emerald-300" : "text-destructive")}>{allPassed ? "DNS 配置全部通过" : "DNS 配置需要处理"}</h3>
|
||||
<Badge variant="outline" className={cn("shrink-0 bg-background/70", allPassed ? "border-emerald-500/40 text-emerald-800 dark:text-emerald-300" : "border-destructive/40 text-destructive")}>{passed}/{entries.length} 项通过</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{allPassed ? "所有邮件相关记录均已正确解析,可以正常使用。" : "请处理下面标红的项目,修改 DNS 后再重新检测。"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-x-4 sm:grid-cols-2 md:grid-cols-4">
|
||||
{entries.map(([name, item]) => <DNSCheckRow key={name} name={name} check={item} />)}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
function DNSCheckRow({ name, check }: { name: string; check: { ok: boolean; message: string; found?: string[] } }) {
|
||||
const visibleRecords = check.found?.filter(Boolean) ?? []
|
||||
const meta = dnsCheckMeta[name.toLowerCase()] || { label: name.toUpperCase(), description: "DNS 记录" }
|
||||
return <div className={cn("space-y-1 border-t py-2.5 text-sm", check.ok ? "border-emerald-500/20" : "border-destructive/20")}>
|
||||
<div className="flex items-start gap-2.5">
|
||||
{check.ok ? <CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" /> : <AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="shrink-0 font-medium text-foreground">{meta.label}<span className="ml-1 font-normal text-muted-foreground">({meta.description})</span></div>
|
||||
<div className={cn("mt-0.5", check.ok ? "text-muted-foreground" : "font-medium text-destructive")}>{check.message}</div>
|
||||
</div>
|
||||
return <div className="space-y-1 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<CheckCircle2 className={`mt-0.5 h-4 w-4 shrink-0 ${check.ok ? "text-green-600" : "text-destructive"}`} />
|
||||
<div className="min-w-0"><span className="font-medium">{name.toUpperCase()}:</span> {check.message}</div>
|
||||
</div>
|
||||
{!check.ok && visibleRecords.length > 0 && <div className="ml-6 rounded-md bg-background/70 px-3 py-2 font-mono text-xs text-muted-foreground">
|
||||
{!check.ok && visibleRecords.length > 0 && <div className="ml-6 rounded-md bg-muted/60 px-3 py-2 font-mono text-xs text-muted-foreground">
|
||||
<div 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>}
|
||||
@@ -2671,61 +2587,36 @@ function dnsDescription(record: DNSRecord): string {
|
||||
if (record.type === "TXT" && record.name.startsWith("_dmarc")) return "声明域名的 DMARC 策略(如何处理未通过 SPF/DKIM 验证的邮件)。"
|
||||
if (record.type === "TXT" && record.value.includes("DKIM1")) return "DKIM 公钥。收件服务器用此密钥验证邮件是否由你发出。"
|
||||
if (record.type === "TXT" && record.value.includes("spf1")) return "声明哪些服务器有权使用你的域名发件,防止伪造。"
|
||||
if (record.type === "MX") {
|
||||
const mx = mxRecordParts(record.value)
|
||||
return `邮件由 ${mx.target} 接收,请确保它的 A 记录指向服务器 IP。优先级 ${mx.priority},数值越小越优先。`
|
||||
}
|
||||
if (record.type === "MX") return `确保 ${record.name} 的 A 记录已指向你的服务器 IP,邮件才能到达。`
|
||||
return ""
|
||||
}
|
||||
|
||||
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 }) {
|
||||
function DNSRecordRow({ record }: { record: DNSRecord }) {
|
||||
const { toast } = useToast()
|
||||
const desc = dnsDescription(record)
|
||||
const longValue = record.value.length > 180
|
||||
const mx = record.type === "MX" ? mxRecordParts(record.value) : null
|
||||
const displayHost = dnsHostForProvider(record.name, domainName)
|
||||
const displayValue = mx?.target || record.value
|
||||
const [valueExpanded, setValueExpanded] = React.useState(false)
|
||||
async function copyField(label: string, value: string) {
|
||||
await navigator.clipboard.writeText(value)
|
||||
toast({ title: `${label}已复制` })
|
||||
}
|
||||
return <div className="flex h-full flex-col rounded-lg border bg-card p-3">
|
||||
<div className="mb-2 flex min-h-7 items-center justify-between gap-2">
|
||||
return <div className="rounded-lg border bg-card p-3">
|
||||
<div className="mb-2 flex items-center">
|
||||
<Badge variant="outline" className="font-mono">{record.type}</Badge>
|
||||
{longValue && <Button type="button" size="sm" variant="ghost" className="h-7 gap-1 px-2 text-xs text-muted-foreground" aria-expanded={valueExpanded} onClick={() => setValueExpanded((current) => !current)}><ChevronDown className={cn("h-3.5 w-3.5 transition-transform", valueExpanded && "rotate-180")} />{valueExpanded ? "收起完整内容" : "查看完整内容"}</Button>}
|
||||
</div>
|
||||
{desc && <p className="mb-2 line-clamp-2 min-h-9 text-xs leading-[1.125rem] text-muted-foreground">{desc}</p>}
|
||||
<div className="mt-auto space-y-1 font-mono text-xs text-muted-foreground">
|
||||
{desc && <p className="mb-2 text-xs text-muted-foreground">{desc}</p>}
|
||||
<div className="space-y-1 font-mono text-xs text-muted-foreground">
|
||||
<div className="grid grid-cols-[4.5rem_minmax(0,1fr)_1.75rem] items-start gap-2">
|
||||
<span className="pt-1 text-foreground">主机记录</span>
|
||||
<code className="break-all pt-1 font-mono font-medium text-foreground">{displayHost}</code>
|
||||
<Button type="button" size="icon" variant="ghost" className="h-7 w-7" aria-label="复制主机记录" title="复制主机记录" onClick={() => copyField("主机记录", displayHost)}><Copy className="h-3.5 w-3.5" /></Button>
|
||||
<code className="break-all pt-1 font-mono">{record.name}</code>
|
||||
<Button type="button" size="icon" variant="ghost" className="h-7 w-7" aria-label="复制主机记录" title="复制主机记录" onClick={() => copyField("主机记录", record.name)}><Copy className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-[4.5rem_minmax(0,1fr)_1.75rem] items-start gap-2">
|
||||
<span className="pt-1 text-foreground">记录值</span>
|
||||
<code className={cn("break-all pt-1 font-mono", longValue && !valueExpanded && "line-clamp-3")}>{displayValue}</code>
|
||||
<Button type="button" size="icon" variant="ghost" className="h-7 w-7" aria-label="复制记录值" title="复制记录值" onClick={() => copyField("记录值", displayValue)}><Copy className="h-3.5 w-3.5" /></Button>
|
||||
<code className="break-all pt-1 font-mono">{record.value}</code>
|
||||
<Button type="button" size="icon" variant="ghost" className="h-7 w-7" aria-label="复制记录值" title="复制记录值" onClick={() => copyField("记录值", record.value)}><Copy className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{mx && <div className="grid grid-cols-[4.5rem_minmax(0,1fr)] gap-2"><span className="text-foreground">优先级</span><code className="font-mono font-medium text-foreground">{mx.priority}</code></div>}
|
||||
<div className="grid grid-cols-[4.5rem_minmax(0,1fr)] gap-2"><span className="text-foreground">TTL</span><code className="font-mono">{record.ttl} 秒</code></div>
|
||||
<div 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>
|
||||
|
||||
@@ -12,7 +12,6 @@ import { Label } from "@/components/ui/label"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import { safeReturnPath } from "@/lib/navigation"
|
||||
import { AuthError, AuthLoading } from "@/components/auth-states"
|
||||
import { BrandMark } from "@/components/brand-mark"
|
||||
|
||||
export function LoginPage() {
|
||||
const me = useMe()
|
||||
@@ -44,8 +43,7 @@ export function LoginPage() {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
|
||||
<div className="w-full max-w-[420px]">
|
||||
<div className="mb-7 flex items-center justify-center gap-3 text-center">
|
||||
<BrandMark className="size-11 [&>svg]:size-7" />
|
||||
<div className="mb-7 text-center">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">NewSzxcn 邮箱</h1>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-background p-6 shadow-sm sm:p-7">
|
||||
|
||||
+23
-30
@@ -653,8 +653,8 @@ export function MailPage() {
|
||||
|
||||
const first = newMessages[0]
|
||||
const firstSender = senderDisplayName(first)
|
||||
const title = newMessages.length > 1 ? `收到 ${newMessages.length} 封新邮件` : `新邮件:${messageSubject(first)}`
|
||||
const description = newMessages.length > 1 ? `${firstSender} 等发来新邮件` : firstSender
|
||||
const title = newMessages.length > 1 ? `收到 ${newMessages.length} 封新邮件` : `新邮件:${first.subject || "(无主题)"}`
|
||||
const description = newMessages.length > 1 ? `${firstSender} 等发来新邮件` : `${firstSender}${first.snippet ? ` · ${first.snippet}` : ""}`
|
||||
const openFirstMessage = () => {
|
||||
setMailView("folder")
|
||||
setFolder("Inbox")
|
||||
@@ -869,12 +869,11 @@ export function MailPage() {
|
||||
}
|
||||
function confirmDeleteMessage(message: MailMessage) {
|
||||
const permanent = message.folder === "Trash"
|
||||
const subject = messageSubject(message)
|
||||
setPendingConfirm({
|
||||
title: permanent ? "永久删除这封邮件?" : "将这封邮件移入已删除?",
|
||||
description: permanent
|
||||
? `邮件“${subject}”将被永久删除,且无法恢复。`
|
||||
: `邮件“${subject}”将移入已删除。`,
|
||||
? `邮件“${message.subject || "无主题"}”将被永久删除,且无法恢复。`
|
||||
: `邮件“${message.subject || "无主题"}”将移入已删除。`,
|
||||
confirmText: permanent ? "永久删除" : "移入已删除",
|
||||
onConfirm: () => del.mutate({ id: message.id, permanent }),
|
||||
})
|
||||
@@ -887,11 +886,11 @@ export function MailPage() {
|
||||
}
|
||||
function openReply(message: MailMessage) {
|
||||
if (!canSendMail) return
|
||||
openCompose({ key: `reply-${message.id}-${Date.now()}`, mailboxId: message.mailboxId, to: message.from, subject: withPrefix(messageSubject(message), "Re:"), text: quoteMessage(message) })
|
||||
openCompose({ key: `reply-${message.id}-${Date.now()}`, mailboxId: message.mailboxId, to: message.from, subject: withPrefix(message.subject, "Re:"), text: quoteMessage(message) })
|
||||
}
|
||||
function openForward(message: MailMessage) {
|
||||
if (!canSendMail) return
|
||||
openCompose({ key: `forward-${message.id}-${Date.now()}`, mailboxId: message.mailboxId, subject: withPrefix(messageSubject(message), "Fwd:"), text: quoteMessage(message) })
|
||||
openCompose({ key: `forward-${message.id}-${Date.now()}`, mailboxId: message.mailboxId, subject: withPrefix(message.subject, "Fwd:"), text: quoteMessage(message) })
|
||||
}
|
||||
async function openDraft(message: MailMessage) {
|
||||
if (!canManageDrafts) return
|
||||
@@ -1308,7 +1307,7 @@ export function MailPage() {
|
||||
setAdvancedSearchOpen(false)
|
||||
}
|
||||
const sidebarContent = (
|
||||
<Sidebar collapsible="none" className="h-full min-h-0 w-full overflow-hidden border-r border-border bg-sidebar text-sidebar-foreground">
|
||||
<Sidebar collapsible="none" className="mail-sidebar-pane h-full min-h-0 w-full overflow-hidden border-r border-border bg-sidebar text-sidebar-foreground">
|
||||
<SidebarHeader className={cn("shrink-0 pb-2 pt-3", sidebarCollapsed ? "px-2" : "px-3")}>
|
||||
<AccountHeader
|
||||
collapsed={sidebarCollapsed}
|
||||
@@ -1740,7 +1739,7 @@ export function MailPage() {
|
||||
) : (
|
||||
<div className={cn("mail-content-grid min-h-0 flex-1 bg-background", selectedId && "is-reading")}>
|
||||
<div className={cn("mail-list-pane min-w-0", selectedId && "max-[767px]:hidden")}>
|
||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||
<div className="mail-list-surface flex h-full min-h-0 flex-col bg-card">
|
||||
<div className="shrink-0 border-b px-3 pb-2.5 pt-2.5">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
||||
@@ -1822,7 +1821,7 @@ export function MailPage() {
|
||||
</div>
|
||||
|
||||
<section className={cn("mail-detail-pane min-w-0", !selectedId && "max-[767px]:hidden")}>
|
||||
<div className="h-full min-h-0 bg-background">
|
||||
<div className="mail-detail-surface h-full min-h-0 bg-background">
|
||||
{!selectedId && (
|
||||
<div className="grid h-full place-items-center text-muted-foreground">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
@@ -1873,7 +1872,7 @@ export function MailPage() {
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="h-svh overflow-hidden bg-background">
|
||||
<div className="mail-app-shell h-svh overflow-hidden bg-background">
|
||||
<SidebarProvider className="h-full min-h-0 w-full min-w-0 flex-col">
|
||||
{isMobile ? (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
@@ -1901,7 +1900,7 @@ export function MailPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="mail-shell-grid h-full min-h-0 w-full min-w-0 overflow-hidden">
|
||||
<div className="h-full min-h-0 min-w-0 overflow-hidden">
|
||||
<div className="mail-sidebar-wrap h-full min-h-0 min-w-0 overflow-hidden">
|
||||
{sidebarContent}
|
||||
</div>
|
||||
<section className="flex h-full min-h-0 min-w-0 flex-col">
|
||||
@@ -1943,7 +1942,6 @@ export function MailPage() {
|
||||
<CreateFolderDialog
|
||||
open={folderDialogOpen}
|
||||
pending={createFolder.isPending}
|
||||
scope={isAllMailboxSelected ? "全部邮箱" : selectedMailbox?.address || "当前邮箱"}
|
||||
onOpenChange={setFolderDialogOpen}
|
||||
onCreate={(payload) => createFolder.mutate(payload)}
|
||||
/>
|
||||
@@ -2827,7 +2825,7 @@ function contextMenuPosition(x: number, y: number) {
|
||||
return { x: Math.min(Math.max(x, padding), maxX), y: Math.min(Math.max(y, padding), maxY) }
|
||||
}
|
||||
|
||||
function CreateFolderDialog({ open, pending, scope, onOpenChange, onCreate }: { open: boolean; pending: boolean; scope: string; onOpenChange: (open: boolean) => void; onCreate: (payload: { name: string; icon: string }) => void }) {
|
||||
function CreateFolderDialog({ open, pending, onOpenChange, onCreate }: { open: boolean; pending: boolean; onOpenChange: (open: boolean) => void; onCreate: (payload: { name: string; icon: string }) => void }) {
|
||||
const [name, setName] = React.useState("")
|
||||
const [icon, setIcon] = React.useState("auto")
|
||||
const [uploadError, setUploadError] = React.useState("")
|
||||
@@ -2858,7 +2856,6 @@ function CreateFolderDialog({ open, pending, scope, onOpenChange, onCreate }: {
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-folder-name">文件夹名称</Label>
|
||||
<Input id="new-folder-name" autoFocus value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:客户、账单、项目归档" />
|
||||
<p className="text-xs text-muted-foreground">创建位置:{scope}</p>
|
||||
</div>
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium">图标</legend>
|
||||
@@ -3033,7 +3030,7 @@ function CompactMailView({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background">
|
||||
<div className="compact-mail-surface flex min-h-0 min-w-0 flex-1 flex-col bg-background">
|
||||
<div className="flex min-h-11 shrink-0 items-center gap-3 border-b px-3 sm:px-4">
|
||||
<div className={cn("flex min-w-0 items-center gap-2.5", selectedIds.length === 0 && "flex-1")}>
|
||||
<Checkbox aria-label="选择当前页邮件" checked={allSelected ? true : someSelected ? "indeterminate" : false} onCheckedChange={(value) => onSelectAll(value === true)} />
|
||||
@@ -3124,7 +3121,7 @@ function CompactMessageDetail({
|
||||
language: Language
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-background">
|
||||
<div className="compact-mail-surface flex min-h-0 flex-1 flex-col bg-background">
|
||||
<div className="shrink-0 border-b px-3 py-2 sm:px-4">
|
||||
<div className="flex min-h-10 items-center gap-2 sm:hidden">
|
||||
<Button variant="ghost" size="icon" onClick={onBack} aria-label="返回">
|
||||
@@ -3193,7 +3190,7 @@ function CompactMessageDetail({
|
||||
<div className="w-full px-4 py-4 sm:px-8 sm:py-6">
|
||||
<div className="space-y-5 border-b pb-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<h1 className="min-w-0 flex-1 break-words text-xl font-semibold tracking-tight sm:text-2xl">{messageSubject(selected)}</h1>
|
||||
<h1 className="min-w-0 flex-1 break-words text-xl font-semibold tracking-tight sm:text-2xl">{selected.subject}</h1>
|
||||
{canOrganize && <Button type="button" variant="ghost" size="icon" aria-label={selected.isStarred ? "取消星标" : "添加星标"} className="text-muted-foreground hover:text-yellow-500" onClick={() => onStar(selected)}>
|
||||
<Star className={cn("h-5 w-5", selected.isStarred && "fill-yellow-400 text-yellow-500")} />
|
||||
</Button>}
|
||||
@@ -3396,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 && "mail-selected-row"
|
||||
active && "bg-[hsl(var(--mail-selected))]"
|
||||
)}>
|
||||
<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" />
|
||||
@@ -3419,7 +3416,7 @@ function CompactMessageRow({ message, active, checked, scheduled, onCheckedChang
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 flex min-w-0 items-center gap-2 sm:mt-0">
|
||||
<span className="truncate font-medium">{messageSubject(message)}</span>
|
||||
<span className="truncate font-medium">{message.subject}</span>
|
||||
<span className="hidden min-w-0 truncate text-muted-foreground sm:block">{message.snippet}</span>
|
||||
{scheduled && <Badge variant="secondary" className="h-5 shrink-0 rounded-md px-1.5 text-[11px] font-normal">已定时</Badge>}
|
||||
{visibleLabels.map((label) => <MailLabelBadge key={label.id} label={label} />)}
|
||||
@@ -3496,8 +3493,8 @@ function AccountHeader({ collapsed, name, email, darkMode, language, onToggleThe
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button type="button" variant="ghost" size="icon" className="size-7 rounded-md text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={onToggleTheme} title={darkMode ? "切换到浅色模式" : "切换到深色模式"} aria-label={darkMode ? "切换到浅色模式" : "切换到深色模式"}>
|
||||
{darkMode ? <Sun className="h-3.5 w-3.5 text-amber-500" /> : <Moon className="h-3.5 w-3.5" />}
|
||||
<Button type="button" variant="ghost" size="icon" className={cn("size-7 rounded-md text-muted-foreground hover:bg-transparent hover:text-foreground", darkMode && "text-amber-400 hover:text-amber-300")} onClick={onToggleTheme} title={darkMode ? "切换到浅色模式" : "切换到深色模式"} aria-label={darkMode ? "切换到浅色模式" : "切换到深色模式"}>
|
||||
{darkMode ? <Sun className="h-3.5 w-3.5" /> : <Moon className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -3623,10 +3620,6 @@ function senderDisplayName(message: MailMessage) {
|
||||
return displayNameFromAddress(message.from)
|
||||
}
|
||||
|
||||
function messageSubject(message: MailMessage) {
|
||||
return decodeMimeHeader(message.subject?.trim() || "") || "无主题"
|
||||
}
|
||||
|
||||
function displayNameFromAddress(value: string) {
|
||||
const text = decodeMimeHeader(value.trim())
|
||||
const namedAddress = text.match(/^"?([^"<]+?)"?\s*<[^>]+>$/)
|
||||
@@ -3837,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 && "mail-selected-row",
|
||||
checked && "mail-selected-row"
|
||||
active && "bg-[hsl(var(--mail-selected))]",
|
||||
checked && "bg-accent/70"
|
||||
)}>
|
||||
<div className="flex gap-2.5">
|
||||
<Checkbox
|
||||
@@ -3882,7 +3875,7 @@ function MessageRow({
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-1 flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 truncate text-[13px] text-foreground">{messageSubject(message)}</span>
|
||||
<span className="min-w-0 truncate text-[13px] text-foreground">{message.subject || "无主题"}</span>
|
||||
{scheduled && <Badge variant="secondary" className="h-5 shrink-0 rounded-md px-1.5 text-[11px] font-normal">已定时</Badge>}
|
||||
{visibleLabels.map((label) => <MailLabelBadge key={label.id} label={label} />)}
|
||||
{hiddenLabelCount > 0 && <Badge variant="outline" className="h-5 shrink-0 rounded-md px-1.5 text-[11px] font-normal text-muted-foreground">+{hiddenLabelCount}</Badge>}
|
||||
@@ -5336,7 +5329,7 @@ function withPrefix(subject: string, prefix: string) { return subject.toLowerCas
|
||||
function quoteMessage(message: MailMessage) {
|
||||
const body = message.bodyText || stripHtml(message.bodyHtml || message.snippet || "")
|
||||
const quote = body.split("\n").map((line) => `> ${line}`).join("\n")
|
||||
return `\n\n----- 原始邮件 -----\nFrom: ${senderTitle(message)}\nTo: ${message.to.join(", ")}\nDate: ${formatDateTime(message.receivedAt)}\nSubject: ${messageSubject(message)}\n\n${quote}`
|
||||
return `\n\n----- 原始邮件 -----\nFrom: ${senderTitle(message)}\nTo: ${message.to.join(", ")}\nDate: ${formatDateTime(message.receivedAt)}\nSubject: ${message.subject}\n\n${quote}`
|
||||
}
|
||||
function stripHtml(html: string) { const div = document.createElement("div"); div.innerHTML = DOMPurify.sanitize(html); return div.textContent || div.innerText || "" }
|
||||
function attachmentLimitBytes(limits?: PermissionLimits) {
|
||||
|
||||
@@ -26,14 +26,13 @@ import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/s
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
|
||||
type Tab = "profile" | "mailboxes" | "contacts" | "cleanup" | "cleanupQueue" | "rules" | "blocked" | "stats" | "apiTokens"
|
||||
type AccountSettingsTab = "account" | "mail" | "clients" | "security"
|
||||
type PendingConfirm = { title: string; description?: string; confirmText: string; destructive?: boolean; onConfirm: () => void }
|
||||
type RetryableQuery = { isLoading: boolean; isError: boolean; error: Error | null; refetch: () => Promise<unknown> }
|
||||
type RetryableQuery = { isError: boolean; error: Error | null; refetch: () => Promise<unknown> }
|
||||
const tabs: Record<Tab, { label: string; icon: React.ReactNode }> = {
|
||||
profile: { label: "账号设置", icon: <Settings className="h-4 w-4" /> },
|
||||
mailboxes: { label: "邮箱管理", icon: <Mail className="h-4 w-4" /> },
|
||||
@@ -344,7 +343,7 @@ export function ProfilePage() {
|
||||
if (me.isError || !user) return <div className="grid h-svh place-items-center text-muted-foreground">登录状态已失效</div>
|
||||
|
||||
const sidebarContent = (
|
||||
<div className="flex h-full w-[var(--app-sidebar-width)] shrink-0 flex-col border-r border-border bg-card">
|
||||
<div className="settings-sidebar-panel flex h-full w-[var(--app-sidebar-width)] shrink-0 flex-col border-r border-border bg-card">
|
||||
<div className="h-[64px] border-b">
|
||||
<AccountHeader name={user.displayName || selectedMailbox?.address || "NewSzxcn"} email={user.email || selectedMailbox?.address} darkMode={darkMode} onToggleTheme={() => setDarkMode((v) => !v)} onBack={() => navigate("/")} />
|
||||
</div>
|
||||
@@ -357,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-[hsl(var(--sidebar-active))] font-semibold text-[hsl(var(--sidebar-active-foreground))]" : "text-muted-foreground hover:bg-muted/70 hover:text-foreground",
|
||||
tab === key ? "bg-muted font-semibold text-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-[hsl(var(--sidebar-active-foreground))]")}>{tabs[key].icon}</span>
|
||||
<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="truncate">{tabs[key].label}</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -378,7 +377,7 @@ export function ProfilePage() {
|
||||
</div>
|
||||
</nav>
|
||||
<div className="border-t p-2">
|
||||
<Button type="button" variant="outline" size="sm" className="h-9 w-full justify-start gap-2 border-destructive/35 px-3 text-destructive shadow-none hover:border-destructive/55 hover:bg-destructive/10 hover:text-destructive dark:border-destructive/45 dark:hover:bg-destructive/15" onClick={logout}>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-9 w-full justify-start gap-2 px-3 text-destructive hover:text-destructive" onClick={logout}>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>退出登录</span>
|
||||
</Button>
|
||||
@@ -398,7 +397,7 @@ export function ProfilePage() {
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div className="h-svh overflow-hidden bg-background">
|
||||
<div className="settings-app-shell h-svh overflow-hidden bg-background">
|
||||
{isMobile ? (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-3">
|
||||
@@ -422,9 +421,9 @@ export function ProfilePage() {
|
||||
</ScrollArea>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full min-h-0 w-full">
|
||||
<div className="settings-shell-grid flex h-full min-h-0 w-full">
|
||||
{sidebarContent}
|
||||
<section className="min-w-0 flex-1 overflow-y-auto">
|
||||
<section className="settings-content-panel settings-glass-content min-w-0 flex-1 overflow-y-auto">
|
||||
<main className="pb-12">
|
||||
<SettingsPageHeader title={pageTitle} subtitle={pageSubtitle} action={pageAction} activeTab={tab === "profile" ? accountTab : undefined} onAccountTabChange={setAccountTab} />
|
||||
<div className={contentFrameClass(tab)}>{renderTab()}</div>
|
||||
@@ -435,8 +434,7 @@ export function ProfilePage() {
|
||||
</div>
|
||||
)
|
||||
function renderTab() {
|
||||
const visibleQueries = visibleTabQueries()
|
||||
const failedQueries = visibleQueries.filter((query) => query.isError && query.error)
|
||||
const failedQueries = visibleTabQueries().filter((query) => query.isError && query.error)
|
||||
if (failedQueries.length > 0) {
|
||||
return (
|
||||
<ProfileQueryFailure
|
||||
@@ -445,7 +443,6 @@ export function ProfilePage() {
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (visibleQueries.some((query) => query.isLoading)) return <ProfileSectionLoading />
|
||||
if (tab === "profile") return (
|
||||
<AccountSettingsSection
|
||||
activeTab={accountTab}
|
||||
@@ -537,16 +534,6 @@ 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) {
|
||||
return cn(
|
||||
"w-full",
|
||||
@@ -576,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-[hsl(var(--sidebar-active-foreground))] bg-[hsl(var(--sidebar-active))] text-[hsl(var(--sidebar-active-foreground))]" : "border-transparent text-muted-foreground hover:text-foreground",
|
||||
activeTab === item.key ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
onClick={() => onAccountTabChange(item.key)}
|
||||
>
|
||||
@@ -2880,8 +2867,8 @@ function AccountHeader({ name, email, darkMode, onToggleTheme, onBack }: { name:
|
||||
<div className="min-w-0 truncate text-sm font-semibold leading-5">{displayName}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button type="button" variant="ghost" size="icon" className="size-[28px] rounded-md text-muted-foreground" aria-label={darkMode ? "切换浅色模式" : "切换深色模式"} title={darkMode ? "浅色模式" : "深色模式"} onClick={onToggleTheme}>
|
||||
{darkMode ? <Sun className="h-4 w-4 text-amber-500" /> : <Moon className="h-4 w-4" />}
|
||||
<Button type="button" variant="ghost" size="icon" className={cn("size-[28px] rounded-md text-muted-foreground", darkMode && "text-amber-400 hover:text-amber-300")} aria-label={darkMode ? "切换浅色模式" : "切换深色模式"} title={darkMode ? "浅色模式" : "深色模式"} onClick={onToggleTheme}>
|
||||
{darkMode ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="size-[28px] rounded-md text-muted-foreground" aria-label="返回邮箱" title="返回邮箱" onClick={onBack}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
|
||||
@@ -14,7 +14,6 @@ import { PasswordInput } from "@/components/ui/password-input"
|
||||
import { TurnstileBox } from "@/components/turnstile-box"
|
||||
import { validatePasswordConfirm } from "@/lib/validation"
|
||||
import { AuthError, AuthLoading } from "@/components/auth-states"
|
||||
import { BrandMark } from "@/components/brand-mark"
|
||||
|
||||
export function RegisterPage() {
|
||||
const me = useMe()
|
||||
@@ -64,8 +63,7 @@ export function RegisterPage() {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
|
||||
<div className="w-full max-w-[420px]">
|
||||
<div className="mb-7 flex items-center justify-center gap-3 text-center">
|
||||
<BrandMark className="size-11 [&>svg]:size-7" />
|
||||
<div className="mb-7 text-center">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">NewSzxcn 邮箱</h1>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-background p-6 shadow-sm sm:p-7">
|
||||
|
||||
Reference in New Issue
Block a user