release: prepare v1.2.21
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
- 优化登录与会话流程:登录后完整返回原页面及查询条件,区分未登录、超时、取消和网络故障,并为服务不可用状态提供明确的重新连接入口。
|
||||||
|
- 完善后台与邮箱错误处理:管理员设置、邮件列表、邮件详情、发送队列、个人资料、签名、DNS 检查及注册流程均增加真实失败提示和重试反馈,避免静默失败或错误成功提示。
|
||||||
|
- 优化邮箱与个人设置:恢复显示名称编辑,新增可持久化的标准/紧凑邮件布局,移除无效时区和模拟资料,修正发信成功后草稿删除失败的提示逻辑。
|
||||||
|
- 保持并强化转发规则:账号级转发目标在所有邮箱中默认勾选、置顶且不可取消,单邮箱可继续追加独立目标,现有单邮箱配置不受影响,目标按数字和字母排序。
|
||||||
|
- 统一界面视觉与交互:采用更清晰的中性色、文字层级、焦点状态和紧凑圆角,补充图标按钮名称、工具提示及全屏页面主区域,改善桌面与移动端可访问性。
|
||||||
|
- 优化前端加载与开发体验:拆分邮件编辑器相关代码包,控制单个构建文件体积,并支持通过 `VITE_API_TARGET` 指定本地后端代理地址。
|
||||||
|
- 加固邮件 HTML 安全:升级 DOMPurify 至已修复版本,依赖审计无已知漏洞;同步清理过时演示数据、无效状态和旧页面逻辑。
|
||||||
|
- 完成全量质量复核:前后端构建、Go 全量及竞态测试、SMTP、外部 IMAP、OAuth、Telegram、Webhook、转发、队列、权限隔离、安装、备份、回滚和 DKIM 同步测试均已通过。
|
||||||
@@ -231,6 +231,8 @@ pnpm install
|
|||||||
pnpm run dev
|
pnpm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
后端不在默认的 `http://localhost:8080` 时,可通过 `VITE_API_TARGET=http://localhost:18080 pnpm run dev` 指定本地代理目标。
|
||||||
|
|
||||||
提交前建议运行:
|
提交前建议运行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"@tiptap/starter-kit": "^3.27.0",
|
"@tiptap/starter-kit": "^3.27.0",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"dompurify": "3.4.12",
|
"dompurify": "3.4.13",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
import { Navigate, useLocation } from "react-router-dom"
|
import { Navigate, useLocation } from "react-router-dom"
|
||||||
import { useMe, isTimeoutError } from "@/hooks/use-me"
|
import { useMe } from "@/hooks/use-me"
|
||||||
import { AuthLoading, AuthError } from "@/components/auth-states"
|
import { AuthLoading, AuthError } from "@/components/auth-states"
|
||||||
|
import { isUnauthorizedError } from "@/lib/api"
|
||||||
|
|
||||||
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
if (me.isLoading) return <AuthLoading />
|
if (me.isLoading) return <AuthLoading />
|
||||||
if (me.isError && isTimeoutError(me.error)) return <AuthError message={me.error.message} onRetry={() => me.refetch()} />
|
if (me.isError && !isUnauthorizedError(me.error)) return <AuthError message={me.error.message} onRetry={() => me.refetch()} />
|
||||||
if (me.isError || !me.data?.user) return <Navigate to="/login" replace state={{ from: location.pathname }} />
|
if (me.isError || !me.data?.user) {
|
||||||
|
const from = `${location.pathname}${location.search}${location.hash}`
|
||||||
|
return <Navigate to="/login" replace state={{ from }} />
|
||||||
|
}
|
||||||
|
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
export function AuthLoading() {
|
export function AuthLoading() {
|
||||||
return <div className="grid min-h-screen place-items-center text-muted-foreground">加载中...</div>
|
return <main className="grid min-h-screen place-items-center text-muted-foreground">加载中...</main>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthError({ message, onRetry }: { message: string; onRetry: () => void }) {
|
export function AuthError({ message, onRetry }: { message: string; onRetry: () => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid min-h-screen place-items-center bg-background px-4">
|
<main className="grid min-h-screen place-items-center bg-background px-4">
|
||||||
<div className="w-full max-w-sm space-y-4 text-center">
|
<div className="w-full max-w-sm space-y-4 text-center">
|
||||||
<div className="text-sm font-medium">无法连接后端服务</div>
|
<div className="text-sm font-medium">服务暂时不可用</div>
|
||||||
<div className="text-sm text-muted-foreground">{message}</div>
|
<div className="text-sm text-muted-foreground">{message}</div>
|
||||||
<Button type="button" variant="outline" onClick={onRetry}>重新加载</Button>
|
<Button type="button" variant="outline" onClick={onRetry}>重新连接</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,18 +5,18 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
"bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
outline:
|
outline:
|
||||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const Card = React.forwardRef<
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-xl border bg-card text-card-foreground shadow",
|
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef<
|
|||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
|
|||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useQuery, type UseQueryOptions } from "@tanstack/react-query"
|
import { useQuery, type UseQueryOptions } from "@tanstack/react-query"
|
||||||
import { api } from "@/lib/api"
|
import { api, isUnauthorizedError } from "@/lib/api"
|
||||||
import type { User } from "@/lib/api"
|
import type { User } from "@/lib/api"
|
||||||
|
|
||||||
type MeResponse = { user: User }
|
type MeResponse = { user: User }
|
||||||
@@ -10,11 +10,7 @@ export function useMe(
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["me"],
|
queryKey: ["me"],
|
||||||
queryFn: api.me,
|
queryFn: api.me,
|
||||||
retry: 1,
|
retry: (failureCount, error) => !isUnauthorizedError(error) && failureCount < 1,
|
||||||
...options,
|
...options,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isTimeoutError(error: unknown): boolean {
|
|
||||||
return error instanceof Error && error.message.includes("请求超时")
|
|
||||||
}
|
|
||||||
|
|||||||
+19
-19
@@ -5,34 +5,34 @@
|
|||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
--background: 0 0% 100%;
|
||||||
--foreground: 222.2 84% 4.9%;
|
--foreground: 0 0% 9%;
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 222.2 84% 4.9%;
|
--card-foreground: 0 0% 9%;
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 222.2 84% 4.9%;
|
--popover-foreground: 0 0% 9%;
|
||||||
--primary: 224 44% 12%;
|
--primary: 0 0% 12%;
|
||||||
--primary-foreground: 0 0% 98%;
|
--primary-foreground: 0 0% 98%;
|
||||||
--secondary: 210 40% 96.1%;
|
--secondary: 0 0% 96.1%;
|
||||||
--secondary-foreground: 222.2 84% 4.9%;
|
--secondary-foreground: 0 0% 12%;
|
||||||
--muted: 210 40% 96.1%;
|
--muted: 0 0% 96.1%;
|
||||||
--muted-foreground: 215.4 16.3% 46.9%;
|
--muted-foreground: 0 0% 42%;
|
||||||
--accent: 210 40% 96.1%;
|
--accent: 0 0% 95%;
|
||||||
--accent-foreground: 222.2 84% 4.9%;
|
--accent-foreground: 0 0% 12%;
|
||||||
--destructive: 358 88% 61%;
|
--destructive: 358 88% 61%;
|
||||||
--destructive-foreground: 0 0% 98%;
|
--destructive-foreground: 0 0% 98%;
|
||||||
--border: 214.3 31.8% 91.4%;
|
--border: 0 0% 89.8%;
|
||||||
--input: 214.3 31.8% 91.4%;
|
--input: 0 0% 82%;
|
||||||
--ring: 216 22% 42%;
|
--ring: 0 0% 32%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
--app-sidebar-width: 18rem;
|
--app-sidebar-width: 18rem;
|
||||||
--sidebar-background: 0 0% 100%;
|
--sidebar-background: 0 0% 100%;
|
||||||
--sidebar-foreground: 222.2 84% 4.9%;
|
--sidebar-foreground: 0 0% 16%;
|
||||||
--sidebar-primary: 224 44% 12%;
|
--sidebar-primary: 0 0% 12%;
|
||||||
--sidebar-primary-foreground: 0 0% 98%;
|
--sidebar-primary-foreground: 0 0% 98%;
|
||||||
--sidebar-accent: 210 40% 96.1%;
|
--sidebar-accent: 0 0% 95%;
|
||||||
--sidebar-accent-foreground: 222.2 84% 4.9%;
|
--sidebar-accent-foreground: 0 0% 12%;
|
||||||
--sidebar-border: 214.3 31.8% 91.4%;
|
--sidebar-border: 0 0% 89.8%;
|
||||||
--sidebar-ring: 215.4 16.3% 46.9%;
|
--sidebar-ring: 0 0% 42%;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { @apply border-border; }
|
* { @apply border-border; }
|
||||||
|
|||||||
+22
-4
@@ -4,6 +4,17 @@ export * from "./api-types"
|
|||||||
const REQUEST_TIMEOUT_MS = 15_000
|
const REQUEST_TIMEOUT_MS = 15_000
|
||||||
const MAIL_DELIVERY_TIMEOUT_MS = 60_000
|
const MAIL_DELIVERY_TIMEOUT_MS = 60_000
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(message: string, readonly status: number) {
|
||||||
|
super(message)
|
||||||
|
this.name = "ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUnauthorizedError(error: unknown): boolean {
|
||||||
|
return error instanceof ApiError && error.status === 401
|
||||||
|
}
|
||||||
|
|
||||||
export type MailSearchParams = {
|
export type MailSearchParams = {
|
||||||
q?: string
|
q?: string
|
||||||
from?: string
|
from?: string
|
||||||
@@ -45,27 +56,34 @@ function appendMailSearchParams(params: URLSearchParams, search: MailSearchParam
|
|||||||
async function request<T>(path: string, init: RequestInit & { timeoutMs?: number } = {}): Promise<T> {
|
async function request<T>(path: string, init: RequestInit & { timeoutMs?: number } = {}): Promise<T> {
|
||||||
const { timeoutMs, ...requestInit } = init
|
const { timeoutMs, ...requestInit } = init
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timeout = window.setTimeout(() => controller.abort(), timeoutMs || REQUEST_TIMEOUT_MS)
|
let timedOut = false
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
timedOut = true
|
||||||
|
controller.abort()
|
||||||
|
}, timeoutMs || REQUEST_TIMEOUT_MS)
|
||||||
const externalSignal = requestInit.signal
|
const externalSignal = requestInit.signal
|
||||||
|
const abortFromExternalSignal = () => controller.abort()
|
||||||
if (externalSignal) {
|
if (externalSignal) {
|
||||||
if (externalSignal.aborted) controller.abort()
|
if (externalSignal.aborted) controller.abort()
|
||||||
else externalSignal.addEventListener("abort", () => controller.abort(), { once: true })
|
else externalSignal.addEventListener("abort", abortFromExternalSignal, { once: true })
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await fetch(path, { credentials: "include", headers: { "Content-Type": "application/json", ...(requestInit.headers || {}) }, ...requestInit, signal: controller.signal })
|
const res = await fetch(path, { credentials: "include", headers: { "Content-Type": "application/json", ...(requestInit.headers || {}) }, ...requestInit, signal: controller.signal })
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let message = `${res.status} ${res.statusText}`
|
let message = `${res.status} ${res.statusText}`
|
||||||
try { const body = await res.json(); message = body.error || message } catch {}
|
try { const body = await res.json(); message = body.error || message } catch {}
|
||||||
throw new Error(message)
|
throw new ApiError(message, res.status)
|
||||||
}
|
}
|
||||||
return res.json() as Promise<T>
|
return res.json() as Promise<T>
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DOMException && error.name === "AbortError") {
|
if (error instanceof DOMException && error.name === "AbortError") {
|
||||||
throw new Error("请求超时,请检查后端服务是否正常")
|
throw new Error(timedOut ? "请求超时,请检查后端服务是否正常" : "请求已取消")
|
||||||
}
|
}
|
||||||
|
if (error instanceof TypeError) throw new Error("无法连接后端服务,请检查服务状态")
|
||||||
throw error instanceof Error ? error : new Error("网络请求失败")
|
throw error instanceof Error ? error : new Error("网络请求失败")
|
||||||
} finally {
|
} finally {
|
||||||
window.clearTimeout(timeout)
|
window.clearTimeout(timeout)
|
||||||
|
externalSignal?.removeEventListener("abort", abortFromExternalSignal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export function safeReturnPath(value: unknown, fallback = "/"): string {
|
||||||
|
if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) return fallback
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -101,6 +101,14 @@ export function AdminPage() {
|
|||||||
const visibleSections = sectionKeys.filter((key) => hasAnyPermission(user, sectionPermissions[key]))
|
const visibleSections = sectionKeys.filter((key) => hasAnyPermission(user, sectionPermissions[key]))
|
||||||
const rawSection = params.get("section") as Section | null
|
const rawSection = params.get("section") as Section | null
|
||||||
const section: Section = rawSection && visibleSections.includes(rawSection) ? rawSection : visibleSections[0] || "overview"
|
const section: Section = rawSection && visibleSections.includes(rawSection) ? rawSection : visibleSections[0] || "overview"
|
||||||
|
const sectionQuery = section === "overview" ? overview
|
||||||
|
: section === "users" ? users
|
||||||
|
: section === "permissionGroups" ? permissionGroups
|
||||||
|
: section === "domains" ? domains
|
||||||
|
: section === "mailboxes" ? mailboxes
|
||||||
|
: section === "aliases" ? aliases
|
||||||
|
: section === "settings" ? settings
|
||||||
|
: null
|
||||||
|
|
||||||
async function refreshAdminPage() {
|
async function refreshAdminPage() {
|
||||||
if (refreshing) return
|
if (refreshing) return
|
||||||
@@ -124,6 +132,8 @@ export function AdminPage() {
|
|||||||
<main className="mx-auto w-full max-w-[1180px] px-3 pb-10 pt-3 sm:px-4 sm:pt-4">
|
<main className="mx-auto w-full max-w-[1180px] px-3 pb-10 pt-3 sm:px-4 sm:pt-4">
|
||||||
<AdminPageHeader section={section} refreshing={refreshing} onRefresh={refreshAdminPage} />
|
<AdminPageHeader section={section} refreshing={refreshing} onRefresh={refreshAdminPage} />
|
||||||
|
|
||||||
|
{sectionQuery?.isError && <QueryFailure error={sectionQuery.error} onRetry={() => { void sectionQuery.refetch() }} />}
|
||||||
|
|
||||||
{section === "overview" && canOverview && (
|
{section === "overview" && canOverview && (
|
||||||
<div className="mb-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
<div className="mb-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
<Stat icon={<Users />} label="账号" value={overview.data?.users || 0} />
|
<Stat icon={<Users />} label="账号" value={overview.data?.users || 0} />
|
||||||
@@ -397,7 +407,7 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
|
|||||||
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">{group.description || "未填写说明"}</div>
|
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">{group.description || "未填写说明"}</div>
|
||||||
</div>
|
</div>
|
||||||
{(canUpdate || canDelete) && <DropdownMenu>
|
{(canUpdate || canDelete) && <DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
|
<DropdownMenuTrigger asChild><Button variant="ghost" size="icon" aria-label={`管理权限配置 ${group.name}`} title="更多操作"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem disabled={!isEditable(group) || !canUpdate} onSelect={() => setEditing(group)}>编辑权限配置</DropdownMenuItem>
|
<DropdownMenuItem disabled={!isEditable(group) || !canUpdate} onSelect={() => setEditing(group)}>编辑权限配置</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
@@ -903,7 +913,8 @@ function AdminMessagesSection({ mailboxes, systemAdmin }: { mailboxes: MailboxTy
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
{messages.isLoading && <Empty text="加载中..." />}
|
{messages.isLoading && <Empty text="加载中..." />}
|
||||||
{!messages.isLoading && items.length === 0 && <Empty text="暂无邮件" />}
|
{messages.isError && <QueryFailure error={messages.error} onRetry={() => { void messages.refetch() }} compact />}
|
||||||
|
{!messages.isLoading && !messages.isError && items.length === 0 && <Empty text="暂无邮件" />}
|
||||||
{!messages.isLoading && messages.hasNextPage && (
|
{!messages.isLoading && messages.hasNextPage && (
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Button variant="outline" size="sm" disabled={messages.isFetchingNextPage} onClick={() => messages.fetchNextPage()}>
|
<Button variant="outline" size="sm" disabled={messages.isFetchingNextPage} onClick={() => messages.fetchNextPage()}>
|
||||||
@@ -1016,7 +1027,8 @@ function AdminSendAuditSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
{audit.isLoading && <Empty text="加载中..." />}
|
{audit.isLoading && <Empty text="加载中..." />}
|
||||||
{!audit.isLoading && items.length === 0 && <Empty text="暂无发送记录" />}
|
{audit.isError && <QueryFailure error={audit.error} onRetry={() => { void audit.refetch() }} compact />}
|
||||||
|
{!audit.isLoading && !audit.isError && items.length === 0 && <Empty text="暂无发送记录" />}
|
||||||
{!audit.isLoading && audit.hasNextPage && (
|
{!audit.isLoading && audit.hasNextPage && (
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Button variant="outline" size="sm" disabled={audit.isFetchingNextPage} onClick={() => audit.fetchNextPage()}>
|
<Button variant="outline" size="sm" disabled={audit.isFetchingNextPage} onClick={() => audit.fetchNextPage()}>
|
||||||
@@ -1330,7 +1342,7 @@ function SystemSettingsSection({ settings, domains, mailboxes, initialTab }: { s
|
|||||||
<div className="space-y-3 border-l-2 border-primary/50 py-1 pl-3">
|
<div className="space-y-3 border-l-2 border-primary/50 py-1 pl-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<code className="min-w-0 flex-1 font-mono text-sm font-semibold">{telegramPairing.code}</code>
|
<code className="min-w-0 flex-1 font-mono text-sm font-semibold">{telegramPairing.code}</code>
|
||||||
<Button type="button" variant="ghost" size="icon" title="复制绑定码" onClick={() => navigator.clipboard.writeText(telegramPairing.code)}>
|
<Button type="button" variant="ghost" size="icon" aria-label="复制绑定码" title="复制绑定码" onClick={() => navigator.clipboard.writeText(telegramPairing.code)}>
|
||||||
<Copy className="h-4 w-4" />
|
<Copy className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1429,7 +1441,8 @@ function SystemSettingsSection({ settings, domains, mailboxes, initialTab }: { s
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>}
|
</Card>}
|
||||||
|
|
||||||
{settingsTab === "templates" && canViewTemplates && <MailTemplatesPanel templates={templates.data?.items || []} loading={templates.isLoading} canUpdate={canUpdateTemplates} canReset={canResetTemplates} />}
|
{settingsTab === "templates" && canViewTemplates && templates.isError && <QueryFailure error={templates.error} onRetry={() => { void templates.refetch() }} />}
|
||||||
|
{settingsTab === "templates" && canViewTemplates && !templates.isError && <MailTemplatesPanel templates={templates.data?.items || []} loading={templates.isLoading} canUpdate={canUpdateTemplates} canReset={canResetTemplates} />}
|
||||||
|
|
||||||
{settingsTab === "security" && <Card>
|
{settingsTab === "security" && <Card>
|
||||||
<CardHeader><CardTitle>安全设置</CardTitle></CardHeader>
|
<CardHeader><CardTitle>安全设置</CardTitle></CardHeader>
|
||||||
@@ -1808,6 +1821,18 @@ function Stat({ icon, label, value }: { icon: React.ReactNode; label: string; va
|
|||||||
}
|
}
|
||||||
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 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> }
|
function Empty({ text }: { text: string }) { return <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">{text}</div> }
|
||||||
|
|
||||||
|
function QueryFailure({ error, onRetry, compact = false }: { error: unknown; onRetry: () => void; compact?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className={cn("mb-4 flex flex-col gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-4 sm:flex-row sm:items-center sm:justify-between", compact && "mb-0")} role="alert">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-semibold text-destructive">数据读取失败</div>
|
||||||
|
<div className="mt-1 break-words text-sm text-muted-foreground">{queryErrorMessage(error)}</div>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" size="sm" className="shrink-0" onClick={onRetry}><RefreshCcw className="h-4 w-4" />重试</Button>
|
||||||
|
</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 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 invalidateAdmin(qc: ReturnType<typeof useQueryClient>) { qc.invalidateQueries({ queryKey: ["admin"] }); qc.invalidateQueries({ queryKey: ["mailboxes"] }); qc.invalidateQueries({ queryKey: ["me"] }) }
|
||||||
|
|
||||||
@@ -1968,7 +1993,7 @@ function UserActions({ user, permissionGroups, onDelete }: { user: AdminUser; pe
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!canUpdate && !canResetPassword && !onDelete) return null
|
if (!canUpdate && !canResetPassword && !onDelete) return null
|
||||||
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{canUpdate && <DropdownMenuItem onSelect={() => setEditOpen(true)}>编辑账号</DropdownMenuItem>}{canResetPassword && <DropdownMenuItem onSelect={() => setPasswordOpen(true)}>重置密码</DropdownMenuItem>}{!user.protected && user.role !== "admin" && canUpdate && <><DropdownMenuSeparator /><DropdownMenuItem onSelect={() => quickPatch({ disabled: !user.disabled })}>{user.disabled ? "启用账号" : "停用账号"}</DropdownMenuItem></>}{!user.protected && user.role !== "admin" && onDelete && <><DropdownMenuSeparator /><DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除账号</DropdownMenuItem></>}</DropdownMenuContent></DropdownMenu>{canUpdate && <EditUserDialog user={user} permissionGroups={permissionGroups} open={editOpen} onOpenChange={setEditOpen} />}{canResetPassword && <ResetPasswordDialog user={user} open={passwordOpen} onOpenChange={setPasswordOpen} />}</>
|
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" aria-label={`管理账号 ${accountPrimaryEmail(user)}`} title="更多操作"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{canUpdate && <DropdownMenuItem onSelect={() => setEditOpen(true)}>编辑账号</DropdownMenuItem>}{canResetPassword && <DropdownMenuItem onSelect={() => setPasswordOpen(true)}>重置密码</DropdownMenuItem>}{!user.protected && user.role !== "admin" && canUpdate && <><DropdownMenuSeparator /><DropdownMenuItem onSelect={() => quickPatch({ disabled: !user.disabled })}>{user.disabled ? "启用账号" : "停用账号"}</DropdownMenuItem></>}{!user.protected && user.role !== "admin" && onDelete && <><DropdownMenuSeparator /><DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除账号</DropdownMenuItem></>}</DropdownMenuContent></DropdownMenu>{canUpdate && <EditUserDialog user={user} permissionGroups={permissionGroups} open={editOpen} onOpenChange={setEditOpen} />}{canResetPassword && <ResetPasswordDialog user={user} open={passwordOpen} onOpenChange={setPasswordOpen} />}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateUserDialog(_props: { permissionGroups: PermissionGroup[] }) {
|
function CreateUserDialog(_props: { permissionGroups: PermissionGroup[] }) {
|
||||||
@@ -2012,12 +2037,12 @@ function CreateUserDialog(_props: { permissionGroups: PermissionGroup[] }) {
|
|||||||
function MailboxActions({ mailbox, users, canUpdate, onDelete }: { mailbox: MailboxType; users: AdminUser[]; canUpdate: boolean; onDelete?: () => void }) {
|
function MailboxActions({ mailbox, users, canUpdate, onDelete }: { mailbox: MailboxType; users: AdminUser[]; canUpdate: boolean; onDelete?: () => void }) {
|
||||||
const [open, setOpen] = React.useState(false)
|
const [open, setOpen] = React.useState(false)
|
||||||
if (!canUpdate && !onDelete) return null
|
if (!canUpdate && !onDelete) return null
|
||||||
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{canUpdate && <DropdownMenuItem onSelect={() => setOpen(true)}>编辑邮箱</DropdownMenuItem>}{canUpdate && onDelete && <DropdownMenuSeparator />}{onDelete && <DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除邮箱</DropdownMenuItem>}</DropdownMenuContent></DropdownMenu>{canUpdate && <EditMailboxDialog mailbox={mailbox} users={users} open={open} onOpenChange={setOpen} />}</>
|
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" aria-label={`管理邮箱 ${mailbox.address}`} title="更多操作"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{canUpdate && <DropdownMenuItem onSelect={() => setOpen(true)}>编辑邮箱</DropdownMenuItem>}{canUpdate && onDelete && <DropdownMenuSeparator />}{onDelete && <DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除邮箱</DropdownMenuItem>}</DropdownMenuContent></DropdownMenu>{canUpdate && <EditMailboxDialog mailbox={mailbox} users={users} open={open} onOpenChange={setOpen} />}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
function AliasActions({ alias, onToggle, onDelete }: { alias: Alias; onToggle?: () => void; onDelete?: () => void }) {
|
function AliasActions({ alias, onToggle, onDelete }: { alias: Alias; onToggle?: () => void; onDelete?: () => void }) {
|
||||||
if (!onToggle && !onDelete) return null
|
if (!onToggle && !onDelete) return null
|
||||||
return <DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{onToggle && <DropdownMenuItem onSelect={onToggle}>{alias.enabled ? "停用" : "启用"}</DropdownMenuItem>}{onToggle && onDelete && <DropdownMenuSeparator />}{onDelete && <DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除转发</DropdownMenuItem>}</DropdownMenuContent></DropdownMenu>
|
return <DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" aria-label={`管理转发 ${alias.source}`} title="更多操作"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{onToggle && <DropdownMenuItem onSelect={onToggle}>{alias.enabled ? "停用" : "启用"}</DropdownMenuItem>}{onToggle && onDelete && <DropdownMenuSeparator />}{onDelete && <DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除转发</DropdownMenuItem>}</DropdownMenuContent></DropdownMenu>
|
||||||
}
|
}
|
||||||
|
|
||||||
function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user: AdminUser; permissionGroups: PermissionGroup[]; open: boolean; onOpenChange: (open: boolean) => void }) {
|
function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user: AdminUser; permissionGroups: PermissionGroup[]; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||||
@@ -2146,15 +2171,15 @@ function DNSPanel({ domain, embedded = false }: { domain?: Domain; embedded?: bo
|
|||||||
const user = me.data?.user
|
const user = me.data?.user
|
||||||
const canCheckDNS = hasPermission(user, "admin.dns.check")
|
const canCheckDNS = hasPermission(user, "admin.dns.check")
|
||||||
const { toast } = useToast(); const qc = useQueryClient(); const records = useQuery({ queryKey: ["dns-records", domain?.id], queryFn: () => api.dnsRecords(domain!.id), enabled: !!domain })
|
const { toast } = useToast(); const qc = useQueryClient(); const records = useQuery({ queryKey: ["dns-records", domain?.id], queryFn: () => api.dnsRecords(domain!.id), enabled: !!domain })
|
||||||
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(";") }) } })
|
const check = useMutation({ mutationFn: () => api.checkDns(domain!.id), onSuccess: (res) => { qc.invalidateQueries({ queryKey: ["admin", "domains"] }); toast({ title: res.status === "ok" ? "DNS 检测通过" : "DNS 检测未通过", description: Object.values(res.checks).map((c) => c.message).join(";") }) }, onError: (error) => toast({ title: "DNS 检测失败", description: error.message }) })
|
||||||
if (!domain) return <Card><CardContent className="p-6 text-muted-foreground">请选择域名</CardContent></Card>
|
if (!domain) return <Card><CardContent className="p-6 text-muted-foreground">请选择域名</CardContent></Card>
|
||||||
const content = <>
|
const content = <>
|
||||||
<p className="mb-3 text-sm text-muted-foreground">以下为需要在域名 DNS 管理中添加的记录:</p>
|
<p className="mb-3 text-sm text-muted-foreground">以下为需要在域名 DNS 管理中添加的记录:</p>
|
||||||
<div className="space-y-3">{records.data?.items.map((r) => <DNSRecordRow key={`${r.type}-${r.name}`} record={r} />)}</div>
|
{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 && <>
|
{check.data && <>
|
||||||
<Separator className="my-4" />
|
<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="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]) => <div key={k} className="flex items-center gap-2 text-sm"><CheckCircle2 className={`h-4 w-4 shrink-0 ${v.ok ? "text-green-600" : "text-destructive"}`} /><span className="font-medium">{k.toUpperCase()}:</span> {v.message}</div>)}</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 checkButton = canCheckDNS ? <Button variant="outline" size="sm" onClick={() => check.mutate()} disabled={check.isPending}><RefreshCcw className="h-4 w-4" />检测</Button> : null
|
||||||
const header = <div className="flex items-center justify-between"><CardTitle>DNS 记录</CardTitle>{checkButton}</div>
|
const header = <div className="flex items-center justify-between"><CardTitle>DNS 记录</CardTitle>{checkButton}</div>
|
||||||
@@ -2162,6 +2187,20 @@ function DNSPanel({ domain, embedded = false }: { domain?: Domain; embedded?: bo
|
|||||||
return <Card><CardHeader>{header}</CardHeader><CardContent>{content}</CardContent></Card>
|
return <Card><CardHeader>{header}</CardHeader><CardContent>{content}</CardContent></Card>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DNSCheckRow({ name, check }: { name: string; check: { ok: boolean; message: string; found?: string[] } }) {
|
||||||
|
const visibleRecords = check.found?.filter(Boolean) ?? []
|
||||||
|
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-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>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
function dnsDescription(record: DNSRecord): string {
|
function dnsDescription(record: DNSRecord): string {
|
||||||
if (record.type === "TXT" && record.name.startsWith("_dmarc")) return "声明域名的 DMARC 策略(如何处理未通过 SPF/DKIM 验证的邮件)。"
|
if (record.type === "TXT" && record.name.startsWith("_dmarc")) return "声明域名的 DMARC 策略(如何处理未通过 SPF/DKIM 验证的邮件)。"
|
||||||
if (record.type === "TXT" && record.value.includes("DKIM1")) return "DKIM 公钥。收件服务器用此密钥验证邮件是否由你发出。"
|
if (record.type === "TXT" && record.value.includes("DKIM1")) return "DKIM 公钥。收件服务器用此密钥验证邮件是否由你发出。"
|
||||||
@@ -2212,19 +2251,25 @@ function fieldNumber(form: FormData, name: string, fallback: number) {
|
|||||||
return Number.isFinite(n) && n > 0 ? n : fallback
|
return Number.isFinite(n) && n > 0 ? n : fallback
|
||||||
}
|
}
|
||||||
function SwitchRow({ label, checked, onCheckedChange, className = "" }: { label: string; checked: boolean; onCheckedChange: (checked: boolean) => void; className?: string }) {
|
function SwitchRow({ label, checked, onCheckedChange, className = "" }: { label: string; checked: boolean; onCheckedChange: (checked: boolean) => void; className?: string }) {
|
||||||
|
const id = React.useId()
|
||||||
return (
|
return (
|
||||||
<div className={`flex min-h-14 items-center justify-between gap-4 ${className}`}>
|
<div className={`flex min-h-14 items-center justify-between gap-4 ${className}`}>
|
||||||
<Label className="text-base font-medium">{label}</Label>
|
<Label htmlFor={id} className="text-base font-medium">{label}</Label>
|
||||||
<Switch checked={checked} onCheckedChange={onCheckedChange} />
|
<Switch id={id} checked={checked} onCheckedChange={onCheckedChange} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
function Field({ label, required = true, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { label: string }) { return <div className="space-y-2"><Label>{label}</Label><Input required={required} {...props} /></div> }
|
function Field({ label, required = true, id: suppliedId, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { label: string }) {
|
||||||
|
const generatedId = React.useId()
|
||||||
|
const id = suppliedId || generatedId
|
||||||
|
return <div className="space-y-2"><Label htmlFor={id}>{label}</Label><Input id={id} required={required} {...props} /></div>
|
||||||
|
}
|
||||||
function MailboxLimitField({ defaultValue }: { defaultValue: number }) {
|
function MailboxLimitField({ defaultValue }: { defaultValue: number }) {
|
||||||
|
const id = React.useId()
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>邮箱数量上限</Label>
|
<Label htmlFor={id}>邮箱数量上限</Label>
|
||||||
<Input name="mailboxLimitOverride" type="number" min={0} step={1} defaultValue={String(defaultValue)} />
|
<Input id={id} name="mailboxLimitOverride" type="number" min={0} step={1} defaultValue={String(defaultValue)} />
|
||||||
<div className="text-xs text-muted-foreground">普通用户默认 9 个,填 0 表示不限制。</div>
|
<div className="text-xs text-muted-foreground">普通用户默认 9 个,填 0 表示不限制。</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -2236,5 +2281,8 @@ function mailboxLimitFromForm(form: FormData, fallback = defaultMailboxLimitOver
|
|||||||
function effectiveMailboxLimit(user: AdminUser) {
|
function effectiveMailboxLimit(user: AdminUser) {
|
||||||
return user.mailboxLimitOverride ?? user.limits?.maxMailboxCount ?? defaultMailboxLimitOverride
|
return user.mailboxLimitOverride ?? user.limits?.maxMailboxCount ?? defaultMailboxLimitOverride
|
||||||
}
|
}
|
||||||
function SelectField({ label, value, onValueChange, items, disabled = false }: { label: string; value: string; onValueChange: (value: string) => void; items: string[][]; disabled?: boolean }) { return <div className="space-y-2"><Label>{label}</Label><Select value={value} onValueChange={onValueChange} disabled={disabled}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent>{items.map(([value, label]) => <SelectItem key={value} value={value}>{label}</SelectItem>)}</SelectContent></Select></div> }
|
function SelectField({ label, value, onValueChange, items, disabled = false }: { label: string; value: string; onValueChange: (value: string) => void; items: string[][]; disabled?: boolean }) {
|
||||||
|
const id = React.useId()
|
||||||
|
return <div className="space-y-2"><Label htmlFor={id}>{label}</Label><Select value={value} onValueChange={onValueChange} disabled={disabled}><SelectTrigger id={id}><SelectValue /></SelectTrigger><SelectContent>{items.map(([value, itemLabel]) => <SelectItem key={value} value={value}>{itemLabel}</SelectItem>)}</SelectContent></Select></div>
|
||||||
|
}
|
||||||
function DomainSelect({ domains, value, onChange }: { domains: Domain[]; value: string; onChange: (value: string) => void }) { return <div className="space-y-2"><Label>域名</Label><Select value={value} onValueChange={onChange}><SelectTrigger><SelectValue placeholder="选择域名" /></SelectTrigger><SelectContent>{domains.map((d) => <SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>)}</SelectContent></Select></div> }
|
function DomainSelect({ domains, value, onChange }: { domains: Domain[]; value: string; onChange: (value: string) => void }) { return <div className="space-y-2"><Label>域名</Label><Select value={value} onValueChange={onChange}><SelectTrigger><SelectValue placeholder="选择域名" /></SelectTrigger><SelectContent>{domains.map((d) => <SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>)}</SelectContent></Select></div> }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Link, Navigate } from "react-router-dom"
|
import { Link, Navigate, useLocation } from "react-router-dom"
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
import { ArrowRight, KeyRound, LockKeyhole } from "lucide-react"
|
import { ArrowRight, KeyRound, LockKeyhole } from "lucide-react"
|
||||||
import { api } from "@/lib/api"
|
import { api } from "@/lib/api"
|
||||||
@@ -10,9 +10,12 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { useToast } from "@/hooks/use-toast"
|
import { useToast } from "@/hooks/use-toast"
|
||||||
|
import { safeReturnPath } from "@/lib/navigation"
|
||||||
|
import { AuthError, AuthLoading } from "@/components/auth-states"
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
|
const location = useLocation()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
|
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
|
||||||
@@ -33,9 +36,12 @@ export function LoginPage() {
|
|||||||
onError: (e) => toast({ title: "登录失败", description: e.message }),
|
onError: (e) => toast({ title: "登录失败", description: e.message }),
|
||||||
})
|
})
|
||||||
const turnstileRequired = !!publicSettings.data?.turnstileEnabled
|
const turnstileRequired = !!publicSettings.data?.turnstileEnabled
|
||||||
if (me.data?.user) return <Navigate to="/" replace />
|
const returnPath = safeReturnPath((location.state as { from?: unknown } | null)?.from)
|
||||||
|
if (me.data?.user) return <Navigate to={returnPath} replace />
|
||||||
|
if (publicSettings.isLoading) return <AuthLoading />
|
||||||
|
if (publicSettings.isError) return <AuthError message={publicSettings.error.message} onRetry={() => { void publicSettings.refetch() }} />
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
|
<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="w-full max-w-[420px]">
|
||||||
<div className="mb-7 text-center">
|
<div className="mb-7 text-center">
|
||||||
<h1 className="text-3xl font-semibold tracking-tight">NewSzxcn 邮箱</h1>
|
<h1 className="text-3xl font-semibold tracking-tight">NewSzxcn 邮箱</h1>
|
||||||
@@ -82,6 +88,6 @@ export function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+97
-18
@@ -155,7 +155,6 @@ export function MailPage() {
|
|||||||
const [autoRefreshing, setAutoRefreshing] = React.useState(false)
|
const [autoRefreshing, setAutoRefreshing] = React.useState(false)
|
||||||
const [exportingMail, setExportingMail] = React.useState(false)
|
const [exportingMail, setExportingMail] = React.useState(false)
|
||||||
const [importingMail, setImportingMail] = React.useState(false)
|
const [importingMail, setImportingMail] = React.useState(false)
|
||||||
const [, setLastAutoRefreshAt] = React.useState<Date | null>(null)
|
|
||||||
const [bulkPending, setBulkPending] = React.useState(false)
|
const [bulkPending, setBulkPending] = React.useState(false)
|
||||||
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||||||
const [cancelingScheduledId, setCancelingScheduledId] = React.useState("")
|
const [cancelingScheduledId, setCancelingScheduledId] = React.useState("")
|
||||||
@@ -427,7 +426,7 @@ export function MailPage() {
|
|||||||
onSettled: () => { qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["messages"] }) },
|
onSettled: () => { qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["messages"] }) },
|
||||||
})
|
})
|
||||||
const del = useMutation({ mutationFn: (id: string) => api.delete(id), onSuccess: async () => { setSelectedId(null); setPendingConfirm(null); await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["folders"] }); await qc.invalidateQueries({ queryKey: ["mailboxes"] }); await qc.invalidateQueries({ queryKey: ["mail-stats"] }); await qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "已删除" }) }, onError: (error) => toast({ title: "删除失败", description: error.message }) })
|
const del = useMutation({ mutationFn: (id: string) => api.delete(id), onSuccess: async () => { setSelectedId(null); setPendingConfirm(null); await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["folders"] }); await qc.invalidateQueries({ queryKey: ["mailboxes"] }); await qc.invalidateQueries({ queryKey: ["mail-stats"] }); await qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "已删除" }) }, onError: (error) => toast({ title: "删除失败", description: error.message }) })
|
||||||
const move = useMutation({ mutationFn: ({ id, folder }: { id: string; folder: string }) => api.move(id, folder), onSuccess: async () => { setSelectedId(null); await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["folders"] }); await qc.invalidateQueries({ queryKey: ["mailboxes"] }); await qc.invalidateQueries({ queryKey: ["mail-stats"] }); await qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "已移动" }) } })
|
const move = useMutation({ mutationFn: ({ id, folder }: { id: string; folder: string }) => api.move(id, folder), onSuccess: async () => { setSelectedId(null); await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["folders"] }); await qc.invalidateQueries({ queryKey: ["mailboxes"] }); await qc.invalidateQueries({ queryKey: ["mail-stats"] }); await qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "已移动" }) }, onError: (error) => toast({ title: "移动失败", description: error.message }) })
|
||||||
const cancelScheduledSend = useMutation({
|
const cancelScheduledSend = useMutation({
|
||||||
mutationFn: (item: ScheduledSend) => api.cancelScheduledSend(item.id),
|
mutationFn: (item: ScheduledSend) => api.cancelScheduledSend(item.id),
|
||||||
onMutate: (item) => setCancelingScheduledId(item.id),
|
onMutate: (item) => setCancelingScheduledId(item.id),
|
||||||
@@ -646,7 +645,6 @@ export function MailPage() {
|
|||||||
qc.invalidateQueries({ queryKey: ["send-queue"] }),
|
qc.invalidateQueries({ queryKey: ["send-queue"] }),
|
||||||
qc.invalidateQueries({ queryKey: ["mail-notifications"] }),
|
qc.invalidateQueries({ queryKey: ["mail-notifications"] }),
|
||||||
]).finally(() => {
|
]).finally(() => {
|
||||||
setLastAutoRefreshAt(new Date())
|
|
||||||
window.setTimeout(() => setAutoRefreshing(false), 600)
|
window.setTimeout(() => setAutoRefreshing(false), 600)
|
||||||
})
|
})
|
||||||
}, mailRefreshInterval || 30000)
|
}, mailRefreshInterval || 30000)
|
||||||
@@ -700,10 +698,12 @@ export function MailPage() {
|
|||||||
const compactAllSelected = visibleMessageIds.length > 0 && selectedCountOnPage === visibleMessageIds.length
|
const compactAllSelected = visibleMessageIds.length > 0 && selectedCountOnPage === visibleMessageIds.length
|
||||||
const compactSomeSelected = selectedCountOnPage > 0 && !compactAllSelected
|
const compactSomeSelected = selectedCountOnPage > 0 && !compactAllSelected
|
||||||
const mailMessagesLoading = mailView === "external" ? externalMessages.isLoading : mailView === "unknown" ? unknownMessages.isLoading : messages.isLoading
|
const mailMessagesLoading = mailView === "external" ? externalMessages.isLoading : mailView === "unknown" ? unknownMessages.isLoading : messages.isLoading
|
||||||
|
const mailMessagesError = mailView === "external" ? externalMessages.error : mailView === "unknown" ? unknownMessages.error : messages.error
|
||||||
const mailMessagesLoadingMore = mailView === "external" ? externalMessages.isFetchingNextPage : mailView === "unknown" ? unknownMessages.isFetchingNextPage : messages.isFetchingNextPage
|
const mailMessagesLoadingMore = mailView === "external" ? externalMessages.isFetchingNextPage : mailView === "unknown" ? unknownMessages.isFetchingNextPage : messages.isFetchingNextPage
|
||||||
const hasMoreMessages = mailView === "external" ? !!externalMessages.hasNextPage : mailView === "unknown" ? !!unknownMessages.hasNextPage : !!messages.hasNextPage
|
const hasMoreMessages = mailView === "external" ? !!externalMessages.hasNextPage : mailView === "unknown" ? !!unknownMessages.hasNextPage : !!messages.hasNextPage
|
||||||
const canLoadMore = hasMoreMessages && !mailMessagesLoadingMore
|
const canLoadMore = hasMoreMessages && !mailMessagesLoadingMore
|
||||||
const loadMoreMessages = () => mailView === "external" ? externalMessages.fetchNextPage() : mailView === "unknown" ? unknownMessages.fetchNextPage() : messages.fetchNextPage()
|
const loadMoreMessages = () => mailView === "external" ? externalMessages.fetchNextPage() : mailView === "unknown" ? unknownMessages.fetchNextPage() : messages.fetchNextPage()
|
||||||
|
const refetchMessages = () => mailView === "external" ? externalMessages.refetch() : mailView === "unknown" ? unknownMessages.refetch() : messages.refetch()
|
||||||
function toggleCompactSelectAll(checked: boolean) {
|
function toggleCompactSelectAll(checked: boolean) {
|
||||||
setCompactSelectedIds(checked ? visibleMessageIds : [])
|
setCompactSelectedIds(checked ? visibleMessageIds : [])
|
||||||
}
|
}
|
||||||
@@ -1103,7 +1103,6 @@ export function MailPage() {
|
|||||||
try {
|
try {
|
||||||
await refreshMailData()
|
await refreshMailData()
|
||||||
const refreshedAt = new Date()
|
const refreshedAt = new Date()
|
||||||
setLastAutoRefreshAt(refreshedAt)
|
|
||||||
toast({ title: "邮件已刷新", description: `更新于 ${refreshedAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })}` })
|
toast({ title: "邮件已刷新", description: `更新于 ${refreshedAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })}` })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({ title: "刷新失败", description: error instanceof Error ? error.message : "请稍后重试" })
|
toast({ title: "刷新失败", description: error instanceof Error ? error.message : "请稍后重试" })
|
||||||
@@ -1263,6 +1262,7 @@ export function MailPage() {
|
|||||||
)}
|
)}
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent className="px-1">
|
<SidebarContent className="px-1">
|
||||||
|
{!sidebarCollapsed && publicSettings.isError && <SidebarQueryFailure label="邮箱设置读取失败" onRetry={() => { void publicSettings.refetch() }} />}
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
{!sidebarCollapsed && (
|
{!sidebarCollapsed && (
|
||||||
<div className="flex items-center justify-between px-2 py-1">
|
<div className="flex items-center justify-between px-2 py-1">
|
||||||
@@ -1316,8 +1316,10 @@ export function MailPage() {
|
|||||||
)}
|
)}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
{folders.isLoading && <FolderSkeleton />}
|
{folders.isLoading && <FolderSkeleton />}
|
||||||
|
{!sidebarCollapsed && folders.isError && <SidebarQueryFailure label="文件夹读取失败" onRetry={() => { void folders.refetch() }} />}
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
{!sidebarCollapsed && externalImapEnabled && externalMailAccounts.isError && <SidebarQueryFailure label="外部邮箱读取失败" onRetry={() => { void externalMailAccounts.refetch() }} />}
|
||||||
{externalAccountItems.length > 0 && <SidebarGroup>
|
{externalAccountItems.length > 0 && <SidebarGroup>
|
||||||
{!sidebarCollapsed && <SidebarGroupLabel>外部邮箱</SidebarGroupLabel>}
|
{!sidebarCollapsed && <SidebarGroupLabel>外部邮箱</SidebarGroupLabel>}
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
@@ -1360,6 +1362,7 @@ export function MailPage() {
|
|||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
))}
|
))}
|
||||||
{!sidebarCollapsed && expanded && externalFolders.isLoading && <FolderSkeleton />}
|
{!sidebarCollapsed && expanded && externalFolders.isLoading && <FolderSkeleton />}
|
||||||
|
{!sidebarCollapsed && expanded && account.id === selectedExternalAccountId && externalFolders.isError && <SidebarQueryFailure label="远端文件夹读取失败" onRetry={() => { void externalFolders.refetch() }} />}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)})}
|
)})}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
@@ -1370,7 +1373,7 @@ export function MailPage() {
|
|||||||
<div className="flex items-center justify-between px-2 py-1">
|
<div className="flex items-center justify-between px-2 py-1">
|
||||||
<SidebarGroupLabel className="m-0 h-auto gap-1 p-0 text-xs font-semibold text-muted-foreground"><ChevronDown className="h-3 w-3" />文件夹</SidebarGroupLabel>
|
<SidebarGroupLabel className="m-0 h-auto gap-1 p-0 text-xs font-semibold text-muted-foreground"><ChevronDown className="h-3 w-3" />文件夹</SidebarGroupLabel>
|
||||||
{canManageFolders && (
|
{canManageFolders && (
|
||||||
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={() => setFolderDialogOpen(true)}>
|
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" aria-label="新建文件夹" title="新建文件夹" onClick={() => setFolderDialogOpen(true)}>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -1430,11 +1433,11 @@ export function MailPage() {
|
|||||||
<SidebarGroupLabel className="m-0 h-auto gap-1 p-0 text-xs font-semibold text-muted-foreground"><ChevronDown className="h-3 w-3" />标签</SidebarGroupLabel>
|
<SidebarGroupLabel className="m-0 h-auto gap-1 p-0 text-xs font-semibold text-muted-foreground"><ChevronDown className="h-3 w-3" />标签</SidebarGroupLabel>
|
||||||
{canManageCurrentMailboxLabels && (
|
{canManageCurrentMailboxLabels && (
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={() => { setNewLabelEditing(true); setLabelEditMode(true) }}>
|
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" aria-label="新建标签" title="新建标签" onClick={() => { setNewLabelEditing(true); setLabelEditMode(true) }}>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
{labelEditMode && (
|
{labelEditMode && (
|
||||||
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={() => setLabelEditMode((v) => !v)}>
|
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" aria-label="完成标签编辑" title="完成编辑" onClick={() => setLabelEditMode((v) => !v)}>
|
||||||
<Check className="h-3 w-3" />
|
<Check className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -1479,7 +1482,7 @@ export function MailPage() {
|
|||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{canReadMail && !sidebarCollapsed && !labels.isLoading && labelItems.length === 0 && <div className="px-2 py-1 text-xs text-muted-foreground">暂无标签</div>}
|
{canReadMail && !sidebarCollapsed && !labels.isLoading && !labels.isError && labelItems.length === 0 && <div className="px-2 py-1 text-xs text-muted-foreground">暂无标签</div>}
|
||||||
{canManageCurrentMailboxLabels && labelEditMode && newLabelEditing && (
|
{canManageCurrentMailboxLabels && labelEditMode && newLabelEditing && (
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<NewLabelButton collapsed={sidebarCollapsed} pending={createLabel.isPending} onCreate={(name) => { createLabel.mutate(name); setNewLabelEditing(false) }} editing={newLabelEditing} onEditingChange={setNewLabelEditing} />
|
<NewLabelButton collapsed={sidebarCollapsed} pending={createLabel.isPending} onCreate={(name) => { createLabel.mutate(name); setNewLabelEditing(false) }} editing={newLabelEditing} onEditingChange={setNewLabelEditing} />
|
||||||
@@ -1487,6 +1490,7 @@ export function MailPage() {
|
|||||||
)}
|
)}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
{labels.isLoading && <FolderSkeleton />}
|
{labels.isLoading && <FolderSkeleton />}
|
||||||
|
{!sidebarCollapsed && labels.isError && <SidebarQueryFailure label="标签读取失败" onRetry={() => { void labels.refetch() }} />}
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>}
|
</SidebarGroup>}
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
@@ -1513,8 +1517,12 @@ export function MailPage() {
|
|||||||
<PermissionEmptyState title="无邮箱前台权限" description="当前账号未开启邮箱前台访问权限。" onOpenSettings={openSettings} />
|
<PermissionEmptyState title="无邮箱前台权限" description="当前账号未开启邮箱前台访问权限。" onOpenSettings={openSettings} />
|
||||||
) : !canReadMail ? (
|
) : !canReadMail ? (
|
||||||
<PermissionEmptyState title="无邮件查看权限" description="当前账号可以访问邮箱前台,但未开启邮件查看权限。" onOpenSettings={openSettings} />
|
<PermissionEmptyState title="无邮件查看权限" description="当前账号可以访问邮箱前台,但未开启邮件查看权限。" onOpenSettings={openSettings} />
|
||||||
|
) : mailboxList.isError ? (
|
||||||
|
<MailQueryFailure error={mailboxList.error} onRetry={() => { void mailboxList.refetch() }} />
|
||||||
) : !mailboxList.isLoading && !hasMailboxes && mailView !== "unknown" ? (
|
) : !mailboxList.isLoading && !hasMailboxes && mailView !== "unknown" ? (
|
||||||
<NoMailboxState onManageMailboxes={() => navigate("/profile?tab=mailboxes")} />
|
<NoMailboxState onManageMailboxes={() => navigate("/profile?tab=mailboxes")} />
|
||||||
|
) : mailView === "scheduled" && scheduledSends.isError ? (
|
||||||
|
<MailQueryFailure error={scheduledSends.error} onRetry={() => { void scheduledSends.refetch() }} />
|
||||||
) : mailView === "scheduled" && canScheduleMail ? (
|
) : mailView === "scheduled" && canScheduleMail ? (
|
||||||
<ScheduledSendView
|
<ScheduledSendView
|
||||||
compact={compactMailLayout}
|
compact={compactMailLayout}
|
||||||
@@ -1527,6 +1535,8 @@ export function MailPage() {
|
|||||||
/>
|
/>
|
||||||
) : mailView === "scheduled" ? (
|
) : mailView === "scheduled" ? (
|
||||||
<PermissionEmptyState title="无定时发送权限" description="当前账号不能查看或管理定时发送任务。" onOpenSettings={openSettings} />
|
<PermissionEmptyState title="无定时发送权限" description="当前账号不能查看或管理定时发送任务。" onOpenSettings={openSettings} />
|
||||||
|
) : mailView === "sendQueue" && sendQueue.isError ? (
|
||||||
|
<MailQueryFailure error={sendQueue.error} onRetry={() => { void sendQueue.refetch() }} />
|
||||||
) : mailView === "sendQueue" && canViewSendQueue ? (
|
) : mailView === "sendQueue" && canViewSendQueue ? (
|
||||||
<SendQueueView
|
<SendQueueView
|
||||||
compact={compactMailLayout}
|
compact={compactMailLayout}
|
||||||
@@ -1552,6 +1562,8 @@ export function MailPage() {
|
|||||||
/>
|
/>
|
||||||
) : mailView === "sendQueue" ? (
|
) : mailView === "sendQueue" ? (
|
||||||
<PermissionEmptyState title="无发送队列权限" description="当前账号不能查看发送队列。" onOpenSettings={openSettings} />
|
<PermissionEmptyState title="无发送队列权限" description="当前账号不能查看发送队列。" onOpenSettings={openSettings} />
|
||||||
|
) : mailMessagesError ? (
|
||||||
|
<MailQueryFailure error={mailMessagesError} onRetry={() => { void refetchMessages() }} />
|
||||||
) : compactMailLayout ? (
|
) : compactMailLayout ? (
|
||||||
<CompactMailView
|
<CompactMailView
|
||||||
title={viewTitle}
|
title={viewTitle}
|
||||||
@@ -1569,6 +1581,8 @@ export function MailPage() {
|
|||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
detailLoading={detail.isLoading}
|
detailLoading={detail.isLoading}
|
||||||
|
detailError={detail.error}
|
||||||
|
onRetryDetail={() => { void detail.refetch() }}
|
||||||
labels={labelItems}
|
labels={labelItems}
|
||||||
labelPending={addLabel.isPending || removeLabel.isPending}
|
labelPending={addLabel.isPending || removeLabel.isPending}
|
||||||
onSelect={openMessage}
|
onSelect={openMessage}
|
||||||
@@ -1690,7 +1704,8 @@ export function MailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{detail.isLoading && <div className="space-y-4 p-6"><Skeleton className="h-8 w-2/3" /><Skeleton className="h-4 w-1/3" /><Separator /><Skeleton className="h-40 w-full" /></div>}
|
{detail.isLoading && <div className="space-y-4 p-6"><Skeleton className="h-8 w-2/3" /><Skeleton className="h-4 w-1/3" /><Separator /><Skeleton className="h-40 w-full" /></div>}
|
||||||
{selected && <div className="flex h-full min-h-0 flex-col">
|
{detail.isError && <MailDetailFailure error={detail.error} onRetry={() => { void detail.refetch() }} />}
|
||||||
|
{!detail.isError && selected && <div className="flex h-full min-h-0 flex-col">
|
||||||
<div className="border-b p-5">
|
<div className="border-b p-5">
|
||||||
{isTwoPaneMailViewport && (
|
{isTwoPaneMailViewport && (
|
||||||
<Button variant="ghost" size="sm" className="-ml-2 mb-3 h-8 px-2 text-[13px] font-normal" onClick={() => setSelectedId(null)}>
|
<Button variant="ghost" size="sm" className="-ml-2 mb-3 h-8 px-2 text-[13px] font-normal" onClick={() => setSelectedId(null)}>
|
||||||
@@ -1773,7 +1788,9 @@ export function MailPage() {
|
|||||||
<SendQueueAuditDialog
|
<SendQueueAuditDialog
|
||||||
open={!!sendQueueAuditId}
|
open={!!sendQueueAuditId}
|
||||||
loading={sendQueueAudit.isLoading}
|
loading={sendQueueAudit.isLoading}
|
||||||
|
error={sendQueueAudit.error}
|
||||||
events={sendQueueAudit.data?.items || []}
|
events={sendQueueAudit.data?.items || []}
|
||||||
|
onRetry={() => { void sendQueueAudit.refetch() }}
|
||||||
onOpenChange={(open) => { if (!open) setSendQueueAuditId("") }}
|
onOpenChange={(open) => { if (!open) setSendQueueAuditId("") }}
|
||||||
/>
|
/>
|
||||||
<MessageContextMenu
|
<MessageContextMenu
|
||||||
@@ -2143,6 +2160,45 @@ function PermissionEmptyState({ title, description, onOpenSettings }: { title: s
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MailQueryFailure({ error, onRetry }: { error: Error; onRetry: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-0 flex-1 place-items-center p-6" role="alert">
|
||||||
|
<div className="w-full max-w-md rounded-lg border border-destructive/40 bg-destructive/5 p-8 text-center">
|
||||||
|
<div className="text-lg font-semibold text-destructive">邮件数据读取失败</div>
|
||||||
|
<div className="mt-2 break-words text-sm text-muted-foreground">{error.message || "请检查网络连接后重试"}</div>
|
||||||
|
<Button type="button" variant="outline" className="mt-5" onClick={onRetry}>
|
||||||
|
<RefreshCcw className="h-4 w-4" />重新读取
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MailDetailFailure({ error, onRetry }: { error: Error; onRetry: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="grid h-full min-h-[240px] place-items-center p-6" role="alert">
|
||||||
|
<div className="max-w-md text-center">
|
||||||
|
<div className="font-semibold text-destructive">邮件详情读取失败</div>
|
||||||
|
<div className="mt-2 break-words text-sm text-muted-foreground">{error.message || "请检查网络连接后重试"}</div>
|
||||||
|
<Button type="button" variant="outline" size="sm" className="mt-4" onClick={onRetry}>
|
||||||
|
<RefreshCcw className="h-4 w-4" />重新读取
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarQueryFailure({ label, onRetry }: { label: string; onRetry: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="mx-2 my-1 flex min-h-8 items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-2 text-xs text-destructive" role="alert">
|
||||||
|
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||||
|
<Button type="button" variant="ghost" size="icon" className="h-6 w-6 shrink-0 text-destructive hover:text-destructive" aria-label={`重试${label}`} title="重新读取" onClick={onRetry}>
|
||||||
|
<RefreshCcw className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ScheduledSendView({ compact, items, total, loading, query, cancelingId, onCancel }: { compact: boolean; items: ScheduledSend[]; total: number; loading: boolean; query: string; cancelingId: string; onCancel: (item: ScheduledSend) => void }) {
|
function ScheduledSendView({ compact, items, total, loading, query, cancelingId, onCancel }: { compact: boolean; items: ScheduledSend[]; total: number; loading: boolean; query: string; cancelingId: string; onCancel: (item: ScheduledSend) => void }) {
|
||||||
const empty = query.trim() ? "当前搜索没有匹配的定时邮件" : "没有待发送邮件"
|
const empty = query.trim() ? "当前搜索没有匹配的定时邮件" : "没有待发送邮件"
|
||||||
return (
|
return (
|
||||||
@@ -2378,7 +2434,7 @@ function SendQueueStatusBadge({ status }: { status: SendQueueStatus }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SendQueueAuditDialog({ open, loading, events, onOpenChange }: { open: boolean; loading: boolean; events: SendQueueAuditEvent[]; onOpenChange: (open: boolean) => void }) {
|
function SendQueueAuditDialog({ open, loading, error, events, onRetry, onOpenChange }: { open: boolean; loading: boolean; error: Error | null; events: SendQueueAuditEvent[]; onRetry: () => void; onOpenChange: (open: boolean) => void }) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="w-[min(92vw,42rem)] max-w-none">
|
<DialogContent className="w-[min(92vw,42rem)] max-w-none">
|
||||||
@@ -2387,8 +2443,9 @@ function SendQueueAuditDialog({ open, loading, events, onOpenChange }: { open: b
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="max-h-[60vh] overflow-auto pr-1">
|
<div className="max-h-[60vh] overflow-auto pr-1">
|
||||||
{loading && <div className="space-y-3">{Array.from({ length: 4 }).map((_, index) => <Skeleton key={index} className="h-14 w-full" />)}</div>}
|
{loading && <div className="space-y-3">{Array.from({ length: 4 }).map((_, index) => <Skeleton key={index} className="h-14 w-full" />)}</div>}
|
||||||
{!loading && events.length === 0 && <div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">暂无投递事件</div>}
|
{!loading && error && <div className="rounded-lg border border-destructive/40 bg-destructive/5 p-6 text-center" role="alert"><div className="text-sm font-medium text-destructive">投递记录读取失败</div><div className="mt-1 break-words text-xs text-muted-foreground">{error.message}</div><Button type="button" variant="outline" size="sm" className="mt-4" onClick={onRetry}><RefreshCcw className="h-4 w-4" />重新读取</Button></div>}
|
||||||
{!loading && events.length > 0 && (
|
{!loading && !error && events.length === 0 && <div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">暂无投递事件</div>}
|
||||||
|
{!loading && !error && events.length > 0 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{events.map((event) => (
|
{events.map((event) => (
|
||||||
<div key={event.id} className="rounded-lg border p-3">
|
<div key={event.id} className="rounded-lg border p-3">
|
||||||
@@ -2709,6 +2766,7 @@ function CompactMailView({
|
|||||||
selectedId,
|
selectedId,
|
||||||
selected,
|
selected,
|
||||||
detailLoading,
|
detailLoading,
|
||||||
|
detailError,
|
||||||
labels,
|
labels,
|
||||||
labelPending,
|
labelPending,
|
||||||
onSelect,
|
onSelect,
|
||||||
@@ -2716,6 +2774,7 @@ function CompactMailView({
|
|||||||
onToggleSelected,
|
onToggleSelected,
|
||||||
scheduledDraftIds,
|
scheduledDraftIds,
|
||||||
onLoadMore,
|
onLoadMore,
|
||||||
|
onRetryDetail,
|
||||||
onCloseReader,
|
onCloseReader,
|
||||||
onStar,
|
onStar,
|
||||||
onReply,
|
onReply,
|
||||||
@@ -2750,6 +2809,7 @@ function CompactMailView({
|
|||||||
selectedId: string | null
|
selectedId: string | null
|
||||||
selected?: MailMessage
|
selected?: MailMessage
|
||||||
detailLoading: boolean
|
detailLoading: boolean
|
||||||
|
detailError: Error | null
|
||||||
labels: MailLabel[]
|
labels: MailLabel[]
|
||||||
labelPending: boolean
|
labelPending: boolean
|
||||||
onSelect: (id: string | null) => void
|
onSelect: (id: string | null) => void
|
||||||
@@ -2757,6 +2817,7 @@ function CompactMailView({
|
|||||||
onToggleSelected: (id: string, checked: boolean) => void
|
onToggleSelected: (id: string, checked: boolean) => void
|
||||||
scheduledDraftIds: Set<string>
|
scheduledDraftIds: Set<string>
|
||||||
onLoadMore: () => void
|
onLoadMore: () => void
|
||||||
|
onRetryDetail: () => void
|
||||||
onCloseReader: () => void
|
onCloseReader: () => void
|
||||||
onStar: (message: MailMessage) => void
|
onStar: (message: MailMessage) => void
|
||||||
onReply: (message: MailMessage) => void
|
onReply: (message: MailMessage) => void
|
||||||
@@ -2786,11 +2847,13 @@ function CompactMailView({
|
|||||||
<CompactMessageDetail
|
<CompactMessageDetail
|
||||||
selected={selected}
|
selected={selected}
|
||||||
loading={detailLoading}
|
loading={detailLoading}
|
||||||
|
error={detailError}
|
||||||
labels={labels}
|
labels={labels}
|
||||||
labelPending={labelPending}
|
labelPending={labelPending}
|
||||||
previousMessage={previousMessage}
|
previousMessage={previousMessage}
|
||||||
nextMessage={nextMessage}
|
nextMessage={nextMessage}
|
||||||
onBack={onCloseReader}
|
onBack={onCloseReader}
|
||||||
|
onRetry={onRetryDetail}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
onStar={onStar}
|
onStar={onStar}
|
||||||
onReply={onReply}
|
onReply={onReply}
|
||||||
@@ -2853,11 +2916,13 @@ function CompactMailView({
|
|||||||
function CompactMessageDetail({
|
function CompactMessageDetail({
|
||||||
selected,
|
selected,
|
||||||
loading,
|
loading,
|
||||||
|
error,
|
||||||
labels,
|
labels,
|
||||||
labelPending,
|
labelPending,
|
||||||
previousMessage,
|
previousMessage,
|
||||||
nextMessage,
|
nextMessage,
|
||||||
onBack,
|
onBack,
|
||||||
|
onRetry,
|
||||||
onSelect,
|
onSelect,
|
||||||
onStar,
|
onStar,
|
||||||
onReply,
|
onReply,
|
||||||
@@ -2876,11 +2941,13 @@ function CompactMessageDetail({
|
|||||||
}: {
|
}: {
|
||||||
selected?: MailMessage
|
selected?: MailMessage
|
||||||
loading: boolean
|
loading: boolean
|
||||||
|
error: Error | null
|
||||||
labels: MailLabel[]
|
labels: MailLabel[]
|
||||||
labelPending: boolean
|
labelPending: boolean
|
||||||
previousMessage?: MailMessage
|
previousMessage?: MailMessage
|
||||||
nextMessage?: MailMessage
|
nextMessage?: MailMessage
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
|
onRetry: () => void
|
||||||
onSelect: (id: string | null) => void
|
onSelect: (id: string | null) => void
|
||||||
onStar: (message: MailMessage) => void
|
onStar: (message: MailMessage) => void
|
||||||
onReply: (message: MailMessage) => void
|
onReply: (message: MailMessage) => void
|
||||||
@@ -2960,8 +3027,9 @@ function CompactMessageDetail({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{loading && <div className="space-y-4 p-8"><Skeleton className="h-8 w-2/3" /><Skeleton className="h-4 w-1/3" /><Separator /><Skeleton className="h-64 w-full" /></div>}
|
{loading && <div className="space-y-4 p-8"><Skeleton className="h-8 w-2/3" /><Skeleton className="h-4 w-1/3" /><Separator /><Skeleton className="h-64 w-full" /></div>}
|
||||||
{!loading && !selected && <div className="grid flex-1 place-items-center text-sm text-muted-foreground">邮件不存在</div>}
|
{!loading && error && <MailDetailFailure error={error} onRetry={onRetry} />}
|
||||||
{selected && (
|
{!loading && !error && !selected && <div className="grid flex-1 place-items-center text-sm text-muted-foreground">邮件不存在</div>}
|
||||||
|
{!error && selected && (
|
||||||
<ScrollArea className="min-h-0 flex-1">
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
<div className="w-full px-4 py-4 sm:px-8 sm:py-6">
|
<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="space-y-5 border-b pb-5">
|
||||||
@@ -3682,12 +3750,15 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
|
|||||||
return sent
|
return sent
|
||||||
},
|
},
|
||||||
onSuccess: async (_, payloads) => {
|
onSuccess: async (_, payloads) => {
|
||||||
|
let draftDeleteFailed = false
|
||||||
if (draftId) {
|
if (draftId) {
|
||||||
try {
|
try {
|
||||||
await api.deleteDraft(draftId)
|
await api.deleteDraft(draftId)
|
||||||
} catch {}
|
} catch {
|
||||||
|
draftDeleteFailed = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
toast({ title: payloads.length > 1 ? `已分别发送 ${payloads.length} 封邮件` : "发送成功" })
|
toast({ title: payloads.length > 1 ? `已分别发送 ${payloads.length} 封邮件` : "发送成功", description: draftDeleteFailed ? "邮件已发送,但原草稿未能自动删除" : undefined })
|
||||||
setFiles([])
|
setFiles([])
|
||||||
setDraftId("")
|
setDraftId("")
|
||||||
onSent()
|
onSent()
|
||||||
@@ -3952,6 +4023,14 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
|
|||||||
<ComposeField label="主 题">
|
<ComposeField label="主 题">
|
||||||
<Input name="subject" placeholder="输入主题" value={subjectValue} onChange={(event) => setSubjectValue(event.target.value)} className="h-10 flex-1 rounded-none border-0 px-0 shadow-none focus-visible:ring-0" />
|
<Input name="subject" placeholder="输入主题" value={subjectValue} onChange={(event) => setSubjectValue(event.target.value)} className="h-10 flex-1 rounded-none border-0 px-0 shadow-none focus-visible:ring-0" />
|
||||||
</ComposeField>
|
</ComposeField>
|
||||||
|
{defaultSignature.isError && (
|
||||||
|
<div className="flex items-center justify-between gap-3 border-b bg-destructive/5 px-4 py-2 text-xs text-destructive sm:px-6" role="alert">
|
||||||
|
<span>默认签名读取失败</span>
|
||||||
|
<Button type="button" variant="ghost" size="sm" className="h-7 text-destructive hover:text-destructive" onClick={() => { void defaultSignature.refetch() }}>
|
||||||
|
<RefreshCcw className="h-3.5 w-3.5" />重新读取
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<MailBodyComposer
|
<MailBodyComposer
|
||||||
defaultValue={composerText}
|
defaultValue={composerText}
|
||||||
defaultHtml={draft?.html}
|
defaultHtml={draft?.html}
|
||||||
@@ -4383,7 +4462,7 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
|
|||||||
<DropdownMenuContent align="start" className="w-64 p-2">
|
<DropdownMenuContent align="start" className="w-64 p-2">
|
||||||
<div className="grid grid-cols-8 gap-1">
|
<div className="grid grid-cols-8 gap-1">
|
||||||
{composerEmojiOptions.map((emoji) => (
|
{composerEmojiOptions.map((emoji) => (
|
||||||
<Button key={emoji} type="button" variant="ghost" size="icon" className="h-8 w-8 rounded-md text-lg" onClick={() => insertEmoji(emoji)}>
|
<Button key={emoji} type="button" variant="ghost" size="icon" className="h-8 w-8 rounded-md text-lg" aria-label={`插入表情 ${emoji}`} title={`插入 ${emoji}`} onClick={() => insertEmoji(emoji)}>
|
||||||
{emoji}
|
{emoji}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
@@ -4495,7 +4574,7 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
|
|||||||
<Paperclip className="h-3.5 w-3.5" />
|
<Paperclip className="h-3.5 w-3.5" />
|
||||||
<span className="max-w-48 truncate">{file.name}</span>
|
<span className="max-w-48 truncate">{file.name}</span>
|
||||||
<span className="text-muted-foreground">{formatBytes(file.size)}</span>
|
<span className="text-muted-foreground">{formatBytes(file.size)}</span>
|
||||||
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 rounded-md" onClick={() => onRemoveFile(index)}>
|
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 rounded-md" aria-label={`移除附件 ${file.name}`} title="移除附件" onClick={() => onRemoveFile(index)}>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Home, MailQuestion } from "lucide-react"
|
|||||||
|
|
||||||
export function NotFoundPage() {
|
export function NotFoundPage() {
|
||||||
return (
|
return (
|
||||||
<div className="grid min-h-screen place-items-center bg-background px-4">
|
<main className="grid min-h-screen place-items-center bg-background px-4">
|
||||||
<div className="w-full max-w-sm text-center">
|
<div className="w-full max-w-sm text-center">
|
||||||
<div className="mb-6 flex justify-center">
|
<div className="mb-6 flex justify-center">
|
||||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted">
|
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted">
|
||||||
@@ -24,6 +24,6 @@ export function NotFoundPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { useToast } from "@/hooks/use-toast"
|
|||||||
type Tab = "profile" | "mailboxes" | "contacts" | "cleanup" | "cleanupQueue" | "rules" | "blocked" | "stats" | "apiTokens"
|
type Tab = "profile" | "mailboxes" | "contacts" | "cleanup" | "cleanupQueue" | "rules" | "blocked" | "stats" | "apiTokens"
|
||||||
type AccountSettingsTab = "account" | "mail" | "clients" | "security"
|
type AccountSettingsTab = "account" | "mail" | "clients" | "security"
|
||||||
type PendingConfirm = { title: string; description?: string; confirmText: string; destructive?: boolean; onConfirm: () => void }
|
type PendingConfirm = { title: string; description?: string; confirmText: string; destructive?: boolean; onConfirm: () => void }
|
||||||
|
type RetryableQuery = { isError: boolean; error: Error | null; refetch: () => Promise<unknown> }
|
||||||
const tabs: Record<Tab, { label: string; icon: React.ReactNode }> = {
|
const tabs: Record<Tab, { label: string; icon: React.ReactNode }> = {
|
||||||
profile: { label: "账号设置", icon: <Settings className="h-4 w-4" /> },
|
profile: { label: "账号设置", icon: <Settings className="h-4 w-4" /> },
|
||||||
mailboxes: { label: "邮箱管理", icon: <Mail className="h-4 w-4" /> },
|
mailboxes: { label: "邮箱管理", icon: <Mail className="h-4 w-4" /> },
|
||||||
@@ -178,7 +179,7 @@ export function ProfilePage() {
|
|||||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["contacts"] }); toast({ title: "联系人已保存" }) },
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["contacts"] }); toast({ title: "联系人已保存" }) },
|
||||||
onError: (error) => toast({ title: "保存失败", description: error.message }),
|
onError: (error) => toast({ title: "保存失败", description: error.message }),
|
||||||
})
|
})
|
||||||
const deleteContact = useMutation({ mutationFn: api.deleteContact, onSuccess: () => { qc.invalidateQueries({ queryKey: ["contacts"] }); toast({ title: "联系人已删除" }) } })
|
const deleteContact = useMutation({ mutationFn: api.deleteContact, onSuccess: () => { qc.invalidateQueries({ queryKey: ["contacts"] }); toast({ title: "联系人已删除" }) }, onError: (error) => toast({ title: "删除失败", description: error.message }) })
|
||||||
const createSignature = useMutation({
|
const createSignature = useMutation({
|
||||||
mutationFn: (form: FormData) => api.createSignature({ mailboxId: String(form.get("mailboxId") || ""), name: String(form.get("name") || ""), content: String(form.get("content") || ""), isDefault: form.get("isDefault") === "on" }),
|
mutationFn: (form: FormData) => api.createSignature({ mailboxId: String(form.get("mailboxId") || ""), name: String(form.get("name") || ""), content: String(form.get("content") || ""), isDefault: form.get("isDefault") === "on" }),
|
||||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["signatures"] }); qc.invalidateQueries({ queryKey: ["signature"] }); toast({ title: "签名已保存" }) },
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["signatures"] }); qc.invalidateQueries({ queryKey: ["signature"] }); toast({ title: "签名已保存" }) },
|
||||||
@@ -194,7 +195,7 @@ export function ProfilePage() {
|
|||||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["signatures"] }); qc.invalidateQueries({ queryKey: ["signature"] }); toast({ title: "默认签名已更新" }) },
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["signatures"] }); qc.invalidateQueries({ queryKey: ["signature"] }); toast({ title: "默认签名已更新" }) },
|
||||||
onError: (error) => toast({ title: "设置失败", description: error.message }),
|
onError: (error) => toast({ title: "设置失败", description: error.message }),
|
||||||
})
|
})
|
||||||
const deleteSignature = useMutation({ mutationFn: api.deleteSignature, onSuccess: () => { qc.invalidateQueries({ queryKey: ["signatures"] }); qc.invalidateQueries({ queryKey: ["signature"] }); toast({ title: "签名已删除" }) } })
|
const deleteSignature = useMutation({ mutationFn: api.deleteSignature, onSuccess: () => { qc.invalidateQueries({ queryKey: ["signatures"] }); qc.invalidateQueries({ queryKey: ["signature"] }); toast({ title: "签名已删除" }) }, onError: (error) => toast({ title: "删除失败", description: error.message }) })
|
||||||
const createRule = useMutation({
|
const createRule = useMutation({
|
||||||
mutationFn: (payload: {
|
mutationFn: (payload: {
|
||||||
mailboxId: string
|
mailboxId: string
|
||||||
@@ -216,7 +217,7 @@ export function ProfilePage() {
|
|||||||
},
|
},
|
||||||
onError: (error) => toast({ title: "保存失败", description: error.message }),
|
onError: (error) => toast({ title: "保存失败", description: error.message }),
|
||||||
})
|
})
|
||||||
const deleteRule = useMutation({ mutationFn: api.deleteRule, onSuccess: () => { qc.invalidateQueries({ queryKey: ["rules"] }); toast({ title: "规则已删除" }) } })
|
const deleteRule = useMutation({ mutationFn: api.deleteRule, onSuccess: () => { qc.invalidateQueries({ queryKey: ["rules"] }); toast({ title: "规则已删除" }) }, onError: (error) => toast({ title: "删除失败", description: error.message }) })
|
||||||
const updateRule = useMutation({
|
const updateRule = useMutation({
|
||||||
mutationFn: ({ id, payload }: { id: string; payload: Partial<RuleCreatePayload> }) => api.updateRule(id, payload),
|
mutationFn: ({ id, payload }: { id: string; payload: Partial<RuleCreatePayload> }) => api.updateRule(id, payload),
|
||||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["rules"] }); setRuleDialogOpen(false); toast({ title: "收件规则已更新" }) },
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["rules"] }); setRuleDialogOpen(false); toast({ title: "收件规则已更新" }) },
|
||||||
@@ -243,7 +244,7 @@ export function ProfilePage() {
|
|||||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["blocked-senders"] }); toast({ title: "拦截规则已保存" }) },
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["blocked-senders"] }); toast({ title: "拦截规则已保存" }) },
|
||||||
onError: (error) => toast({ title: "保存失败", description: error.message }),
|
onError: (error) => toast({ title: "保存失败", description: error.message }),
|
||||||
})
|
})
|
||||||
const deleteBlocked = useMutation({ mutationFn: api.deleteBlockedSender, onSuccess: () => { qc.invalidateQueries({ queryKey: ["blocked-senders"] }); toast({ title: "拦截规则已删除" }) } })
|
const deleteBlocked = useMutation({ mutationFn: api.deleteBlockedSender, onSuccess: () => { qc.invalidateQueries({ queryKey: ["blocked-senders"] }); toast({ title: "拦截规则已删除" }) }, onError: (error) => toast({ title: "删除失败", description: error.message }) })
|
||||||
const createLabel = useMutation({
|
const createLabel = useMutation({
|
||||||
mutationFn: (form: FormData) => api.createLabel({ mailboxId: activeMailboxId, name: String(form.get("name") || ""), color: String(form.get("color") || "") }),
|
mutationFn: (form: FormData) => api.createLabel({ mailboxId: activeMailboxId, name: String(form.get("name") || ""), color: String(form.get("color") || "") }),
|
||||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "标签已创建" }) },
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "标签已创建" }) },
|
||||||
@@ -433,6 +434,15 @@ export function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
function renderTab() {
|
function renderTab() {
|
||||||
|
const failedQueries = visibleTabQueries().filter((query) => query.isError && query.error)
|
||||||
|
if (failedQueries.length > 0) {
|
||||||
|
return (
|
||||||
|
<ProfileQueryFailure
|
||||||
|
errors={failedQueries.map((query) => query.error!)}
|
||||||
|
onRetry={() => { void Promise.all(failedQueries.map((query) => query.refetch())) }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
if (tab === "profile") return (
|
if (tab === "profile") return (
|
||||||
<AccountSettingsSection
|
<AccountSettingsSection
|
||||||
activeTab={accountTab}
|
activeTab={accountTab}
|
||||||
@@ -506,6 +516,22 @@ export function ProfilePage() {
|
|||||||
if (tab === "apiTokens") return <ApiTokensSection items={apiTokens.data?.items || []} loading={apiTokens.isLoading} pending={createApiToken.isPending || updateApiToken.isPending || deleteApiToken.isPending} onCreate={(payload) => createApiToken.mutateAsync(payload)} onUpdate={(id, payload) => updateApiToken.mutate({ id, payload })} onDelete={(id) => deleteApiToken.mutate(id)} onCopy={copy} />
|
if (tab === "apiTokens") return <ApiTokensSection items={apiTokens.data?.items || []} loading={apiTokens.isLoading} pending={createApiToken.isPending || updateApiToken.isPending || deleteApiToken.isPending} onCreate={(payload) => createApiToken.mutateAsync(payload)} onUpdate={(id, payload) => updateApiToken.mutate({ id, payload })} onDelete={(id) => deleteApiToken.mutate(id)} onCopy={copy} />
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
function visibleTabQueries(): RetryableQuery[] {
|
||||||
|
if (tab === "profile") {
|
||||||
|
if (accountTab === "security") return []
|
||||||
|
if (accountTab === "account") return [mailboxes, accountStats]
|
||||||
|
if (accountTab === "mail") return [mailboxes, labels, signatures]
|
||||||
|
return [mailboxes, publicSettings]
|
||||||
|
}
|
||||||
|
if (tab === "mailboxes") return [mailboxes, mailboxApplyOptions, publicSettings, externalImapAccounts, externalRunFolders, externalSyncRuns]
|
||||||
|
if (tab === "contacts") return [contacts]
|
||||||
|
if (tab === "cleanup" || tab === "cleanupQueue") return [mailboxes, mailboxStats]
|
||||||
|
if (tab === "rules") return [mailboxes, rules, ruleForwarding, labels]
|
||||||
|
if (tab === "blocked") return [mailboxes, blocked, blockedStats]
|
||||||
|
if (tab === "stats") return [mailboxes, dashboardStats]
|
||||||
|
if (tab === "apiTokens") return [apiTokens]
|
||||||
|
return []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function contentFrameClass(tab: Tab) {
|
function contentFrameClass(tab: Tab) {
|
||||||
@@ -677,7 +703,20 @@ function SettingsCard({ title, subtitle, action, children, className, contentCla
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AccountTabSection({ user, stats, selectedMailbox, mailboxes, onOpenCleanup }: { user: AccountSettingsSectionProps["user"]; profile: AccountSettingsSectionProps["profile"]; stats?: MailStats; showStats: boolean; displayMode: DisplayMode; onDisplayModeChange: (mode: DisplayMode) => void; selectedMailbox?: Mailbox; mailboxes: Mailbox[]; onOpenCleanup: () => void }) {
|
function ProfileQueryFailure({ errors, onRetry }: { errors: Error[]; onRetry: () => void }) {
|
||||||
|
const messages = Array.from(new Set(errors.map((error) => error.message).filter(Boolean)))
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-destructive/40 bg-destructive/5 px-6 py-8 text-center" role="alert">
|
||||||
|
<div className="text-base font-semibold text-destructive">页面数据读取失败</div>
|
||||||
|
<div className="mt-2 break-words text-sm text-muted-foreground">{messages.join(";") || "请检查网络连接后重试"}</div>
|
||||||
|
<Button type="button" variant="outline" className="mt-5" onClick={onRetry}>
|
||||||
|
<RefreshCcw className="h-4 w-4" />重新读取
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountTabSection({ user, profile, stats, showStats, displayMode, onDisplayModeChange, selectedMailbox, mailboxes, onOpenCleanup }: { user: AccountSettingsSectionProps["user"]; profile: AccountSettingsSectionProps["profile"]; stats?: MailStats; showStats: boolean; displayMode: DisplayMode; onDisplayModeChange: (mode: DisplayMode) => void; selectedMailbox?: Mailbox; mailboxes: Mailbox[]; onOpenCleanup: () => void }) {
|
||||||
const accountName = user.email
|
const accountName = user.email
|
||||||
const quotaBytes = stats?.quotaBytes || (selectedMailbox?.quotaMb ? selectedMailbox.quotaMb * 1024 * 1024 : 0)
|
const quotaBytes = stats?.quotaBytes || (selectedMailbox?.quotaMb ? selectedMailbox.quotaMb * 1024 * 1024 : 0)
|
||||||
const storageBytes = stats?.storageBytes || 0
|
const storageBytes = stats?.storageBytes || 0
|
||||||
@@ -685,26 +724,35 @@ function AccountTabSection({ user, stats, selectedMailbox, mailboxes, onOpenClea
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<SettingsCard title="账号信息">
|
<SettingsCard title="账号信息">
|
||||||
<div className="space-y-5">
|
<form className="space-y-5" onSubmit={(event) => { event.preventDefault(); profile.mutate(new FormData(event.currentTarget)) }}>
|
||||||
<InfoLine label="主登录邮箱" value={accountName} />
|
<InfoLine label="主登录邮箱" value={accountName} />
|
||||||
<div className="grid gap-2 sm:grid-cols-[10rem_minmax(0,1fr)] sm:items-center">
|
<div className="grid gap-2 sm:grid-cols-[10rem_minmax(0,1fr)] sm:items-center">
|
||||||
<Label className="text-base font-normal text-muted-foreground">时区</Label>
|
<Label htmlFor="profile-display-name" className="text-base font-normal text-muted-foreground">显示名称</Label>
|
||||||
<Select defaultValue="Asia/Shanghai">
|
<div className="flex flex-col gap-2 sm:ml-auto sm:w-[320px] sm:flex-row">
|
||||||
<SelectTrigger className="h-[29px] sm:ml-auto sm:w-[236px]">
|
<Input id="profile-display-name" name="displayName" defaultValue={user.displayName} maxLength={80} required className="h-9" />
|
||||||
<SelectValue />
|
<Button type="submit" size="sm" disabled={profile.isPending}>{profile.isPending ? "保存中..." : "保存"}</Button>
|
||||||
</SelectTrigger>
|
</div>
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="Asia/Shanghai">Asia/Shanghai (UTC+8)</SelectItem>
|
|
||||||
<SelectItem value="Asia/Tokyo">Asia/Tokyo (UTC+9)</SelectItem>
|
|
||||||
<SelectItem value="Asia/Singapore">Asia/Singapore (UTC+8)</SelectItem>
|
|
||||||
<SelectItem value="UTC">UTC (UTC+0)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
<SettingsCard title="邮件列表显示">
|
||||||
|
<div className="grid grid-cols-2 gap-1 rounded-lg bg-muted p-1" role="group" aria-label="邮件列表显示模式">
|
||||||
|
{(["detailed", "compact"] as DisplayMode[]).map((mode) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
className={cn("h-9 rounded-md px-3 text-sm font-medium transition-colors", displayMode === mode ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground")}
|
||||||
|
aria-pressed={displayMode === mode}
|
||||||
|
onClick={() => onDisplayModeChange(mode)}
|
||||||
|
>
|
||||||
|
{mode === "detailed" ? "标准布局" : "紧凑布局"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
|
||||||
<SettingsCard title="存储容量" action={<Button type="button" variant="outline" size="sm" onClick={onOpenCleanup}>邮件清理</Button>}>
|
{showStats && <SettingsCard title="存储容量" action={<Button type="button" variant="outline" size="sm" onClick={onOpenCleanup}>邮件清理</Button>}>
|
||||||
<div className="mb-2 flex items-center justify-between text-sm text-muted-foreground">
|
<div className="mb-2 flex items-center justify-between text-sm text-muted-foreground">
|
||||||
<span>{quotaBytes > 0 ? `${formatBytes(storageBytes)} / ${formatBytes(quotaBytes)}` : formatBytes(storageBytes)}</span>
|
<span>{quotaBytes > 0 ? `${formatBytes(storageBytes)} / ${formatBytes(quotaBytes)}` : formatBytes(storageBytes)}</span>
|
||||||
<span>{quotaBytes > 0 ? `${quotaPct}%` : "不限"}</span>
|
<span>{quotaBytes > 0 ? `${quotaPct}%` : "不限"}</span>
|
||||||
@@ -712,7 +760,7 @@ function AccountTabSection({ user, stats, selectedMailbox, mailboxes, onOpenClea
|
|||||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||||
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${quotaBytes > 0 ? quotaPct : 12}%` }} />
|
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${quotaBytes > 0 ? quotaPct : 12}%` }} />
|
||||||
</div>
|
</div>
|
||||||
</SettingsCard>
|
</SettingsCard>}
|
||||||
|
|
||||||
<SettingsCard title="账号配额" action={<span className="pt-1 text-sm text-muted-foreground">实时按当前账号配置计算</span>}>
|
<SettingsCard title="账号配额" action={<span className="pt-1 text-sm text-muted-foreground">实时按当前账号配置计算</span>}>
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
@@ -862,8 +910,8 @@ function MailPreferencesSection({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 gap-1">
|
<div className="flex shrink-0 gap-1">
|
||||||
{!item.isDefault && <Button type="button" variant="outline" size="sm" disabled={signaturesPending} onClick={() => onSetDefaultSignature(item.id)}>设为默认</Button>}
|
{!item.isDefault && <Button type="button" variant="outline" size="sm" disabled={signaturesPending} onClick={() => onSetDefaultSignature(item.id)}>设为默认</Button>}
|
||||||
<Button type="button" variant="ghost" size="icon" className="size-8" onClick={() => setEditingSignature(item)}><PencilLine className="h-4 w-4" /></Button>
|
<Button type="button" variant="ghost" size="icon" className="size-8" aria-label={`编辑签名 ${item.name}`} title="编辑签名" onClick={() => setEditingSignature(item)}><PencilLine className="h-4 w-4" /></Button>
|
||||||
<Button type="button" variant="ghost" size="icon" className="size-8 text-destructive" onClick={() => setPendingConfirm({ title: "删除签名?", description: `签名“${item.name}”将被删除。`, confirmText: "删除签名", destructive: true, onConfirm: () => { onDeleteSignature(item.id); setPendingConfirm(null) } })}><Trash2 className="h-4 w-4" /></Button>
|
<Button type="button" variant="ghost" size="icon" className="size-8 text-destructive" aria-label={`删除签名 ${item.name}`} title="删除签名" onClick={() => setPendingConfirm({ title: "删除签名?", description: `签名“${item.name}”将被删除。`, confirmText: "删除签名", destructive: true, onConfirm: () => { onDeleteSignature(item.id); setPendingConfirm(null) } })}><Trash2 className="h-4 w-4" /></Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -923,7 +971,7 @@ function SecuritySettingsSection({ user, password, passwordFormRef, twoFactorFor
|
|||||||
<div className="flex size-10 items-center justify-center rounded-full bg-emerald-100 text-emerald-700"><ShieldCheck className="h-5 w-5" /></div>
|
<div className="flex size-10 items-center justify-center rounded-full bg-emerald-100 text-emerald-700"><ShieldCheck className="h-5 w-5" /></div>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold">{user.email}</div>
|
<div className="font-semibold">{user.email}</div>
|
||||||
<div className="text-sm text-muted-foreground">上次登录:刚刚</div>
|
<div className="text-sm text-muted-foreground">当前已登录账号</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
@@ -999,27 +1047,10 @@ function SecuritySettingsSection({ user, password, passwordFormRef, twoFactorFor
|
|||||||
)}
|
)}
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
|
||||||
<SettingsCard title="临时发信申请">
|
|
||||||
<div className="space-y-3">
|
|
||||||
{[selectedRequestRow(user.email, "已批准", "6小时"), selectedRequestRow(user.email, "已过期", "24小时")].map((item, index) => (
|
|
||||||
<div key={`${item.status}-${index}`} className="rounded-lg border px-4 py-3 text-sm">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span className="font-medium">{item.email}</span>
|
|
||||||
<Badge variant="secondary">{item.status}</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-muted-foreground">申请时长:{item.duration} · 原因:临时客户端发信测试</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SettingsCard>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedRequestRow(email: string, status: string, duration: string) {
|
|
||||||
return { email, status, duration }
|
|
||||||
}
|
|
||||||
|
|
||||||
function CleanupQueueSection({ mailbox, stats }: { mailbox?: Mailbox; stats?: MailStats }) {
|
function CleanupQueueSection({ mailbox, stats }: { mailbox?: Mailbox; stats?: MailStats }) {
|
||||||
const rows = (stats?.byFolder || []).filter((item) => item.role === "trash" || item.role === "spam").map((item) => ({ name: folderLabel(item.folder), count: item.count, bytes: item.bytes }))
|
const rows = (stats?.byFolder || []).filter((item) => item.role === "trash" || item.role === "spam").map((item) => ({ name: folderLabel(item.folder), count: item.count, bytes: item.bytes }))
|
||||||
return (
|
return (
|
||||||
@@ -1776,7 +1807,7 @@ function ForwardingTargetPicker({ emails, selected, lockedSelected = [], lockedL
|
|||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
{query && (
|
{query && (
|
||||||
<Button type="button" variant="ghost" size="icon" className="absolute right-0.5 top-0.5 size-7 text-muted-foreground" onClick={() => setQuery("")}>
|
<Button type="button" variant="ghost" size="icon" className="absolute right-0.5 top-0.5 size-7 text-muted-foreground" aria-label="清除搜索" title="清除搜索" onClick={() => setQuery("")}>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -1937,7 +1968,7 @@ function ClientSettingsSection({ mailboxes, selectedMailboxId, hostname, onSelec
|
|||||||
<div className="text-muted-foreground">用户名</div>
|
<div className="text-muted-foreground">用户名</div>
|
||||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||||
<span className="truncate text-right sm:text-left">{selected.address}</span>
|
<span className="truncate text-right sm:text-left">{selected.address}</span>
|
||||||
<Button type="button" variant="ghost" size="icon" className="size-7" onClick={() => onCopy(selected.address)}><Copy className="h-4 w-4" /></Button>
|
<Button type="button" variant="ghost" size="icon" className="size-7" aria-label="复制邮箱地址" title="复制邮箱地址" onClick={() => onCopy(selected.address)}><Copy className="h-4 w-4" /></Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground">密码</div>
|
<div className="text-muted-foreground">密码</div>
|
||||||
<div>邮箱登录密码</div>
|
<div>邮箱登录密码</div>
|
||||||
@@ -1961,7 +1992,7 @@ function ClientConfigRow({ label, value, security, onCopy }: { label: string; va
|
|||||||
<code className="truncate rounded border bg-background px-2 py-1 text-xs">{value}</code>
|
<code className="truncate rounded border bg-background px-2 py-1 text-xs">{value}</code>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<span className="text-xs font-medium text-emerald-600">{security}</span>
|
<span className="text-xs font-medium text-emerald-600">{security}</span>
|
||||||
<Button type="button" variant="ghost" size="icon" className="size-7" onClick={() => onCopy(value)}><Copy className="h-4 w-4" /></Button>
|
<Button type="button" variant="ghost" size="icon" className="size-7" aria-label={`复制${label}`} title={`复制${label}`} onClick={() => onCopy(value)}><Copy className="h-4 w-4" /></Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2128,8 +2159,8 @@ function ContactsSection({ items, loading, onCreate, onDelete, onCopy, pending }
|
|||||||
<div className="truncate text-xs text-muted-foreground">{item.email}{item.note ? ` · ${item.note}` : ""}</div>
|
<div className="truncate text-xs text-muted-foreground">{item.email}{item.note ? ` · ${item.note}` : ""}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 gap-1">
|
<div className="flex shrink-0 gap-1">
|
||||||
<Button variant="ghost" size="icon" className="size-8" onClick={() => onCopy(item.email)}><Copy className="h-4 w-4" /></Button>
|
<Button variant="ghost" size="icon" className="size-8" aria-label={`复制联系人邮箱 ${item.email}`} title="复制邮箱" onClick={() => onCopy(item.email)}><Copy className="h-4 w-4" /></Button>
|
||||||
<Button variant="ghost" size="icon" className="size-8 text-destructive" onClick={() => setPendingConfirm({ title: "删除联系人?", description: `${item.email} 将从联系人列表中移除。`, confirmText: "删除联系人", onConfirm: () => { onDelete(item.id); setPendingConfirm(null) } })}><Trash2 className="h-4 w-4" /></Button>
|
<Button variant="ghost" size="icon" className="size-8 text-destructive" aria-label={`删除联系人 ${item.email}`} title="删除联系人" onClick={() => setPendingConfirm({ title: "删除联系人?", description: `${item.email} 将从联系人列表中移除。`, confirmText: "删除联系人", onConfirm: () => { onDelete(item.id); setPendingConfirm(null) } })}><Trash2 className="h-4 w-4" /></Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -2297,8 +2328,8 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, verifiedEmails, pen
|
|||||||
<SelectContent>{conditionOperatorsForField(condition.field).map((value) => <SelectItem key={value} value={value}>{conditionOperatorLabels[value]}</SelectItem>)}</SelectContent>
|
<SelectContent>{conditionOperatorsForField(condition.field).map((value) => <SelectItem key={value} value={value}>{conditionOperatorLabels[value]}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Input type={condition.field === "date" ? "date" : "text"} value={condition.value || ""} onChange={(event) => updateCondition(index, { value: event.target.value })} placeholder={conditionPlaceholder(condition.field)} />
|
<Input type={condition.field === "date" ? "date" : "text"} value={condition.value || ""} onChange={(event) => updateCondition(index, { value: event.target.value })} placeholder={conditionPlaceholder(condition.field)} />
|
||||||
<Button type="button" variant="ghost" size="icon" className="text-muted-foreground" onClick={() => removeCondition(index)} disabled={conditions.length === 1}><X className="h-4 w-4" /></Button>
|
<Button type="button" variant="ghost" size="icon" className="text-muted-foreground" aria-label={`删除第 ${index + 1} 个条件`} title="删除条件" onClick={() => removeCondition(index)} disabled={conditions.length === 1}><X className="h-4 w-4" /></Button>
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={addCondition}><Plus className="h-4 w-4" /></Button>
|
<Button type="button" variant="ghost" size="icon" aria-label="添加条件" title="添加条件" onClick={addCondition}><Plus className="h-4 w-4" /></Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -2314,8 +2345,8 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, verifiedEmails, pen
|
|||||||
<SelectContent>{(Object.keys(ruleActionLabels) as MailRuleAction["type"][]).map((value) => <SelectItem key={value} value={value}>{ruleActionLabels[value]}</SelectItem>)}</SelectContent>
|
<SelectContent>{(Object.keys(ruleActionLabels) as MailRuleAction["type"][]).map((value) => <SelectItem key={value} value={value}>{ruleActionLabels[value]}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<RuleActionValue action={action} labels={availableLabels} verifiedEmails={verifiedEmails} onChange={(patch) => updateAction(index, patch)} />
|
<RuleActionValue action={action} labels={availableLabels} verifiedEmails={verifiedEmails} onChange={(patch) => updateAction(index, patch)} />
|
||||||
<Button type="button" variant="ghost" size="icon" className="text-muted-foreground" onClick={() => removeAction(index)} disabled={actions.length === 1}><X className="h-4 w-4" /></Button>
|
<Button type="button" variant="ghost" size="icon" className="text-muted-foreground" aria-label={`删除第 ${index + 1} 个操作`} title="删除操作" onClick={() => removeAction(index)} disabled={actions.length === 1}><X className="h-4 w-4" /></Button>
|
||||||
{action.type !== "forward" && <Button type="button" variant="ghost" size="icon" onClick={addAction}><Plus className="h-4 w-4" /></Button>}
|
{action.type !== "forward" && <Button type="button" variant="ghost" size="icon" aria-label="添加操作" title="添加操作" onClick={addAction}><Plus className="h-4 w-4" /></Button>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -2500,7 +2531,7 @@ function BlockedSection({ items, mailboxes, mailboxId, spamCount, onMailboxChang
|
|||||||
<div className="truncate text-sm font-semibold text-foreground">{item.email}</div>
|
<div className="truncate text-sm font-semibold text-foreground">{item.email}</div>
|
||||||
<div className="mt-0.5 truncate text-xs text-muted-foreground">{item.mailboxId ? mailboxes.find((m) => m.id === item.mailboxId)?.address : "全部邮箱"}{item.reason ? ` · ${item.reason}` : ""}</div>
|
<div className="mt-0.5 truncate text-xs text-muted-foreground">{item.mailboxId ? mailboxes.find((m) => m.id === item.mailboxId)?.address : "全部邮箱"}{item.reason ? ` · ${item.reason}` : ""}</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="icon" className="size-8 shrink-0 text-destructive hover:bg-destructive/10 hover:text-destructive" onClick={() => setPendingConfirm({ title: "移除拦截规则?", description: `${item.email} 之后将不再被此规则拦截。`, confirmText: "移除规则", onConfirm: () => { onDelete(item.id); setPendingConfirm(null) } })}><Trash2 className="h-4 w-4" /></Button>
|
<Button variant="ghost" size="icon" className="size-8 shrink-0 text-destructive hover:bg-destructive/10 hover:text-destructive" aria-label={`移除拦截规则 ${item.email}`} title="移除拦截规则" onClick={() => setPendingConfirm({ title: "移除拦截规则?", description: `${item.email} 之后将不再被此规则拦截。`, confirmText: "移除规则", onConfirm: () => { onDelete(item.id); setPendingConfirm(null) } })}><Trash2 className="h-4 w-4" /></Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{items.length === 0 && <EmptyState icon={<ShieldCheck />} text="没有被拦截的邮件" description="当前没有发件人拦截规则,所有邮件都会按正常规则投递。" />}
|
{items.length === 0 && <EmptyState icon={<ShieldCheck />} text="没有被拦截的邮件" description="当前没有发件人拦截规则,所有邮件都会按正常规则投递。" />}
|
||||||
@@ -2771,10 +2802,10 @@ function AccountHeader({ name, email, darkMode, onToggleTheme, onBack }: { name:
|
|||||||
<div className="min-w-0 truncate text-sm font-semibold leading-5">{displayName}</div>
|
<div className="min-w-0 truncate text-sm font-semibold leading-5">{displayName}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<Button type="button" variant="ghost" size="icon" className="size-[28px] rounded-md text-muted-foreground" onClick={onToggleTheme}>
|
<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" /> : <Moon className="h-4 w-4" />}
|
{darkMode ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="ghost" size="icon" className="size-[28px] rounded-md text-muted-foreground" onClick={onBack}>
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useToast } from "@/hooks/use-toast"
|
|||||||
import { PasswordInput } from "@/components/ui/password-input"
|
import { PasswordInput } from "@/components/ui/password-input"
|
||||||
import { TurnstileBox } from "@/components/turnstile-box"
|
import { TurnstileBox } from "@/components/turnstile-box"
|
||||||
import { validatePasswordConfirm } from "@/lib/validation"
|
import { validatePasswordConfirm } from "@/lib/validation"
|
||||||
|
import { AuthError, AuthLoading } from "@/components/auth-states"
|
||||||
|
|
||||||
export function RegisterPage() {
|
export function RegisterPage() {
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
@@ -57,8 +58,10 @@ export function RegisterPage() {
|
|||||||
})
|
})
|
||||||
const turnstileRequired = !!publicSettings.data?.turnstileEnabled
|
const turnstileRequired = !!publicSettings.data?.turnstileEnabled
|
||||||
if (me.data?.user) return <Navigate to="/" replace />
|
if (me.data?.user) return <Navigate to="/" replace />
|
||||||
|
if (publicSettings.isLoading) return <AuthLoading />
|
||||||
|
if (publicSettings.isError) return <AuthError message={publicSettings.error.message} onRetry={() => { void publicSettings.refetch() }} />
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
|
<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="w-full max-w-[420px]">
|
||||||
<div className="mb-7 text-center">
|
<div className="mb-7 text-center">
|
||||||
<h1 className="text-3xl font-semibold tracking-tight">NewSzxcn 邮箱</h1>
|
<h1 className="text-3xl font-semibold tracking-tight">NewSzxcn 邮箱</h1>
|
||||||
@@ -103,7 +106,6 @@ export function RegisterPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="displayName" className="text-sm font-medium">显示名称</Label>
|
<Label htmlFor="displayName" className="text-sm font-medium">显示名称</Label>
|
||||||
<Input id="displayName" name="displayName" autoComplete="name" required className="h-11 text-base" />
|
<Input id="displayName" name="displayName" autoComplete="name" required className="h-11 text-base" />
|
||||||
<p className="text-xs leading-5 text-muted-foreground">显示名称注册后不可自行修改,如需更换请联系管理员。</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password" className="text-sm font-medium">密码</Label>
|
<Label htmlFor="password" className="text-sm font-medium">密码</Label>
|
||||||
@@ -130,6 +132,6 @@ export function RegisterPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-13
@@ -1,19 +1,44 @@
|
|||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import react from "@vitejs/plugin-react"
|
import react from "@vitejs/plugin-react"
|
||||||
import { defineConfig } from "vite"
|
import { defineConfig, loadEnv } from "vite"
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
plugins: [react()],
|
const env = loadEnv(mode, process.cwd(), "")
|
||||||
resolve: {
|
const apiTarget = env.VITE_API_TARGET || "http://localhost:8080"
|
||||||
alias: {
|
|
||||||
"@": path.resolve(__dirname, "./src"),
|
return {
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
server: {
|
||||||
server: {
|
port: 5173,
|
||||||
port: 5173,
|
proxy: {
|
||||||
proxy: {
|
"/api": apiTarget,
|
||||||
"/api": "http://localhost:8080",
|
"/healthz": apiTarget,
|
||||||
"/healthz": "http://localhost:8080",
|
},
|
||||||
},
|
},
|
||||||
},
|
build: {
|
||||||
|
rolldownOptions: {
|
||||||
|
output: {
|
||||||
|
codeSplitting: {
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
name: "prosemirror",
|
||||||
|
test: /node_modules[\\/]prosemirror-/,
|
||||||
|
priority: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tiptap",
|
||||||
|
test: /node_modules[\\/]@tiptap/,
|
||||||
|
priority: 20,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
| NSX-20260806-007 | 2026-08-06 | 已完成 | 前端/UI;后端/通知;部署运维/CI | 邮箱下拉层越界、验证码漏识别、邮件与版本通知链接样式 | S3 | v1.2.18 | 随 v1.2.18 发布 |
|
| NSX-20260806-007 | 2026-08-06 | 已完成 | 前端/UI;后端/通知;部署运维/CI | 邮箱下拉层越界、验证码漏识别、邮件与版本通知链接样式 | S3 | v1.2.18 | 随 v1.2.18 发布 |
|
||||||
| NSX-20260807-008 | 2026-08-07 | 已完成 | 前端/UI;邮件发送;部署运维/安装 | 全部邮箱写信无法选择发件邮箱且默认项错误,写信窗口过宽;安装管理员邮箱流程需明确 | S3 | v1.2.19 | 随 v1.2.19 发布 |
|
| NSX-20260807-008 | 2026-08-07 | 已完成 | 前端/UI;邮件发送;部署运维/安装 | 全部邮箱写信无法选择发件邮箱且默认项错误,写信窗口过宽;安装管理员邮箱流程需明确 | S3 | v1.2.19 | 随 v1.2.19 发布 |
|
||||||
| NSX-20260807-009 | 2026-08-07 | 已完成 | 部署运维/安装;文档;质量复核 | 一键安装主菜单未按安装状态区分,命令前置条件和状态显示需复核 | S2 | v1.2.19 | 随 v1.2.19 发布 |
|
| NSX-20260807-009 | 2026-08-07 | 已完成 | 部署运维/安装;文档;质量复核 | 一键安装主菜单未按安装状态区分,命令前置条件和状态显示需复核 | S2 | v1.2.19 | 随 v1.2.19 发布 |
|
||||||
| NSX-20260807-010 | 2026-08-07 | 待验收 | 邮件投递;DKIM;部署运维;质量复核 | Rspamd 在域名密钥变化后继续使用旧私钥,导致外发邮件 DKIM 验证失败 | S2 | v1.2.19 | 已发布,待线上实发验收 |
|
| NSX-20260807-010 | 2026-08-07 | 已完成 | 邮件投递;DKIM;部署运维;质量复核 | Rspamd 在域名密钥变化后继续使用旧私钥,导致外发邮件 DKIM 验证失败 | S2 | v1.2.19 | 随 v1.2.19 发布,线上 DKIM 已验收通过 |
|
||||||
| NSX-20260807-011 | 2026-08-07 | 已完成 | 后端/通知;验证码识别;质量复核 | 邮件地址中的字母数字片段触发验证码候选冲突,导致 Telegram 不显示验证码与复制按钮 | S3 | v1.2.19 | 随 v1.2.19 发布 |
|
| NSX-20260807-011 | 2026-08-07 | 已完成 | 后端/通知;验证码识别;质量复核 | 邮件地址中的字母数字片段触发验证码候选冲突,导致 Telegram 不显示验证码与复制按钮 | S3 | v1.2.19 | 随 v1.2.19 发布 |
|
||||||
| NSX-20260807-012 | 2026-08-07 | 已完成 | 前端/UI;域名管理;质量复核 | DNS 记录复制按钮把类型、名称和值拼成整行,无法直接粘贴到域名服务商对应字段 | S3 | v1.2.19 | 随 v1.2.19 发布 |
|
| NSX-20260807-012 | 2026-08-07 | 已完成 | 前端/UI;域名管理;质量复核 | DNS 记录复制按钮把类型、名称和值拼成整行,无法直接粘贴到域名服务商对应字段 | S3 | v1.2.19 | 随 v1.2.19 发布 |
|
||||||
| NSX-20260807-013 | 2026-08-07 | 已完成 | 邮件核心;前端/标签;数据迁移;质量复核 | 邮箱创建后没有常用默认标签,需要手动逐个建立 | S3 | v1.2.19 | 随 v1.2.19 发布 |
|
| NSX-20260807-013 | 2026-08-07 | 已完成 | 邮件核心;前端/标签;数据迁移;质量复核 | 邮箱创建后没有常用默认标签,需要手动逐个建立 | S3 | v1.2.19 | 随 v1.2.19 发布 |
|
||||||
@@ -336,16 +336,16 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 编号 | NSX-20260807-010 |
|
| 编号 | NSX-20260807-010 |
|
||||||
| 日期 | 2026-08-07 |
|
| 日期 | 2026-08-07 |
|
||||||
| 状态 | 待验收 |
|
| 状态 | 已完成 |
|
||||||
| 模块 | 邮件投递;DKIM;部署运维;质量复核 |
|
| 模块 | 邮件投递;DKIM;部署运维;质量复核 |
|
||||||
| 现象 | Gmail 显示 SPF 和 DMARC 通过,但 NewSzxcn 发出的邮件 DKIM 验证失败;同内容的 NodeSeek 对照邮件三项认证均通过。 |
|
| 现象 | Gmail 显示 SPF 和 DMARC 通过,但 NewSzxcn 发出的邮件 DKIM 验证失败;同内容的 NodeSeek 对照邮件三项认证均通过。 |
|
||||||
| 诊断 | NewSzxcn 邮件的 relaxed 正文哈希与 Gmail 收到的 `bh` 精确一致,排除正文传输修改;邮件签名无法由当前 DNS 公钥验证,说明发信时使用了不同私钥。`key not secure` 仅表示 DNSSEC 未验证,TXT 分段也属于正常 DNS 表示。 |
|
| 诊断 | NewSzxcn 邮件的 relaxed 正文哈希与 Gmail 收到的 `bh` 精确一致,排除正文传输修改;邮件签名无法由当前 DNS 公钥验证,说明发信时使用了不同私钥。`key not secure` 仅表示 DNSSEC 未验证,TXT 分段也属于正常 DNS 表示。 |
|
||||||
| 根因 | DKIM 同步任务会覆盖容器内密钥文件,但 Rspamd 已载入的签名密钥不会随文件替换自动更新,域名重建或密钥变化后可能继续使用内存中的旧私钥。 |
|
| 根因 | DKIM 同步任务会覆盖容器内密钥文件,但 Rspamd 已载入的签名密钥不会随文件替换自动更新,域名重建或密钥变化后可能继续使用内存中的旧私钥。 |
|
||||||
| 实现 | DKIM 同步改为先比较密钥内容;相同密钥不再重复替换,密钥新增或变化后立即重新载入 Rspamd;后台 DNS 检查从“仅判断 DKIM 记录存在”升级为核对实际 `p=` 公钥,明确区分缺失与公钥不一致;新增密钥同步和 DNS 公钥匹配回归,并纳入 CI 与发布检查。 |
|
| 实现 | DKIM 同步改为先比较密钥内容;相同密钥不再重复替换,密钥新增或变化后立即重新载入 Rspamd;后台 DNS 检查从“仅判断 DKIM 记录存在”升级为核对实际 `p=` 公钥,明确区分缺失与公钥不一致;新增密钥同步和 DNS 公钥匹配回归,并纳入 CI 与发布检查。 |
|
||||||
| 兼容性 | 不轮换现有 DKIM 密钥、不修改 DNS;已有数据库、邮件和域名配置保持不变。 |
|
| 兼容性 | 不轮换现有 DKIM 密钥、不修改 DNS;已有数据库、邮件和域名配置保持不变。 |
|
||||||
| 测试结果 | DKIM 同步专项测试、DNS 公钥匹配测试、Go 全量测试与 vet、安装脚本回归均通过;线上仍需升级后重新发信,确认 Gmail 原始邮件显示 `dkim=pass`。 |
|
| 测试结果 | DKIM 同步专项测试、DNS 公钥匹配测试、Go 全量测试与 vet、安装脚本回归均通过;线上 DKIM 已验收通过。 |
|
||||||
| 目标版本 | v1.2.19 |
|
| 目标版本 | v1.2.19 |
|
||||||
| 发布状态 | 已随 v1.2.19 发布,待服务器升级后实发验收。 |
|
| 发布状态 | 已随 v1.2.19 发布,线上 DKIM 已验收通过。 |
|
||||||
|
|
||||||
### 历史
|
### 历史
|
||||||
|
|
||||||
@@ -354,6 +354,7 @@
|
|||||||
| 2026-08-07 | 用户提供 Gmail 原始邮件和 NodeSeek 对照邮件,完成正文哈希、签名公钥和认证结果比对。 |
|
| 2026-08-07 | 用户提供 Gmail 原始邮件和 NodeSeek 对照邮件,完成正文哈希、签名公钥和认证结果比对。 |
|
||||||
| 2026-08-07 | 完成 DKIM 密钥热更新修复和专项回归,等待服务器实发验收。 |
|
| 2026-08-07 | 完成 DKIM 密钥热更新修复和专项回归,等待服务器实发验收。 |
|
||||||
| 2026-08-07 | 修复已随 v1.2.19 发布;保留待验收状态,需升级后确认 Gmail 原始邮件显示 `dkim=pass`。 |
|
| 2026-08-07 | 修复已随 v1.2.19 发布;保留待验收状态,需升级后确认 Gmail 原始邮件显示 `dkim=pass`。 |
|
||||||
|
| 2026-08-08 | 用户确认 DKIM 已通过,状态流转为已完成。 |
|
||||||
|
|
||||||
## NSX-20260807-011
|
## NSX-20260807-011
|
||||||
|
|
||||||
|
|||||||
Generated
+5
-5
@@ -81,8 +81,8 @@ importers:
|
|||||||
specifier: 2.1.1
|
specifier: 2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
dompurify:
|
dompurify:
|
||||||
specifier: 3.4.12
|
specifier: 3.4.13
|
||||||
version: 3.4.12
|
version: 3.4.13
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.468.0
|
specifier: ^0.468.0
|
||||||
version: 0.468.0(react@18.3.1)
|
version: 0.468.0(react@18.3.1)
|
||||||
@@ -1022,8 +1022,8 @@ packages:
|
|||||||
dlv@1.1.3:
|
dlv@1.1.3:
|
||||||
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
|
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
|
||||||
|
|
||||||
dompurify@3.4.12:
|
dompurify@3.4.13:
|
||||||
resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==}
|
resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==}
|
||||||
|
|
||||||
electron-to-chromium@1.5.375:
|
electron-to-chromium@1.5.375:
|
||||||
resolution: {integrity: sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==}
|
resolution: {integrity: sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==}
|
||||||
@@ -2447,7 +2447,7 @@ snapshots:
|
|||||||
|
|
||||||
dlv@1.1.3: {}
|
dlv@1.1.3: {}
|
||||||
|
|
||||||
dompurify@3.4.12:
|
dompurify@3.4.13:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/trusted-types': 2.0.7
|
'@types/trusted-types': 2.0.7
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user