This commit is contained in:
LanQin
2026-06-21 01:04:17 +08:00
9 changed files with 643 additions and 408 deletions
+1
View File
@@ -57,6 +57,7 @@
"autoprefixer": "10.4.20", "autoprefixer": "10.4.20",
"postcss": "8.5.15", "postcss": "8.5.15",
"tailwindcss": "3.4.15", "tailwindcss": "3.4.15",
"tailwindcss-animate": "^1.0.7",
"typescript": "5.6.3", "typescript": "5.6.3",
"vite": "8.0.16" "vite": "8.0.16"
} }
+25 -12
View File
@@ -21,6 +21,7 @@ import {
SidebarProvider, SidebarProvider,
SidebarRail, SidebarRail,
SidebarTrigger, SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
const adminSections = [ const adminSections = [
@@ -80,16 +81,7 @@ function ProtectedContent() {
<SidebarGroup> <SidebarGroup>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{adminSections.map((item) => ( <AdminSectionItems activeSection={adminSection} />
<SidebarMenuItem key={item.key}>
<SidebarMenuButton asChild isActive={adminSection === item.key} tooltip={item.label}>
<Link to={`/admin?section=${item.key}`}>
{item.icon}
<span>{item.label}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
@@ -126,8 +118,11 @@ function ProtectedContent() {
</Sidebar> </Sidebar>
<SidebarInset> <SidebarInset>
<div className="flex min-h-svh flex-col bg-muted/20"> <div className="flex min-h-svh flex-col bg-muted/20">
<div className="flex h-12 items-center border-b bg-white px-3 md:hidden"> <div className="flex h-12 items-center gap-3 border-b bg-background px-3 md:hidden">
<SidebarTrigger /> <SidebarTrigger aria-label="打开导航" />
<div className="min-w-0 flex-1 truncate text-sm font-semibold">
{isAdminRoute ? adminSections.find((item) => item.key === adminSection)?.label || "系统管理" : "LanQin Email"}
</div>
</div> </div>
<Outlet /> <Outlet />
</div> </div>
@@ -135,3 +130,21 @@ function ProtectedContent() {
</SidebarProvider> </SidebarProvider>
) )
} }
function AdminSectionItems({ activeSection }: { activeSection: string }) {
const { isMobile, setOpenMobile } = useSidebar()
function closeMobile() {
if (isMobile) setOpenMobile(false)
}
return adminSections.map((item) => (
<SidebarMenuItem key={item.key}>
<SidebarMenuButton asChild isActive={activeSection === item.key} tooltip={item.label}>
<Link to={`/admin?section=${item.key}`} onClick={closeMobile}>
{item.icon}
<span>{item.label}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))
}
+2 -2
View File
@@ -19,7 +19,7 @@ const SheetOverlay = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay <SheetPrimitive.Overlay
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "fixed inset-0 z-50 bg-black/35 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:duration-150 data-[state=open]:duration-200 sm:bg-black/45",
className className
)} )}
{...props} {...props}
@@ -29,7 +29,7 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva( const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out", "fixed z-50 gap-4 bg-background p-6 shadow-lg will-change-transform data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-150 data-[state=open]:duration-200 data-[state=closed]:ease-in data-[state=open]:ease-out",
{ {
variants: { variants: {
side: { side: {
+2 -3
View File
@@ -11,7 +11,6 @@ import { Separator } from "@/components/ui/separator"
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
SheetDescription,
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from "@/components/ui/sheet" } from "@/components/ui/sheet"
@@ -209,10 +208,10 @@ const Sidebar = React.forwardRef<
} as React.CSSProperties } as React.CSSProperties
} }
side={side} side={side}
aria-describedby={undefined}
> >
<SheetHeader className="sr-only"> <SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle> <SheetTitle></SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader> </SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div> <div className="flex h-full w-full flex-col">{children}</div>
</SheetContent> </SheetContent>
+166 -90
View File
@@ -53,20 +53,12 @@ export function AdminPage() {
const section: Section = rawSection && sectionKeys.includes(rawSection) ? rawSection : "overview" const section: Section = rawSection && sectionKeys.includes(rawSection) ? rawSection : "overview"
return ( return (
<ScrollArea className="h-svh"> <ScrollArea className="h-[calc(100svh-3rem)] md:h-svh">
<main className="p-6"> <main className="p-4 sm:p-6">
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <div className="mb-6 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<h1 className="text-2xl font-semibold tracking-tight">{sectionLabels[section]}</h1> <h1 className="text-2xl font-semibold tracking-tight">{sectionLabels[section]}</h1>
</div> </div>
<div className="mb-6 flex flex-wrap gap-2 lg:hidden">
{sectionKeys.map((key) => (
<Button key={key} variant={section === key ? "default" : "outline"} size="sm" onClick={() => setParams(key === "overview" ? {} : { section: key })}>
{sectionLabels[key]}
</Button>
))}
</div>
{section === "overview" && ( {section === "overview" && (
<div className="mb-6 grid gap-4 md:grid-cols-4"> <div className="mb-6 grid gap-4 md:grid-cols-4">
<Stat icon={<Users />} label="用户" value={overview.data?.users || 0} /> <Stat icon={<Users />} label="用户" value={overview.data?.users || 0} />
@@ -203,24 +195,45 @@ function UsersSection({ users }: { users: AdminUser[] }) {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<Table> <div className="space-y-3 md:hidden">
<TableHeader><TableRow><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead className="w-16"></TableHead></TableRow></TableHeader> {filteredUsers.map((user) => (
<TableBody> <div key={user.id} className="rounded-lg border p-4">
{filteredUsers.map((user) => ( <div className="flex items-start justify-between gap-3">
<TableRow key={user.id}> <div className="min-w-0">
<TableCell> <div className="truncate font-medium">{user.displayName}</div>
<div className="font-medium">{user.displayName}</div> <div className="truncate text-xs text-muted-foreground">{user.email}</div>
<div className="text-xs text-muted-foreground">{user.email}</div> </div>
</TableCell> <UserActions user={user} onDelete={() => setPendingConfirm({ title: "删除用户?", description: `将删除 ${user.email} 及其关联数据。`, confirmText: "删除用户", onConfirm: () => remove.mutate(user.id) })} />
<TableCell><Badge variant={user.role === "admin" ? "default" : "secondary"}>{user.role === "admin" ? "管理员" : "普通用户"}</Badge></TableCell> </div>
<TableCell><UserMailboxCell user={user} /></TableCell> <div className="mt-3 flex flex-wrap gap-2">
<TableCell><Badge variant={user.disabled ? "secondary" : "default"}>{user.disabled ? "停用" : "正常"}</Badge></TableCell> <Badge variant={user.role === "admin" ? "default" : "secondary"}>{user.role === "admin" ? "管理员" : "普通用户"}</Badge>
<TableCell className="text-muted-foreground">{new Date(user.createdAt).toLocaleDateString()}</TableCell> <Badge variant={user.disabled ? "secondary" : "default"}>{user.disabled ? "停用" : "正常"}</Badge>
<TableCell><UserActions user={user} onDelete={() => setPendingConfirm({ title: "删除用户?", description: `将删除 ${user.email} 及其关联数据。`, confirmText: "删除用户", onConfirm: () => remove.mutate(user.id) })} /></TableCell> <Badge variant="outline">{new Date(user.createdAt).toLocaleDateString()}</Badge>
</TableRow> </div>
))} <div className="mt-3"><UserMailboxCell user={user} /></div>
</TableBody> </div>
</Table> ))}
</div>
<div className="hidden md:block">
<Table>
<TableHeader><TableRow><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead className="w-16"></TableHead></TableRow></TableHeader>
<TableBody>
{filteredUsers.map((user) => (
<TableRow key={user.id}>
<TableCell>
<div className="font-medium">{user.displayName}</div>
<div className="text-xs text-muted-foreground">{user.email}</div>
</TableCell>
<TableCell><Badge variant={user.role === "admin" ? "default" : "secondary"}>{user.role === "admin" ? "管理员" : "普通用户"}</Badge></TableCell>
<TableCell><UserMailboxCell user={user} /></TableCell>
<TableCell><Badge variant={user.disabled ? "secondary" : "default"}>{user.disabled ? "停用" : "正常"}</Badge></TableCell>
<TableCell className="text-muted-foreground">{new Date(user.createdAt).toLocaleDateString()}</TableCell>
<TableCell><UserActions user={user} onDelete={() => setPendingConfirm({ title: "删除用户?", description: `将删除 ${user.email} 及其关联数据。`, confirmText: "删除用户", onConfirm: () => remove.mutate(user.id) })} /></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{filteredUsers.length === 0 && <Empty text="没有匹配的用户" />} {filteredUsers.length === 0 && <Empty text="没有匹配的用户" />}
</CardContent> </CardContent>
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive pending={remove.isPending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} /> <ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive pending={remove.isPending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
@@ -293,21 +306,41 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Table> <div className="space-y-3 md:hidden">
<TableHeader><TableRow><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead className="w-16"></TableHead></TableRow></TableHeader> {mailboxes.map((mailbox) => (
<TableBody> <div key={mailbox.id} className="rounded-lg border p-4">
{mailboxes.map((mailbox) => ( <div className="flex items-start justify-between gap-3">
<TableRow key={mailbox.id}> <div className="min-w-0">
<TableCell className="font-medium">{mailbox.address}</TableCell> <div className="truncate font-medium">{mailbox.address}</div>
<TableCell className="text-muted-foreground">{mailbox.userEmail || mailbox.userId}</TableCell> <div className="truncate text-xs text-muted-foreground">{mailbox.userEmail || mailbox.userId}</div>
<TableCell>{mailbox.displayName}</TableCell> </div>
<TableCell>{mailbox.quotaMb} MB</TableCell> <MailboxActions mailbox={mailbox} users={users} onDelete={() => setPendingConfirm({ title: "删除邮箱?", description: `将删除 ${mailbox.address} 和其中邮件。`, confirmText: "删除邮箱", onConfirm: () => remove.mutate(mailbox.id) })} />
<TableCell><Badge variant={mailbox.status === "active" ? "default" : "secondary"}>{mailbox.status === "active" ? "启用" : "停用"}</Badge></TableCell> </div>
<TableCell><MailboxActions mailbox={mailbox} users={users} onDelete={() => setPendingConfirm({ title: "删除邮箱?", description: `将删除 ${mailbox.address} 和其中邮件。`, confirmText: "删除邮箱", onConfirm: () => remove.mutate(mailbox.id) })} /></TableCell> <div className="mt-3 flex flex-wrap gap-2">
</TableRow> <Badge variant={mailbox.status === "active" ? "default" : "secondary"}>{mailbox.status === "active" ? "启用" : "停用"}</Badge>
))} <Badge variant="outline">{mailbox.quotaMb} MB</Badge>
</TableBody> <Badge variant="outline">{mailbox.displayName || "未命名"}</Badge>
</Table> </div>
</div>
))}
</div>
<div className="hidden md:block">
<Table>
<TableHeader><TableRow><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead className="w-16"></TableHead></TableRow></TableHeader>
<TableBody>
{mailboxes.map((mailbox) => (
<TableRow key={mailbox.id}>
<TableCell className="font-medium">{mailbox.address}</TableCell>
<TableCell className="text-muted-foreground">{mailbox.userEmail || mailbox.userId}</TableCell>
<TableCell>{mailbox.displayName}</TableCell>
<TableCell>{mailbox.quotaMb} MB</TableCell>
<TableCell><Badge variant={mailbox.status === "active" ? "default" : "secondary"}>{mailbox.status === "active" ? "启用" : "停用"}</Badge></TableCell>
<TableCell><MailboxActions mailbox={mailbox} users={users} onDelete={() => setPendingConfirm({ title: "删除邮箱?", description: `将删除 ${mailbox.address} 和其中邮件。`, confirmText: "删除邮箱", onConfirm: () => remove.mutate(mailbox.id) })} /></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{mailboxes.length === 0 && <Empty text="暂无邮箱账号" />} {mailboxes.length === 0 && <Empty text="暂无邮箱账号" />}
</CardContent> </CardContent>
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive pending={remove.isPending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} /> <ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive pending={remove.isPending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
@@ -330,20 +363,39 @@ function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domai
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Table> <div className="space-y-3 md:hidden">
<TableHeader><TableRow><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead className="w-16"></TableHead></TableRow></TableHeader> {aliases.map((alias) => (
<TableBody> <div key={alias.id} className="rounded-lg border p-4">
{aliases.map((alias) => ( <div className="flex items-start justify-between gap-3">
<TableRow key={alias.id}> <div className="min-w-0">
<TableCell className="font-medium">{alias.source}</TableCell> <div className="truncate font-medium">{alias.source}</div>
<TableCell>{alias.destination}</TableCell> <div className="truncate text-xs text-muted-foreground">{alias.destination}</div>
<TableCell className="text-muted-foreground">{domains.find((d) => d.id === alias.domainId)?.name || alias.domainId}</TableCell> </div>
<TableCell><Badge variant={alias.enabled ? "default" : "secondary"}>{alias.enabled ? "启用" : "停用"}</Badge></TableCell> <AliasActions alias={alias} onToggle={() => update.mutate({ id: alias.id, payload: { source: alias.source, destination: alias.destination, enabled: !alias.enabled } })} onDelete={() => setPendingConfirm({ title: "删除别名?", description: `${alias.source} 将不再转发到 ${alias.destination}`, confirmText: "删除别名", onConfirm: () => remove.mutate(alias.id) })} />
<TableCell><AliasActions alias={alias} onToggle={() => update.mutate({ id: alias.id, payload: { source: alias.source, destination: alias.destination, enabled: !alias.enabled } })} onDelete={() => setPendingConfirm({ title: "删除别名?", description: `${alias.source} 将不再转发到 ${alias.destination}`, confirmText: "删除别名", onConfirm: () => remove.mutate(alias.id) })} /></TableCell> </div>
</TableRow> <div className="mt-3 flex flex-wrap gap-2">
))} <Badge variant={alias.enabled ? "default" : "secondary"}>{alias.enabled ? "启用" : "停用"}</Badge>
</TableBody> <Badge variant="outline">{domains.find((d) => d.id === alias.domainId)?.name || alias.domainId}</Badge>
</Table> </div>
</div>
))}
</div>
<div className="hidden md:block">
<Table>
<TableHeader><TableRow><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead className="w-16"></TableHead></TableRow></TableHeader>
<TableBody>
{aliases.map((alias) => (
<TableRow key={alias.id}>
<TableCell className="font-medium">{alias.source}</TableCell>
<TableCell>{alias.destination}</TableCell>
<TableCell className="text-muted-foreground">{domains.find((d) => d.id === alias.domainId)?.name || alias.domainId}</TableCell>
<TableCell><Badge variant={alias.enabled ? "default" : "secondary"}>{alias.enabled ? "启用" : "停用"}</Badge></TableCell>
<TableCell><AliasActions alias={alias} onToggle={() => update.mutate({ id: alias.id, payload: { source: alias.source, destination: alias.destination, enabled: !alias.enabled } })} onDelete={() => setPendingConfirm({ title: "删除别名?", description: `${alias.source} 将不再转发到 ${alias.destination}`, confirmText: "删除别名", onConfirm: () => remove.mutate(alias.id) })} /></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{aliases.length === 0 && <Empty text="暂无别名转发" />} {aliases.length === 0 && <Empty text="暂无别名转发" />}
</CardContent> </CardContent>
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive pending={remove.isPending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} /> <ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive pending={remove.isPending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
@@ -407,38 +459,62 @@ function AdminMessagesSection({ mailboxes }: { mailboxes: MailboxType[] }) {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<Table> <div className="space-y-3 md:hidden">
<TableHeader> {items.map((message) => (
<TableRow> <div key={message.id} className="rounded-lg border p-4">
<TableHead></TableHead> <div className="flex items-start justify-between gap-3">
<TableHead></TableHead> <div className="min-w-0">
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((message) => (
<TableRow key={message.id}>
<TableCell className="max-w-[360px]">
<div className="truncate font-medium">{message.subject}</div> <div className="truncate font-medium">{message.subject}</div>
<div className="truncate text-xs text-muted-foreground">{message.snippet}</div> <div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{message.snippet}</div>
</TableCell> </div>
<TableCell> <Button variant="ghost" size="sm" onClick={() => setSelectedId(message.id)}></Button>
<div className="font-medium">{message.mailboxAddress || message.recipientAddress || "-"}</div> </div>
{message.ownerEmail && <div className="text-xs text-muted-foreground">{message.ownerEmail}</div>} <div className="mt-3 space-y-2 text-sm">
</TableCell> <div className="truncate text-muted-foreground">{message.mailboxAddress || message.recipientAddress || "-"}</div>
<TableCell className="max-w-[220px] truncate" title={adminSenderTitle(message)}>{adminSenderDisplayName(message)}</TableCell> <div className="truncate text-muted-foreground">{adminSenderDisplayName(message)}</div>
<TableCell className="max-w-[220px] truncate">{message.recipientAddress || message.to.join(", ")}</TableCell> <div className="truncate text-muted-foreground">{message.recipientAddress || message.to?.join(", ") || ""}</div>
<TableCell><Badge variant="secondary">{folderName(message.folder)}</Badge></TableCell> </div>
<TableCell className="text-muted-foreground">{formatDate(message.receivedAt)}</TableCell> <div className="mt-3 flex flex-wrap gap-2">
<TableCell><Button variant="ghost" size="sm" onClick={() => setSelectedId(message.id)}></Button></TableCell> <Badge variant="secondary">{folderName(message.folder)}</Badge>
<Badge variant="outline">{formatDate(message.receivedAt)}</Badge>
</div>
</div>
))}
</div>
<div className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-20"></TableHead>
</TableRow> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
</Table> {items.map((message) => (
<TableRow key={message.id}>
<TableCell className="max-w-[360px]">
<div className="truncate font-medium">{message.subject}</div>
<div className="truncate text-xs text-muted-foreground">{message.snippet}</div>
</TableCell>
<TableCell>
<div className="font-medium">{message.mailboxAddress || message.recipientAddress || "-"}</div>
{message.ownerEmail && <div className="text-xs text-muted-foreground">{message.ownerEmail}</div>}
</TableCell>
<TableCell className="max-w-[220px] truncate" title={adminSenderTitle(message)}>{adminSenderDisplayName(message)}</TableCell>
<TableCell className="max-w-[220px] truncate">{message.recipientAddress || message.to?.join(", ") || ""}</TableCell>
<TableCell><Badge variant="secondary">{folderName(message.folder)}</Badge></TableCell>
<TableCell className="text-muted-foreground">{formatDate(message.receivedAt)}</TableCell>
<TableCell><Button variant="ghost" size="sm" onClick={() => setSelectedId(message.id)}></Button></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{messages.isLoading && <Empty text="加载中..." />} {messages.isLoading && <Empty text="加载中..." />}
{!messages.isLoading && items.length === 0 && <Empty text="暂无邮件" />} {!messages.isLoading && items.length === 0 && <Empty text="暂无邮件" />}
{!messages.isLoading && messages.hasNextPage && ( {!messages.isLoading && messages.hasNextPage && (
@@ -772,7 +848,7 @@ function AdminMessageDialog({ message, loading, open, onOpenChange }: { message?
<MessageMeta label="所属邮箱" value={message.mailboxAddress || message.recipientAddress || ""} /> <MessageMeta label="所属邮箱" value={message.mailboxAddress || message.recipientAddress || ""} />
<MessageMeta label="所属用户" value={message.ownerEmail || ""} /> <MessageMeta label="所属用户" value={message.ownerEmail || ""} />
<MessageMeta label="发件人" value={adminSenderTitle(message)} /> <MessageMeta label="发件人" value={adminSenderTitle(message)} />
<MessageMeta label="收件人" value={message.recipientAddress || message.to.join(", ")} /> <MessageMeta label="收件人" value={message.recipientAddress || message.to?.join(", ") || ""} />
<MessageMeta label="文件夹" value={folderName(message.folder)} /> <MessageMeta label="文件夹" value={folderName(message.folder)} />
<MessageMeta label="时间" value={formatDate(message.receivedAt)} /> <MessageMeta label="时间" value={formatDate(message.receivedAt)} />
</div> </div>
@@ -828,9 +904,9 @@ function adminSenderTitle(message: MailMessage) {
} }
function Stat({ icon, label, value }: { icon: React.ReactNode; label: string; value: React.ReactNode }) { function Stat({ icon, label, value }: { icon: React.ReactNode; label: string; value: React.ReactNode }) {
return <Card><CardContent className="flex items-center gap-4 p-5"><div className="grid h-10 w-10 place-items-center rounded-lg bg-muted text-foreground">{icon}</div><div><div className="text-2xl font-semibold tracking-tight">{value}</div><div className="text-xs text-muted-foreground">{label}</div></div></CardContent></Card> return <Card><CardContent className="flex items-center gap-3 p-4 sm:gap-4 sm:p-5"><div className="grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-muted text-foreground sm:h-10 sm:w-10">{icon}</div><div className="min-w-0"><div className="truncate text-xl font-semibold tracking-tight sm:text-2xl">{value}</div><div className="text-xs text-muted-foreground">{label}</div></div></CardContent></Card>
} }
function InfoBox({ label, value }: { label: string; value: React.ReactNode }) { return <div className="rounded-lg border p-4"><div className="text-2xl font-semibold tracking-tight">{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 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"] }) }
+357 -253
View File
@@ -22,6 +22,7 @@ import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -44,6 +45,7 @@ import {
SidebarProvider, SidebarProvider,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
import { useMe } from "@/hooks/use-me" import { useMe } from "@/hooks/use-me"
import { useIsMobile } from "@/hooks/use-mobile"
import { useToast } from "@/hooks/use-toast" import { useToast } from "@/hooks/use-toast"
const folderIcons: Record<string, React.ReactNode> = { inbox: <Inbox className="h-4 w-4" />, sent: <Send className="h-4 w-4" />, drafts: <FileText className="h-4 w-4" />, archive: <Archive className="h-4 w-4" />, spam: <Trash2 className="h-4 w-4" />, trash: <Trash2 className="h-4 w-4" /> } const folderIcons: Record<string, React.ReactNode> = { inbox: <Inbox className="h-4 w-4" />, sent: <Send className="h-4 w-4" />, drafts: <FileText className="h-4 w-4" />, archive: <Archive className="h-4 w-4" />, spam: <Trash2 className="h-4 w-4" />, trash: <Trash2 className="h-4 w-4" /> }
@@ -93,12 +95,14 @@ export function MailPage() {
const [selectedMailboxId, setSelectedMailboxId] = React.useState(() => localStorage.getItem("lanqin:selected-mailbox") || "") const [selectedMailboxId, setSelectedMailboxId] = React.useState(() => localStorage.getItem("lanqin:selected-mailbox") || "")
const [darkMode, setDarkMode] = React.useState(getInitialTheme) const [darkMode, setDarkMode] = React.useState(getInitialTheme)
const [displayMode] = useDisplayMode() const [displayMode] = useDisplayMode()
const isMobile = useIsMobile()
const [refreshing, setRefreshing] = React.useState(false) const [refreshing, setRefreshing] = React.useState(false)
const [autoRefreshing, setAutoRefreshing] = React.useState(false) const [autoRefreshing, setAutoRefreshing] = React.useState(false)
const [lastAutoRefreshAt, setLastAutoRefreshAt] = React.useState<Date | null>(null) const [lastAutoRefreshAt, 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("")
const [mobileSidebarOpen, setMobileSidebarOpen] = React.useState(false)
const sidebarPanelRef = React.useRef<ImperativePanelHandle>(null) const sidebarPanelRef = React.useRef<ImperativePanelHandle>(null)
const themeMountedRef = React.useRef(false) const themeMountedRef = React.useRef(false)
const mailNotifyStateRef = React.useRef<Record<string, MailNotificationState>>({}) const mailNotifyStateRef = React.useRef<Record<string, MailNotificationState>>({})
@@ -470,30 +474,36 @@ export function MailPage() {
setSelectedLabelId("") setSelectedLabelId("")
setSelectedId(null) setSelectedId(null)
setMailFilter("all") setMailFilter("all")
setMobileSidebarOpen(false)
} }
function openFolder(nextFolder: string) { function openFolder(nextFolder: string) {
setFolder(nextFolder) setFolder(nextFolder)
setMailView("folder") setMailView("folder")
setSelectedLabelId("") setSelectedLabelId("")
setSelectedId(null) setSelectedId(null)
setMailFilter("all")
setMobileSidebarOpen(false)
} }
function openStarred() { function openStarred() {
setMailView("starred") setMailView("starred")
setSelectedLabelId("") setSelectedLabelId("")
setSelectedId(null) setSelectedId(null)
setMailFilter("all") setMailFilter("all")
setMobileSidebarOpen(false)
} }
function openScheduled() { function openScheduled() {
setMailView("scheduled") setMailView("scheduled")
setSelectedLabelId("") setSelectedLabelId("")
setSelectedId(null) setSelectedId(null)
setMailFilter("all") setMailFilter("all")
setMobileSidebarOpen(false)
} }
function openLabel(labelId: string) { function openLabel(labelId: string) {
setSelectedLabelId(labelId) setSelectedLabelId(labelId)
setMailView("label") setMailView("label")
setSelectedId(null) setSelectedId(null)
setMailFilter("all") setMailFilter("all")
setMobileSidebarOpen(false)
} }
function openMessage(messageId: string | null) { function openMessage(messageId: string | null) {
if (!messageId) { if (!messageId) {
@@ -527,6 +537,89 @@ export function MailPage() {
function openSettings() { function openSettings() {
navigate("/profile") navigate("/profile")
} }
const sidebarContent = (
<Sidebar collapsible="none" className="h-full w-full border-r bg-sidebar">
<SidebarHeader className={cn("border-b py-3", sidebarCollapsed ? "px-2" : "px-3")}>
<AccountHeader
collapsed={sidebarCollapsed}
name={me.data?.user.displayName || selectedMailbox?.address || "LanQin"}
email={me.data?.user.email || selectedMailbox?.address}
darkMode={darkMode}
onToggleTheme={() => setDarkMode((value) => !value)}
onSettings={openSettings}
/>
<div className={cn("mt-2 flex gap-2", sidebarCollapsed && "justify-center")}>
<MailboxSwitcher
collapsed={sidebarCollapsed}
mailboxes={mailboxList.data?.items || []}
selectedMailbox={selectedMailbox}
onSelect={switchMailbox}
/>
{!sidebarCollapsed && (
<Button type="button" variant="outline" size="icon" className="h-9 w-9 shrink-0 rounded-md" onClick={copyCurrentMailbox} disabled={!selectedMailbox}>
<Copy className="h-4 w-4" />
</Button>
)}
</div>
<Button className={cn("mt-2 h-10 w-full rounded-md text-sm", sidebarCollapsed && "px-0")} size={sidebarCollapsed ? "icon" : "default"} onClick={() => openCompose()} disabled={!selectedMailbox}>
<PencilLine className="h-4 w-4" />
{!sidebarCollapsed && <span></span>}
</Button>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
{!sidebarCollapsed && <SidebarGroupLabel></SidebarGroupLabel>}
<SidebarGroupContent>
<SidebarMenu>
{mailMenuItems.map((item) => (
<SidebarMenuItem key={item.key}>
<SidebarMenuButton
isActive={item.type === "starred" ? mailView === "starred" : item.type === "scheduled" ? mailView === "scheduled" : mailView === "folder" && folder === item.folderName}
className={cn(sidebarCollapsed && "justify-center px-0")}
onClick={() => item.type === "starred" ? openStarred() : item.type === "scheduled" ? openScheduled() : openFolder(item.folderName)}
>
{item.icon}
{!sidebarCollapsed && <span>{item.label}</span>}
{!sidebarCollapsed && item.count > 0 && <Badge variant="secondary" className="ml-auto">{item.count}</Badge>}
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
{folders.isLoading && <FolderSkeleton />}
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
{!sidebarCollapsed && <SidebarGroupLabel></SidebarGroupLabel>}
<SidebarGroupContent>
<SidebarMenu>
{labelItems.map((label) => (
<SidebarMenuItem key={label.id}>
<SidebarMenuButton isActive={mailView === "label" && selectedLabelId === label.id} className={cn(sidebarCollapsed && "justify-center px-0")} onClick={() => openLabel(label.id)}>
<Tag className="h-4 w-4" style={{ color: label.color }} />
{!sidebarCollapsed && <span>{label.name}</span>}
{!sidebarCollapsed && !!label.messageCount && <Badge variant="secondary" className="ml-auto">{label.messageCount}</Badge>}
</SidebarMenuButton>
</SidebarMenuItem>
))}
{!sidebarCollapsed && !labels.isLoading && labelItems.length === 0 && <div className="px-2 py-1 text-xs text-muted-foreground"></div>}
<SidebarMenuItem>
<NewLabelButton collapsed={sidebarCollapsed} pending={createLabel.isPending} onCreate={(name) => createLabel.mutate(name)} />
</SidebarMenuItem>
</SidebarMenu>
{labels.isLoading && <FolderSkeleton />}
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
{!isMobile && (
<div className={cn("mt-auto border-t p-2", sidebarCollapsed ? "flex justify-center" : "")}>
<Button type="button" variant="ghost" size={sidebarCollapsed ? "icon" : "sm"} className={cn(!sidebarCollapsed && "w-full justify-start")} onClick={toggleSidebar}>
{sidebarCollapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
{!sidebarCollapsed && <span></span>}
</Button>
</div>
)}
</Sidebar>
)
function toggleSidebar() { function toggleSidebar() {
if (sidebarCollapsed) { if (sidebarCollapsed) {
sidebarPanelRef.current?.expand(14) sidebarPanelRef.current?.expand(14)
@@ -537,93 +630,158 @@ export function MailPage() {
} }
} }
return ( const contentView = !mailboxList.isLoading && !hasMailboxes ? (
<div className="h-svh bg-background"> <NoMailboxState onOpenSettings={openSettings} />
<SidebarProvider className="h-full min-h-0 w-full"> ) : mailView === "scheduled" ? (
<ResizablePanelGroup direction="horizontal" className="h-full min-h-0 w-full"> <ScheduledSendView
<ResizablePanel ref={sidebarPanelRef} collapsible collapsedSize={4} defaultSize={15} minSize={11} maxSize={24} onCollapse={() => setSidebarCollapsed(true)} onExpand={() => setSidebarCollapsed(false)}> compact={isMobile || displayMode === "compact"}
<Sidebar collapsible="none" className="h-full w-full border-r bg-sidebar"> items={visibleScheduledItems}
<SidebarHeader className={cn("border-b py-3", sidebarCollapsed ? "px-2" : "px-3")}> total={scheduledItems.length}
<AccountHeader loading={scheduledSends.isLoading}
collapsed={sidebarCollapsed} query={query}
name={me.data?.user.displayName || selectedMailbox?.address || "LanQin"} cancelingId={cancelingScheduledId}
email={me.data?.user.email || selectedMailbox?.address} onCancel={(item) => cancelScheduledSend.mutate(item)}
darkMode={darkMode} />
onToggleTheme={() => setDarkMode((value) => !value)} ) : isMobile || displayMode === "compact" ? (
onSettings={openSettings} <CompactMailView
/> title={viewTitle}
<div className={cn("mt-2 flex gap-2", sidebarCollapsed && "justify-center")}> icon={mailView === "label" && selectedLabel ? <Tag className="h-4 w-4" style={{ color: selectedLabel.color }} /> : undefined}
<MailboxSwitcher messages={visibleMessages}
collapsed={sidebarCollapsed} total={allMessages.length}
mailboxes={mailboxList.data?.items || []} selectedIds={compactSelectedIds}
selectedMailbox={selectedMailbox} allSelected={compactAllSelected}
onSelect={switchMailbox} someSelected={compactSomeSelected}
/> loading={messages.isLoading}
{!sidebarCollapsed && ( hasMore={hasMoreMessages}
<Button type="button" variant="outline" size="icon" className="h-9 w-9 shrink-0 rounded-md" onClick={copyCurrentMailbox} disabled={!selectedMailbox}> loadingMore={messages.isFetchingNextPage}
<Copy className="h-4 w-4" /> onLoadMore={() => messages.fetchNextPage()}
</Button> emptyMessage={emptyMessage}
)} selectedId={selectedId}
</div> selected={selected}
<Button className={cn("mt-2 h-10 w-full rounded-md text-sm", sidebarCollapsed && "px-0")} size={sidebarCollapsed ? "icon" : "default"} onClick={() => openCompose()} disabled={!selectedMailbox}> detailLoading={detail.isLoading}
<PencilLine className="h-4 w-4" /> labels={labelItems}
{!sidebarCollapsed && <span></span>} labelPending={addLabel.isPending || removeLabel.isPending}
</Button> onSelect={openMessage}
</SidebarHeader> onSelectAll={toggleCompactSelectAll}
<SidebarContent> onToggleSelected={toggleCompactSelect}
<SidebarGroup> scheduledDraftIds={scheduledDraftIds}
{!sidebarCollapsed && <SidebarGroupLabel></SidebarGroupLabel>} onCloseReader={() => setSelectedId(null)}
<SidebarGroupContent> onStar={(message) => star.mutate({ id: message.id, starred: !message.isStarred })}
<SidebarMenu> onReply={openReply}
{mailMenuItems.map((item) => ( onForward={openForward}
<SidebarMenuItem key={item.key}> onArchive={(message) => move.mutate({ id: message.id, folder: message.folder === "Archive" ? "Inbox" : "Archive" })}
<SidebarMenuButton onDelete={confirmDeleteMessage}
isActive={item.type === "starred" ? mailView === "starred" : item.type === "scheduled" ? mailView === "scheduled" : mailView === "folder" && folder === item.folderName} onToggleRead={(message) => markRead.mutate({ id: message.id, read: !message.isRead })}
className={cn(sidebarCollapsed && "justify-center px-0")} onAddLabel={(message, label) => addLabel.mutate({ id: message.id, label })}
onClick={() => item.type === "starred" ? openStarred() : item.type === "scheduled" ? openScheduled() : openFolder(item.folderName)} onRemoveLabel={(message, labelId) => removeLabel.mutate({ id: message.id, labelId })}
> bulkPending={bulkPending}
{item.icon} onBulkAction={runBulkAction}
{!sidebarCollapsed && <span>{item.label}</span>} />
{!sidebarCollapsed && item.count > 0 && <Badge variant="secondary" className="ml-auto">{item.count}</Badge>} ) : (
</SidebarMenuButton> <ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1">
</SidebarMenuItem> <ResizablePanel defaultSize={32} minSize={24} maxSize={44}>
))} <div className="flex h-full min-h-0 flex-col">
</SidebarMenu> <div className="flex h-14 shrink-0 items-center justify-between border-b px-5">
{folders.isLoading && <FolderSkeleton />} <div className="flex min-w-0 items-center gap-3">
</SidebarGroupContent> <Checkbox aria-label="选择当前页邮件" checked={compactAllSelected ? true : compactSomeSelected ? "indeterminate" : false} onCheckedChange={(value) => toggleCompactSelectAll(value === true)} />
</SidebarGroup> <div className="min-w-0">
<SidebarGroup> <div className="flex items-center gap-2 text-sm font-semibold">{mailView === "label" && selectedLabel && <Tag className="h-4 w-4" style={{ color: selectedLabel.color }} />}{viewTitle}</div>
{!sidebarCollapsed && <SidebarGroupLabel></SidebarGroupLabel>} <div className="text-xs text-muted-foreground">{selectedCountOnPage > 0 ? `已选 ${selectedCountOnPage}` : `${visibleMessages.length} / ${allMessages.length} 封邮件`}</div>
<SidebarGroupContent> </div>
<SidebarMenu> </div>
{labelItems.map((label) => ( {selectedCountOnPage > 0 && <BulkActionMenu pending={bulkPending} onAction={runBulkAction} />}
<SidebarMenuItem key={label.id}> </div>
<SidebarMenuButton isActive={mailView === "label" && selectedLabelId === label.id} className={cn(sidebarCollapsed && "justify-center px-0")} onClick={() => openLabel(label.id)}> <ScrollArea className="min-h-0 flex-1">
<Tag className="h-4 w-4" style={{ color: label.color }} /> {messages.isLoading && <MessageSkeleton />}
{!sidebarCollapsed && <span>{label.name}</span>} {visibleMessages.map((m) => <MessageRow key={m.id} message={m} active={selectedId === m.id} checked={compactSelectedIds.includes(m.id)} scheduled={scheduledDraftIds.has(m.id)} onCheckedChange={(checked) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} />)}
{!sidebarCollapsed && !!label.messageCount && <Badge variant="secondary" className="ml-auto">{label.messageCount}</Badge>} {!messages.isLoading && visibleMessages.length === 0 && <div className="p-8 text-center text-sm text-muted-foreground">{emptyMessage}</div>}
</SidebarMenuButton> {!messages.isLoading && hasMoreMessages && (
</SidebarMenuItem> <div className="border-b p-4 text-center">
))} <Button variant="outline" size="sm" disabled={!canLoadMore} onClick={() => messages.fetchNextPage()}>
{!sidebarCollapsed && !labels.isLoading && labelItems.length === 0 && <div className="px-2 py-1 text-xs text-muted-foreground"></div>} {messages.isFetchingNextPage ? "加载中..." : "加载更多"}
<SidebarMenuItem> </Button>
<NewLabelButton collapsed={sidebarCollapsed} pending={createLabel.isPending} onCreate={(name) => createLabel.mutate(name)} /> </div>
</SidebarMenuItem> )}
</SidebarMenu> </ScrollArea>
{labels.isLoading && <FolderSkeleton />} </div>
</SidebarGroupContent> </ResizablePanel>
</SidebarGroup> <ResizableHandle withHandle />
</SidebarContent>
<div className={cn("mt-auto border-t p-2", sidebarCollapsed ? "flex justify-center" : "")}> <ResizablePanel defaultSize={68} minSize={44}>
<Button type="button" variant="ghost" size={sidebarCollapsed ? "icon" : "sm"} className={cn(!sidebarCollapsed && "w-full justify-start")} onClick={toggleSidebar}> <section className="h-full min-h-0">
{sidebarCollapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />} {!selectedId && <div className="grid h-full place-items-center text-muted-foreground"></div>}
{!sidebarCollapsed && <span></span>} {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>}
</Button> {selected && <div className="flex h-full min-h-0 flex-col">
<div className="border-b p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="text-xl font-semibold">{selected.subject}</h2>
<div className="flex flex-wrap justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => openReply(selected)}><Reply className="h-4 w-4" /></Button>
<Button variant="outline" size="sm" onClick={() => openForward(selected)}><Forward className="h-4 w-4" /></Button>
{selected.folder === "Archive" ? (
<Button variant="outline" size="sm" onClick={() => move.mutate({ id: selected.id, folder: "Inbox" })}></Button>
) : (
<Button variant="outline" size="sm" onClick={() => move.mutate({ id: selected.id, folder: "Archive" })}></Button>
)}
<Button variant="destructive" size="sm" onClick={() => confirmDeleteMessage(selected)}></Button>
</div> </div>
</Sidebar> </div>
<div className="text-sm text-muted-foreground"><span className="font-medium text-foreground" title={senderTitle(selected)}>{senderDisplayName(selected)}</span> {selected.to.join(", ")} · {formatDateTime(selected.receivedAt)}</div>
<MessageLabels
messageLabels={selected.labels || []}
availableLabels={labelItems}
onAdd={(label) => addLabel.mutate({ id: selected.id, label })}
onRemove={(labelId) => removeLabel.mutate({ id: selected.id, labelId })}
pending={addLabel.isPending || removeLabel.isPending}
/>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="p-6">
<div className="mail-html prose max-w-none text-sm leading-7" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(selected.bodyHtml || `<pre>${escapeHtml(selected.bodyText || "")}</pre>`) }} />
{selected.attachments && selected.attachments.length > 0 && <div className="mt-8 rounded-lg border p-4"><div className="mb-3 font-medium"></div><div className="space-y-2">{selected.attachments.map((a) => <a className="flex items-center justify-between rounded-md border p-3 text-sm hover:bg-accent" href={`/api/mail/attachments/${a.id}`} key={a.id}><span className="flex items-center gap-2"><Paperclip className="h-4 w-4" />{a.filename}</span><span className="text-muted-foreground">{formatBytes(a.sizeBytes)}</span></a>)}</div></div>}
</div>
</ScrollArea>
</div>}
</section>
</ResizablePanel>
</ResizablePanelGroup>
)
return (
<div className="h-svh overflow-hidden bg-background">
<SidebarProvider className="h-full min-h-0 w-full">
{isMobile ? (
<div className="flex h-full min-h-0 flex-col">
{!selectedId && (
<header className="flex min-h-14 shrink-0 flex-wrap items-center gap-2 border-b px-3 py-2">
<Sheet open={mobileSidebarOpen} onOpenChange={setMobileSidebarOpen}>
<SheetTrigger asChild>
<Button size="icon" variant="ghost" aria-label="打开导航"><PanelLeftOpen className="h-4 w-4" /></Button>
</SheetTrigger>
<SheetContent side="left" className="w-[86vw] max-w-80 p-0 [&>button]:hidden" aria-describedby={undefined}>
<SheetTitle className="sr-only"></SheetTitle>
<div className="h-svh">{sidebarContent}</div>
</SheetContent>
</Sheet>
<Button size="icon" variant="ghost" onClick={refreshMail} disabled={refreshing || autoRefreshing} className={cn("transition-all", (refreshing || autoRefreshing) && "bg-primary/5 text-primary")} title={autoRefreshing ? "自动刷新中" : "刷新邮件"}>
<RefreshCcw className={cn("h-4 w-4", (refreshing || autoRefreshing) && "animate-spin")} />
</Button>
<div className="min-w-0 flex-1 text-sm font-semibold">{viewTitle}</div>
<Button type="button" size="icon" onClick={() => openCompose()} disabled={!selectedMailbox} aria-label="写邮件"><PencilLine className="h-4 w-4" /></Button>
<div className="relative basis-full">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="h-10 pl-9" />
</div>
</header>
)}
<section className="min-h-0 flex-1">{contentView}</section>
</div>
) : (
<ResizablePanelGroup direction="horizontal" className="h-full min-h-0 w-full">
<ResizablePanel ref={sidebarPanelRef} collapsible collapsedSize={4} defaultSize={15} minSize={11} maxSize={24} onCollapse={() => setSidebarCollapsed(true)} onExpand={() => setSidebarCollapsed(false)}>
{sidebarContent}
</ResizablePanel> </ResizablePanel>
<ResizableHandle withHandle /> <ResizableHandle withHandle />
<ResizablePanel defaultSize={85} minSize={60}> <ResizablePanel defaultSize={85} minSize={60}>
<section className="flex h-full min-h-0 flex-col"> <section className="flex h-full min-h-0 flex-col">
<header className="flex h-16 shrink-0 items-center justify-between gap-3 border-b px-5"> <header className="flex h-16 shrink-0 items-center justify-between gap-3 border-b px-5">
@@ -659,127 +817,11 @@ export function MailPage() {
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="pl-9" /> <Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="pl-9" />
</div> </div>
</header> </header>
{contentView}
{!mailboxList.isLoading && !hasMailboxes ? (
<NoMailboxState onOpenSettings={openSettings} />
) : mailView === "scheduled" ? (
<ScheduledSendView
compact={displayMode === "compact"}
items={visibleScheduledItems}
total={scheduledItems.length}
loading={scheduledSends.isLoading}
query={query}
cancelingId={cancelingScheduledId}
onCancel={(item) => cancelScheduledSend.mutate(item)}
/>
) : displayMode === "compact" ? (
<CompactMailView
title={viewTitle}
icon={mailView === "label" && selectedLabel ? <Tag className="h-4 w-4" style={{ color: selectedLabel.color }} /> : undefined}
messages={visibleMessages}
total={allMessages.length}
selectedIds={compactSelectedIds}
allSelected={compactAllSelected}
someSelected={compactSomeSelected}
loading={messages.isLoading}
hasMore={hasMoreMessages}
loadingMore={messages.isFetchingNextPage}
onLoadMore={() => messages.fetchNextPage()}
emptyMessage={emptyMessage}
selectedId={selectedId}
selected={selected}
detailLoading={detail.isLoading}
labels={labelItems}
labelPending={addLabel.isPending || removeLabel.isPending}
onSelect={openMessage}
onSelectAll={toggleCompactSelectAll}
onToggleSelected={toggleCompactSelect}
scheduledDraftIds={scheduledDraftIds}
onCloseReader={() => setSelectedId(null)}
onStar={(message) => star.mutate({ id: message.id, starred: !message.isStarred })}
onReply={openReply}
onForward={openForward}
onArchive={(message) => move.mutate({ id: message.id, folder: message.folder === "Archive" ? "Inbox" : "Archive" })}
onDelete={confirmDeleteMessage}
onToggleRead={(message) => markRead.mutate({ id: message.id, read: !message.isRead })}
onAddLabel={(message, label) => addLabel.mutate({ id: message.id, label })}
onRemoveLabel={(message, labelId) => removeLabel.mutate({ id: message.id, labelId })}
bulkPending={bulkPending}
onBulkAction={runBulkAction}
/>
) : (
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1">
<ResizablePanel defaultSize={32} minSize={24} maxSize={44}>
<div className="flex h-full min-h-0 flex-col">
<div className="flex h-14 shrink-0 items-center justify-between border-b px-5">
<div className="flex min-w-0 items-center gap-3">
<Checkbox aria-label="选择当前页邮件" checked={compactAllSelected ? true : compactSomeSelected ? "indeterminate" : false} onCheckedChange={(value) => toggleCompactSelectAll(value === true)} />
<div className="min-w-0">
<div className="flex items-center gap-2 text-sm font-semibold">{mailView === "label" && selectedLabel && <Tag className="h-4 w-4" style={{ color: selectedLabel.color }} />}{viewTitle}</div>
<div className="text-xs text-muted-foreground">{selectedCountOnPage > 0 ? `已选 ${selectedCountOnPage}` : `${visibleMessages.length} / ${allMessages.length} 封邮件`}</div>
</div>
</div>
{selectedCountOnPage > 0 && <BulkActionMenu pending={bulkPending} onAction={runBulkAction} />}
</div>
<ScrollArea className="min-h-0 flex-1">
{messages.isLoading && <MessageSkeleton />}
{visibleMessages.map((m) => <MessageRow key={m.id} message={m} active={selectedId === m.id} checked={compactSelectedIds.includes(m.id)} scheduled={scheduledDraftIds.has(m.id)} onCheckedChange={(checked) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} />)}
{!messages.isLoading && visibleMessages.length === 0 && <div className="p-8 text-center text-sm text-muted-foreground">{emptyMessage}</div>}
{!messages.isLoading && hasMoreMessages && (
<div className="border-b p-4 text-center">
<Button variant="outline" size="sm" disabled={!canLoadMore} onClick={() => messages.fetchNextPage()}>
{messages.isFetchingNextPage ? "加载中..." : "加载更多"}
</Button>
</div>
)}
</ScrollArea>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={68} minSize={44}>
<section className="h-full min-h-0">
{!selectedId && <div className="grid h-full place-items-center text-muted-foreground"></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">
<div className="border-b p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="text-xl font-semibold">{selected.subject}</h2>
<div className="flex flex-wrap justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => openReply(selected)}><Reply className="h-4 w-4" /></Button>
<Button variant="outline" size="sm" onClick={() => openForward(selected)}><Forward className="h-4 w-4" /></Button>
{selected.folder === "Archive" ? (
<Button variant="outline" size="sm" onClick={() => move.mutate({ id: selected.id, folder: "Inbox" })}></Button>
) : (
<Button variant="outline" size="sm" onClick={() => move.mutate({ id: selected.id, folder: "Archive" })}></Button>
)}
<Button variant="destructive" size="sm" onClick={() => confirmDeleteMessage(selected)}></Button>
</div>
</div>
<div className="text-sm text-muted-foreground"><span className="font-medium text-foreground" title={senderTitle(selected)}>{senderDisplayName(selected)}</span> {selected.to.join(", ")} · {formatDateTime(selected.receivedAt)}</div>
<MessageLabels
messageLabels={selected.labels || []}
availableLabels={labelItems}
onAdd={(label) => addLabel.mutate({ id: selected.id, label })}
onRemove={(labelId) => removeLabel.mutate({ id: selected.id, labelId })}
pending={addLabel.isPending || removeLabel.isPending}
/>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="p-6">
<div className="mail-html prose max-w-none text-sm leading-7" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(selected.bodyHtml || `<pre>${selected.bodyText || ""}</pre>`) }} />
{selected.attachments && selected.attachments.length > 0 && <div className="mt-8 rounded-lg border p-4"><div className="mb-3 font-medium"></div><div className="space-y-2">{selected.attachments.map((a) => <a className="flex items-center justify-between rounded-md border p-3 text-sm hover:bg-accent" href={`/api/mail/attachments/${a.id}`} key={a.id}><span className="flex items-center gap-2"><Paperclip className="h-4 w-4" />{a.filename}</span><span className="text-muted-foreground">{formatBytes(a.sizeBytes)}</span></a>)}</div></div>}
</div>
</ScrollArea>
</div>}
</section>
</ResizablePanel>
</ResizablePanelGroup>
)}
</section> </section>
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup> </ResizablePanelGroup>
)}
</SidebarProvider> </SidebarProvider>
<ComposeDialog mailbox={selectedMailbox} open={composeOpen} draft={composeDraft} onOpenChange={(open) => { setComposeOpen(open); if (!open) setComposeDraft(undefined) }} onSent={() => { setComposeOpen(false); setComposeDraft(undefined); qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["mail-stats"] }); qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["scheduled-sends"] }) }} /> <ComposeDialog mailbox={selectedMailbox} open={composeOpen} draft={composeDraft} onOpenChange={(open) => { setComposeOpen(open); if (!open) setComposeDraft(undefined) }} onSent={() => { setComposeOpen(false); setComposeDraft(undefined); qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["mail-stats"] }); qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["scheduled-sends"] }) }} />
@@ -1044,15 +1086,18 @@ function CompactMailView({
return ( return (
<div className="flex min-h-0 flex-1 flex-col bg-background"> <div className="flex min-h-0 flex-1 flex-col bg-background">
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4"> <div className="flex min-h-12 shrink-0 items-center justify-between gap-2 border-b px-3 sm:px-4">
<div className="flex items-center gap-3"> <div className="flex min-w-0 flex-1 items-center gap-3">
<Checkbox aria-label="选择当前页邮件" checked={allSelected ? true : someSelected ? "indeterminate" : false} onCheckedChange={(value) => onSelectAll(value === true)} /> <Checkbox aria-label="选择当前页邮件" checked={allSelected ? true : someSelected ? "indeterminate" : false} onCheckedChange={(value) => onSelectAll(value === true)} />
<div className="flex items-center gap-2 text-base font-semibold">{icon}{title}</div> <div className="flex min-w-0 items-center gap-2 text-base font-semibold">
{icon}
<span className="truncate">{title}</span>
</div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
{selectedIds.length > 0 ? ( {selectedIds.length > 0 ? (
<> <>
<span className="text-sm text-muted-foreground"> {selectedIds.length} </span> <span className="hidden text-sm text-muted-foreground min-[380px]:inline"> {selectedIds.length} </span>
<BulkActionMenu pending={bulkPending} onAction={onBulkAction} /> <BulkActionMenu pending={bulkPending} onAction={onBulkAction} />
</> </>
) : ( ) : (
@@ -1113,7 +1158,43 @@ function CompactMessageDetail({
}) { }) {
return ( return (
<div className="flex min-h-0 flex-1 flex-col bg-background"> <div className="flex min-h-0 flex-1 flex-col bg-background">
<div className="flex h-14 shrink-0 items-center justify-between gap-3 border-b px-4"> <div className="shrink-0 border-b px-3 py-2 sm:px-4">
<div className="flex min-h-10 items-center gap-2 sm:hidden">
<Button variant="ghost" size="icon" onClick={onBack} aria-label="返回">
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="min-w-0 flex-1 truncate text-sm font-semibold">{selected?.subject || "邮件详情"}</div>
<Button variant="ghost" size="icon" disabled={!previousMessage} onClick={() => previousMessage && onSelect(previousMessage.id)} aria-label="上一封">
<ArrowLeft className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" disabled={!nextMessage} onClick={() => nextMessage && onSelect(nextMessage.id)} aria-label="下一封">
<ArrowLeft className="h-4 w-4 rotate-180" />
</Button>
{selected && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label="更多操作">
<Ellipsis className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{selected.folder === "Drafts" ? (
<DropdownMenuItem onSelect={() => onSelect(selected.id)}><PencilLine className="h-4 w-4" />稿</DropdownMenuItem>
) : (
<>
<DropdownMenuItem onSelect={() => onReply(selected)}><Reply className="h-4 w-4" /></DropdownMenuItem>
<DropdownMenuItem onSelect={() => onForward(selected)}><Forward className="h-4 w-4" /></DropdownMenuItem>
<DropdownMenuItem onSelect={() => onArchive(selected)}><Archive className="h-4 w-4" />{selected.folder === "Archive" ? "取消归档" : "归档"}</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onToggleRead(selected)}><MailCheck className="h-4 w-4" />{selected.isRead ? "标为未读" : "标为已读"}</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onStar(selected)}><Star className={cn("h-4 w-4", selected.isStarred && "fill-yellow-400 text-yellow-500")} />{selected.isStarred ? "取消星标" : "添加星标"}</DropdownMenuItem>
</>
)}
<DropdownMenuItem onSelect={() => onDelete(selected)} className="text-destructive"><Trash2 className="h-4 w-4" /></DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="hidden min-h-10 items-center justify-between gap-3 sm:flex">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={onBack}><ArrowLeft className="h-4 w-4" /></Button> <Button variant="outline" size="sm" onClick={onBack}><ArrowLeft className="h-4 w-4" /></Button>
{selected?.folder === "Drafts" ? ( {selected?.folder === "Drafts" ? (
@@ -1134,19 +1215,20 @@ function CompactMessageDetail({
<Button variant="ghost" size="sm" disabled={!nextMessage} onClick={() => nextMessage && onSelect(nextMessage.id)}></Button> <Button variant="ghost" size="sm" disabled={!nextMessage} onClick={() => nextMessage && onSelect(nextMessage.id)}></Button>
</div> </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 && !selected && <div className="grid flex-1 place-items-center text-sm text-muted-foreground"></div>}
{selected && ( {selected && (
<ScrollArea className="min-h-0 flex-1"> <ScrollArea className="min-h-0 flex-1">
<div className="w-full px-8 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">
<div className="flex items-center gap-3"> <div className="flex items-start gap-3">
<h1 className="min-w-0 flex-1 truncate text-2xl font-semibold tracking-tight">{selected.subject}</h1> <h1 className="min-w-0 flex-1 break-words text-xl font-semibold tracking-tight sm:text-2xl">{selected.subject}</h1>
<Button type="button" variant="ghost" size="icon" aria-label={selected.isStarred ? "取消星标" : "添加星标"} className="text-muted-foreground hover:text-yellow-500" onClick={() => onStar(selected)}> <Button type="button" variant="ghost" size="icon" aria-label={selected.isStarred ? "取消星标" : "添加星标"} className="text-muted-foreground hover:text-yellow-500" onClick={() => onStar(selected)}>
<Star className={cn("h-5 w-5", selected.isStarred && "fill-yellow-400 text-yellow-500")} /> <Star className={cn("h-5 w-5", selected.isStarred && "fill-yellow-400 text-yellow-500")} />
</Button> </Button>
</div> </div>
<div className="flex items-start justify-between gap-4"> <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 items-start gap-3"> <div className="flex min-w-0 items-start gap-3">
<Avatar className="size-10 rounded-full"><AvatarFallback className="bg-primary text-sm font-semibold text-primary-foreground">{accountInitial(senderDisplayName(selected), selected.from)}</AvatarFallback></Avatar> <Avatar className="size-10 rounded-full"><AvatarFallback className="bg-primary text-sm font-semibold text-primary-foreground">{accountInitial(senderDisplayName(selected), selected.from)}</AvatarFallback></Avatar>
<div className="min-w-0 text-sm"> <div className="min-w-0 text-sm">
@@ -1154,7 +1236,7 @@ function CompactMessageDetail({
<div className="truncate text-muted-foreground"> {selected.to.join(", ")}</div> <div className="truncate text-muted-foreground"> {selected.to.join(", ")}</div>
</div> </div>
</div> </div>
<div className="shrink-0 text-right text-sm text-muted-foreground">{formatDateTime(selected.receivedAt)}</div> <div className="shrink-0 text-left text-sm text-muted-foreground sm:text-right">{formatDateTime(selected.receivedAt)}</div>
</div> </div>
<MessageLabels <MessageLabels
messageLabels={selected.labels || []} messageLabels={selected.labels || []}
@@ -1164,9 +1246,9 @@ function CompactMessageDetail({
pending={labelPending} pending={labelPending}
/> />
</div> </div>
<div className="py-8"> <div className="py-6 sm:py-8">
<div className="mail-html prose max-w-none text-sm leading-7" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(selected.bodyHtml || `<pre>${selected.bodyText || ""}</pre>`) }} /> <div className="mail-html prose max-w-none text-sm leading-7" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(selected.bodyHtml || `<pre>${escapeHtml(selected.bodyText || "")}</pre>`) }} />
{selected.attachments && selected.attachments.length > 0 && <div className="mt-8 rounded-lg border p-4"><div className="mb-3 font-medium"></div><div className="space-y-2">{selected.attachments.map((a) => <a className="flex items-center justify-between rounded-md border p-3 text-sm hover:bg-accent" href={`/api/mail/attachments/${a.id}`} key={a.id}><span className="flex items-center gap-2"><Paperclip className="h-4 w-4" />{a.filename}</span><span className="text-muted-foreground">{formatBytes(a.sizeBytes)}</span></a>)}</div></div>} {selected.attachments && selected.attachments.length > 0 && <div className="mt-8 rounded-lg border p-4"><div className="mb-3 font-medium"></div><div className="space-y-2">{selected.attachments.map((a) => <a className="flex flex-col gap-1 rounded-md border p-3 text-sm hover:bg-accent sm:flex-row sm:items-center sm:justify-between" href={`/api/mail/attachments/${a.id}`} key={a.id}><span className="flex min-w-0 items-center gap-2"><Paperclip className="h-4 w-4 shrink-0" /><span className="truncate">{a.filename}</span></span><span className="text-muted-foreground">{formatBytes(a.sizeBytes)}</span></a>)}</div></div>}
</div> </div>
</div> </div>
</ScrollArea> </ScrollArea>
@@ -1177,21 +1259,40 @@ function CompactMessageDetail({
function CompactMessageRow({ message, active, checked, scheduled, onCheckedChange, onClick, onStar }: { message: MailMessage; active: boolean; checked: boolean; scheduled?: boolean; onCheckedChange: (checked: boolean) => void; onClick: () => void; onStar: () => void }) { function CompactMessageRow({ message, active, checked, scheduled, onCheckedChange, onClick, onStar }: { message: MailMessage; active: boolean; checked: boolean; scheduled?: boolean; onCheckedChange: (checked: boolean) => void; onClick: () => void; onStar: () => void }) {
const visibleLabels = (message.labels || []).slice(0, 2) const visibleLabels = (message.labels || []).slice(0, 2)
const hiddenLabelCount = Math.max((message.labels?.length || 0) - visibleLabels.length, 0)
const senderName = senderDisplayName(message) const senderName = senderDisplayName(message)
return ( return (
<div onClick={onClick} className={cn("grid cursor-pointer grid-cols-[32px_28px_minmax(140px,240px)_minmax(0,1fr)_88px_36px] items-center gap-2 border-b px-4 py-2 text-sm transition-colors hover:bg-accent/50", active && "bg-accent", !message.isRead && "font-semibold")}> <div onClick={onClick} className={cn("cursor-pointer border-b px-3 py-3 text-sm transition-colors hover:bg-accent/50 sm:grid sm:grid-cols-[32px_28px_minmax(140px,220px)_minmax(0,1fr)_104px_36px] sm:items-center sm:gap-2 sm:px-4 sm:py-2", active && "bg-accent", !message.isRead && "font-semibold")}>
<Checkbox aria-label="选择邮件" checked={checked} onCheckedChange={(value) => onCheckedChange(value === true)} onClick={(event) => event.stopPropagation()} /> <div className="flex gap-3 sm:contents">
<Mail className={cn("h-4 w-4", message.isRead ? "text-muted-foreground/70" : "fill-yellow-200 text-yellow-500")} /> <Checkbox aria-label="选择邮件" checked={checked} onCheckedChange={(value) => onCheckedChange(value === true)} onClick={(event) => event.stopPropagation()} className="mt-0.5 shrink-0 sm:mt-0" />
<div className="truncate" title={senderTitle(message)}>{senderName}</div> {message.isRead ? (
<div className="flex min-w-0 items-center gap-2"> <MailCheck className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground/70 sm:mt-0" />
<span className="truncate font-medium">{message.subject}</span> ) : (
<span className="min-w-0 truncate text-muted-foreground">{message.snippet}</span> <Mail className="mt-0.5 h-4 w-4 shrink-0 fill-yellow-200 text-yellow-500 sm:mt-0" />
{scheduled && <Badge variant="secondary" className="h-5 shrink-0 rounded-md px-1.5 text-[11px] font-normal"></Badge>} )}
{visibleLabels.map((label) => <MailLabelBadge key={label.id} label={label} compact />)} <div className="min-w-0 flex-1 sm:contents">
{message.hasAttachments && <Paperclip className="h-3 w-3 shrink-0 text-muted-foreground" />} <div className="flex min-w-0 items-center justify-between gap-2 sm:block">
<div className="min-w-0 truncate" title={senderTitle(message)}>{senderName}</div>
<div className="flex shrink-0 items-center gap-1 sm:hidden">
<span className="text-xs text-muted-foreground">{formatDate(message.receivedAt)}</span>
<Button type="button" variant="ghost" size="icon" aria-label={message.isStarred ? "取消星标" : "添加星标"} className="h-7 w-7 text-muted-foreground hover:text-yellow-500" onClick={(event) => { event.stopPropagation(); onStar() }}>
<Star className={cn("h-4 w-4", message.isStarred && "fill-yellow-400 text-yellow-500")} />
</Button>
</div>
</div>
<div className="mt-1 flex min-w-0 items-center gap-2 sm:mt-0">
<span className="truncate font-medium">{message.subject}</span>
<span className="hidden min-w-0 truncate text-muted-foreground sm:block">{message.snippet}</span>
{scheduled && <Badge variant="secondary" className="h-5 shrink-0 rounded-md px-1.5 text-[11px] font-normal"></Badge>}
{visibleLabels.map((label) => <MailLabelBadge key={label.id} label={label} compact />)}
{hiddenLabelCount > 0 && <Badge variant="outline" className="h-5 shrink-0 rounded-md px-1.5 text-[11px] font-normal text-muted-foreground">+{hiddenLabelCount}</Badge>}
{message.hasAttachments && <Paperclip className="h-3 w-3 shrink-0 text-muted-foreground" />}
</div>
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground sm:hidden">{message.snippet}</div>
</div>
</div> </div>
<div className="shrink-0 text-right text-xs text-muted-foreground">{formatDate(message.receivedAt)}</div> <div className="hidden shrink-0 text-right text-xs text-muted-foreground sm:block">{formatDate(message.receivedAt)}</div>
<Button type="button" variant="ghost" size="icon" aria-label={message.isStarred ? "取消星标" : "添加星标"} className="h-7 w-7 text-muted-foreground hover:text-yellow-500" onClick={(event) => { event.stopPropagation(); onStar() }}> <Button type="button" variant="ghost" size="icon" aria-label={message.isStarred ? "取消星标" : "添加星标"} className="hidden h-7 w-7 text-muted-foreground hover:text-yellow-500 sm:inline-flex" onClick={(event) => { event.stopPropagation(); onStar() }}>
<Star className={cn("h-4 w-4", message.isStarred && "fill-yellow-400 text-yellow-500")} /> <Star className={cn("h-4 w-4", message.isStarred && "fill-yellow-400 text-yellow-500")} />
</Button> </Button>
</div> </div>
@@ -1666,27 +1767,27 @@ function ComposeDialog({ mailbox, open, draft, onOpenChange, onSent }: { mailbox
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent <DialogContent
className="w-[min(96vw,82rem)] max-w-none overflow-hidden p-0" className="flex h-svh w-screen max-w-none overflow-hidden p-0 sm:h-auto sm:max-h-[92vh] sm:w-[min(96vw,82rem)]"
onInteractOutside={(event) => event.preventDefault()} onInteractOutside={(event) => event.preventDefault()}
onPointerDownOutside={(event) => event.preventDefault()} onPointerDownOutside={(event) => event.preventDefault()}
> >
<form key={draft?.key || "new"} className="flex max-h-[90vh] flex-col" onSubmit={submit}> <form key={draft?.key || "new"} className="flex min-h-0 flex-1 flex-col sm:max-h-[90vh]" onSubmit={submit}>
<DialogHeader className="border-b px-6 py-4 text-left"> <DialogHeader className="border-b px-4 py-3 text-left sm:px-6 sm:py-4">
<DialogTitle className="flex items-center justify-between gap-4"> <DialogTitle className="flex min-w-0 flex-col gap-1 pr-8 sm:flex-row sm:items-center sm:justify-between sm:gap-4 sm:pr-6">
<span>{draftId ? "编辑草稿" : "写信"}</span> <span>{draftId ? "编辑草稿" : "写信"}</span>
<span className={cn("text-xs font-normal", draftStatus === "error" ? "text-destructive" : "text-muted-foreground")}> <span className={cn("text-xs font-normal", draftStatus === "error" ? "text-destructive" : "text-muted-foreground")}>
{draftStatus === "saving" ? "正在保存草稿..." : draftStatus === "saved" && lastSavedAt ? `草稿已保存 ${lastSavedAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : draftStatus === "error" ? "草稿保存失败" : ""} {draftStatus === "saving" ? "正在保存草稿..." : draftStatus === "saved" && lastSavedAt ? `草稿已保存 ${lastSavedAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : draftStatus === "error" ? "草稿保存失败" : ""}
</span> </span>
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="flex-1 overflow-y-auto"> <div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
<ComposeField label="发件邮箱"> <ComposeField label="发件邮箱">
<Input value={mailbox?.address || "未选择"} readOnly className="h-10 flex-1 rounded-none border-0 px-0 shadow-none focus-visible:ring-0" /> <Input value={mailbox?.address || "未选择"} readOnly className="h-10 flex-1 rounded-none border-0 px-0 shadow-none focus-visible:ring-0" />
</ComposeField> </ComposeField>
<ComposeField <ComposeField
label="收件人" label="收件人"
action={ action={
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2 text-sm"> <div className="flex shrink-0 flex-wrap items-center justify-start gap-1 text-sm sm:justify-end sm:gap-2">
<Button type="button" variant="ghost" size="sm" className="h-8 px-2 font-normal" onClick={() => setShowCc((value) => !value)}></Button> <Button type="button" variant="ghost" size="sm" className="h-8 px-2 font-normal" onClick={() => setShowCc((value) => !value)}></Button>
<Button type="button" variant="ghost" size="sm" className="h-8 px-2 font-normal" onClick={() => setShowBcc((value) => !value)}></Button> <Button type="button" variant="ghost" size="sm" className="h-8 px-2 font-normal" onClick={() => setShowBcc((value) => !value)}></Button>
<div className="flex items-center gap-2 rounded-md px-2 py-1"> <div className="flex items-center gap-2 rounded-md px-2 py-1">
@@ -1721,10 +1822,10 @@ function ComposeDialog({ mailbox, open, draft, onOpenChange, onSent }: { mailbox
onRemoveFile={(index) => { setAttachmentsTouched(true); setFiles((current) => current.filter((_, itemIndex) => itemIndex !== index)) }} onRemoveFile={(index) => { setAttachmentsTouched(true); setFiles((current) => current.filter((_, itemIndex) => itemIndex !== index)) }}
/> />
</div> </div>
<DialogFooter className="border-t bg-background px-6 py-4"> <DialogFooter className="grid grid-cols-3 gap-2 border-t bg-background px-4 py-3 sm:flex sm:flex-row sm:justify-end sm:px-6 sm:py-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}></Button> <Button type="button" variant="outline" className="min-h-10 px-3" onClick={() => onOpenChange(false)}></Button>
<Button type="button" variant="outline" disabled={send.isPending || scheduleSend.isPending || !mailbox} onClick={() => setScheduleDialogOpen(true)}><Calendar className="h-4 w-4" /></Button> <Button type="button" variant="outline" className="min-h-10 px-3" disabled={send.isPending || scheduleSend.isPending || !mailbox} onClick={() => setScheduleDialogOpen(true)}><Calendar className="h-4 w-4" /></Button>
<Button disabled={send.isPending || !mailbox}><Send className="h-4 w-4" />{send.isPending ? "发送中..." : "发送"}</Button> <Button className="min-h-10 px-4" disabled={send.isPending || !mailbox}><Send className="h-4 w-4" />{send.isPending ? "发送中..." : "发送"}</Button>
</DialogFooter> </DialogFooter>
</form> </form>
<ScheduleSendDialog open={scheduleDialogOpen} pending={scheduleSend.isPending} onOpenChange={setScheduleDialogOpen} onConfirm={scheduleAt} /> <ScheduleSendDialog open={scheduleDialogOpen} pending={scheduleSend.isPending} onOpenChange={setScheduleDialogOpen} onConfirm={scheduleAt} />
@@ -1744,8 +1845,8 @@ function ComposeDialog({ mailbox, open, draft, onOpenChange, onSent }: { mailbox
function ComposeField({ label, children, action }: { label: string; children: React.ReactNode; action?: React.ReactNode }) { function ComposeField({ label, children, action }: { label: string; children: React.ReactNode; action?: React.ReactNode }) {
return ( return (
<div className="flex min-h-14 flex-col gap-2 border-b px-6 py-2 sm:flex-row sm:items-center"> <div className="flex min-h-14 flex-col gap-2 border-b px-4 py-2 sm:flex-row sm:items-center sm:px-6">
<Label className="w-20 shrink-0 text-base font-normal text-foreground">{label}</Label> <Label className="shrink-0 text-base font-normal text-foreground sm:w-20">{label}</Label>
<div className="flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:items-center"> <div className="flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:items-center">
{children} {children}
{action} {action}
@@ -1955,13 +2056,19 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, onC
const fileInputRef = React.useRef<HTMLInputElement>(null) const fileInputRef = React.useRef<HTMLInputElement>(null)
const dirtyRef = React.useRef(false) const dirtyRef = React.useRef(false)
const lastDefaultRef = React.useRef(`${defaultValue}\n${defaultHtml || ""}`) const lastDefaultRef = React.useRef(`${defaultValue}\n${defaultHtml || ""}`)
const [formatOpen, setFormatOpen] = React.useState(true) const isMobile = useIsMobile()
const [formatOpen, setFormatOpen] = React.useState(() => typeof window === "undefined" ? true : window.innerWidth >= 768)
const [scheduleOpen, setScheduleOpen] = React.useState(false) const [scheduleOpen, setScheduleOpen] = React.useState(false)
const [emojiOpen, setEmojiOpen] = React.useState(false) const [emojiOpen, setEmojiOpen] = React.useState(false)
const [insertDialog, setInsertDialog] = React.useState<InsertDialogState | null>(null) const [insertDialog, setInsertDialog] = React.useState<InsertDialogState | null>(null)
const [previewOpen, setPreviewOpen] = React.useState(false) const [previewOpen, setPreviewOpen] = React.useState(false)
const [empty, setEmpty] = React.useState(!defaultValue.trim()) const [empty, setEmpty] = React.useState(!defaultValue.trim())
const [selectionVersion, setSelectionVersion] = React.useState(0) const [selectionVersion, setSelectionVersion] = React.useState(0)
React.useEffect(() => {
setFormatOpen(!isMobile)
}, [isMobile])
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
StarterKit.configure({ link: false }), StarterKit.configure({ link: false }),
@@ -1983,7 +2090,7 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, onC
content: composerInitialHtml(defaultValue, defaultHtml), content: composerInitialHtml(defaultValue, defaultHtml),
editorProps: { editorProps: {
attributes: { attributes: {
class: "mail-html min-h-[280px] overflow-y-auto px-6 py-5 text-base leading-7 outline-none", class: "mail-html min-h-[240px] min-w-0 flex-1 overflow-y-auto px-4 py-4 text-base leading-7 outline-none sm:min-h-[280px] sm:px-6 sm:py-5",
"aria-label": "正文", "aria-label": "正文",
}, },
handlePaste(view, event) { handlePaste(view, event) {
@@ -2105,13 +2212,12 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, onC
} }
return ( return (
<div className="min-h-[420px]"> <div className="flex min-h-[330px] flex-1 flex-col bg-background sm:min-h-[420px]">
<Input ref={fileInputRef} type="file" multiple className="hidden" onChange={handlePickedFiles} /> <Input ref={fileInputRef} type="file" multiple className="hidden" onChange={handlePickedFiles} />
<div className="flex min-h-11 flex-wrap items-center gap-1 border-b px-6 py-2"> <div className="flex min-h-11 flex-wrap items-center gap-1 overflow-visible border-b px-3 py-2 sm:px-6">
<ToolbarButton label="撤销" disabled={!editor?.can().undo()} onClick={() => editor?.chain().focus().undo().run()}><Undo2 className="h-4 w-4" /></ToolbarButton> <ToolbarButton label="撤销" disabled={!editor?.can().undo()} onClick={() => editor?.chain().focus().undo().run()}><Undo2 className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="重做" disabled={!editor?.can().redo()} onClick={() => editor?.chain().focus().redo().run()}><Redo2 className="h-4 w-4" /></ToolbarButton> <ToolbarButton label="重做" disabled={!editor?.can().redo()} onClick={() => editor?.chain().focus().redo().run()}><Redo2 className="h-4 w-4" /></ToolbarButton>
<Separator orientation="vertical" className="mx-2 h-6" /> <Separator orientation="vertical" className="mx-2 h-6" />
<ToolbarTextButton label="图片" icon={<Image className="h-4 w-4" />} active={editor?.isActive("image")} disabled={!editor} onClick={() => openInsertDialog("image")} />
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button type="button" variant="ghost" size="sm" className="h-8 gap-1 rounded-md px-2 font-normal hover:bg-accent hover:shadow-sm" onMouseDown={(event) => event.preventDefault()}> <Button type="button" variant="ghost" size="sm" className="h-8 gap-1 rounded-md px-2 font-normal hover:bg-accent hover:shadow-sm" onMouseDown={(event) => event.preventDefault()}>
@@ -2125,7 +2231,6 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, onC
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => editor?.chain().focus().setHorizontalRule().run()}><span className="h-4 w-4 border-t border-current" aria-hidden />线</DropdownMenuItem> <DropdownMenuItem className={composerMenuItemClass} onSelect={() => editor?.chain().focus().setHorizontalRule().run()}><span className="h-4 w-4 border-t border-current" aria-hidden />线</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<ToolbarTextButton label="导入文档" icon={<FileText className="h-4 w-4" />} onClick={() => fileInputRef.current?.click()} />
<ToolbarTextButton label="日程" icon={<Calendar className="h-4 w-4" />} onClick={() => setScheduleOpen(true)} /> <ToolbarTextButton label="日程" icon={<Calendar className="h-4 w-4" />} onClick={() => setScheduleOpen(true)} />
<DropdownMenu open={emojiOpen} onOpenChange={setEmojiOpen}> <DropdownMenu open={emojiOpen} onOpenChange={setEmojiOpen}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -2144,14 +2249,13 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, onC
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<ToolbarTextButton label="格式" icon={<Type className="h-4 w-4" />} active={formatOpen} onClick={() => setFormatOpen((value) => !value)} /> <ToolbarTextButton label="格式" icon={<Type className="h-4 w-4" />} active={formatOpen} onClick={() => setFormatOpen((value) => !value)} />
<div className="ml-auto flex items-center gap-1"> <div className="flex items-center gap-1">
<ToolbarTextButton label="预览" icon={<Eye className="h-4 w-4" />} active={previewOpen} onClick={() => setPreviewOpen(true)} /> <ToolbarTextButton label="预览" icon={<Eye className="h-4 w-4" />} active={previewOpen} onClick={() => setPreviewOpen(true)} />
<ToolbarTextButton label="签名" icon={<Signature className="h-4 w-4" />} onClick={insertSignature} disabled={!signatureText.trim()} /> <ToolbarTextButton label="签名" icon={<Signature className="h-4 w-4" />} onClick={insertSignature} disabled={!signatureText.trim()} />
<ToolbarButton label="更多"><Ellipsis className="h-4 w-4" /></ToolbarButton>
</div> </div>
</div> </div>
{formatOpen && ( {formatOpen && (
<div className="flex min-h-14 flex-wrap items-center gap-1 border-b bg-muted/40 px-6 py-2"> <div className="flex min-h-14 flex-wrap items-center gap-1 overflow-visible border-b bg-muted/40 px-3 py-2 sm:px-6">
<ToolbarButton label="清除格式" disabled={!editor} onClick={() => editor?.chain().focus().unsetAllMarks().clearNodes().run()}><Eraser className="h-4 w-4" /></ToolbarButton> <ToolbarButton label="清除格式" disabled={!editor} onClick={() => editor?.chain().focus().unsetAllMarks().clearNodes().run()}><Eraser className="h-4 w-4" /></ToolbarButton>
<Separator orientation="vertical" className="mx-2 h-6" /> <Separator orientation="vertical" className="mx-2 h-6" />
<DropdownMenu> <DropdownMenu>
@@ -2233,16 +2337,16 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, onC
</div> </div>
)} )}
<div className={cn( <div className={cn(
"composer-editor relative", "composer-editor relative flex min-h-[240px] flex-1 border-b focus-within:bg-card/40 sm:min-h-[280px]",
"[&_.ProseMirror]:min-h-[280px] [&_.ProseMirror]:overflow-y-auto [&_.ProseMirror]:px-6 [&_.ProseMirror]:py-5 [&_.ProseMirror]:text-base [&_.ProseMirror]:leading-7 [&_.ProseMirror]:outline-none", "[&_.ProseMirror]:min-h-[240px] [&_.ProseMirror]:w-full [&_.ProseMirror]:flex-1 [&_.ProseMirror]:overflow-y-auto [&_.ProseMirror]:px-4 [&_.ProseMirror]:py-4 [&_.ProseMirror]:text-base [&_.ProseMirror]:leading-7 [&_.ProseMirror]:outline-none sm:[&_.ProseMirror]:min-h-[280px] sm:[&_.ProseMirror]:px-6 sm:[&_.ProseMirror]:py-5",
"[&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-left [&_.ProseMirror_p.is-editor-empty:first-child::before]:h-0 [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-muted-foreground [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)]", "[&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-left [&_.ProseMirror_p.is-editor-empty:first-child::before]:h-0 [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-muted-foreground [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)]",
"[&_.ProseMirror_ul]:list-disc [&_.ProseMirror_ol]:list-decimal [&_.ProseMirror_ul]:pl-6 [&_.ProseMirror_ol]:pl-6 [&_.ProseMirror_blockquote]:border-l-4 [&_.ProseMirror_blockquote]:border-border [&_.ProseMirror_blockquote]:pl-4 [&_.ProseMirror_blockquote]:text-muted-foreground [&_.ProseMirror_pre]:rounded-md [&_.ProseMirror_pre]:bg-muted [&_.ProseMirror_pre]:p-3", "[&_.ProseMirror_ul]:list-disc [&_.ProseMirror_ol]:list-decimal [&_.ProseMirror_ul]:pl-6 [&_.ProseMirror_ol]:pl-6 [&_.ProseMirror_blockquote]:border-l-4 [&_.ProseMirror_blockquote]:border-border [&_.ProseMirror_blockquote]:pl-4 [&_.ProseMirror_blockquote]:text-muted-foreground [&_.ProseMirror_pre]:rounded-md [&_.ProseMirror_pre]:bg-muted [&_.ProseMirror_pre]:p-3",
empty && "bg-background" empty && "bg-background"
)}> )}>
<EditorContent editor={editor} /> <EditorContent editor={editor} className="flex min-h-0 flex-1" />
</div> </div>
{files.length > 0 && ( {files.length > 0 && (
<div className="border-t px-6 py-3"> <div className="border-t px-4 py-3 sm:px-6">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{files.map((file, index) => ( {files.map((file, index) => (
<Badge key={`${file.name}-${file.size}-${index}`} variant="outline" className="h-8 gap-2 rounded-md px-2 font-normal"> <Badge key={`${file.name}-${file.size}-${index}`} variant="outline" className="h-8 gap-2 rounded-md px-2 font-normal">
+77 -47
View File
@@ -10,6 +10,7 @@ import { applyTheme, getInitialTheme } from "@/lib/theme"
import { DisplayMode, useDisplayMode } from "@/lib/display-mode" import { DisplayMode, useDisplayMode } from "@/lib/display-mode"
import { useMe } from "@/hooks/use-me" import { useMe } from "@/hooks/use-me"
import { useLogout } from "@/hooks/use-logout" import { useLogout } from "@/hooks/use-logout"
import { useIsMobile } from "@/hooks/use-mobile"
import { validatePasswordConfirm } from "@/lib/validation" import { validatePasswordConfirm } from "@/lib/validation"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { PasswordInput } from "@/components/ui/password-input" import { PasswordInput } from "@/components/ui/password-input"
@@ -21,6 +22,7 @@ import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
@@ -60,6 +62,8 @@ export function ProfilePage() {
const [displayMode, setDisplayMode] = useDisplayMode() const [displayMode, setDisplayMode] = useDisplayMode()
const [blockedMailboxId, setBlockedMailboxId] = React.useState("all") const [blockedMailboxId, setBlockedMailboxId] = React.useState("all")
const [ruleDialogOpen, setRuleDialogOpen] = React.useState(false) const [ruleDialogOpen, setRuleDialogOpen] = React.useState(false)
const [mobileSidebarOpen, setMobileSidebarOpen] = React.useState(false)
const isMobile = useIsMobile()
const themeMountedRef = React.useRef(false) const themeMountedRef = React.useRef(false)
const rawTab = params.get("tab") as Tab | null const rawTab = params.get("tab") as Tab | null
@@ -187,52 +191,78 @@ export function ProfilePage() {
const logout = useLogout() const logout = useLogout()
async function copy(text: string) { await navigator.clipboard.writeText(text); toast({ title: "已复制" }) } async function copy(text: string) { await navigator.clipboard.writeText(text); toast({ title: "已复制" }) }
function setTab(next: Tab) { setParams(next === "profile" ? {} : { tab: next }) } function setTab(next: Tab) { setParams(next === "profile" ? {} : { tab: next }); setMobileSidebarOpen(false) }
function toggleSidebar() { sidebarCollapsed ? (sidebarPanelRef.current?.expand(14), setSidebarCollapsed(false)) : (sidebarPanelRef.current?.collapse(), setSidebarCollapsed(true)) } function toggleSidebar() { sidebarCollapsed ? (sidebarPanelRef.current?.expand(14), setSidebarCollapsed(false)) : (sidebarPanelRef.current?.collapse(), setSidebarCollapsed(true)) }
if (me.isLoading) return <div className="grid h-svh place-items-center text-muted-foreground">...</div> if (me.isLoading) return <div className="grid h-svh place-items-center text-muted-foreground">...</div>
if (me.isError || !user) return <div className="grid h-svh place-items-center text-muted-foreground"></div> if (me.isError || !user) return <div className="grid h-svh place-items-center text-muted-foreground"></div>
const sidebarContent = (
<Sidebar collapsible="none" className="h-full w-full border-r bg-sidebar">
<SidebarHeader className={cn("border-b py-4", sidebarCollapsed ? "px-2" : "px-4")}>
<AccountHeader collapsed={sidebarCollapsed} name={user.displayName || selectedMailbox?.address || "LanQin"} email={user.email || selectedMailbox?.address} darkMode={darkMode} onToggleTheme={() => setDarkMode((v) => !v)} onBack={() => navigate("/")} />
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
{!sidebarCollapsed && <SidebarGroupLabel></SidebarGroupLabel>}
<SidebarGroupContent>
<SidebarMenu>{tabKeys.map((key) => <SidebarMenuItem key={key}><SidebarMenuButton isActive={tab === key} className={cn(sidebarCollapsed && "justify-center px-0")} onClick={() => setTab(key)}>{tabs[key].icon}{!sidebarCollapsed && <span>{tabs[key].label}</span>}</SidebarMenuButton></SidebarMenuItem>)}</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<div className={cn("mt-auto border-t p-2", sidebarCollapsed ? "flex flex-col items-center" : "")}>
<Button type="button" variant="ghost" size={sidebarCollapsed ? "icon" : "sm"} className={cn("text-muted-foreground", !sidebarCollapsed && "w-full justify-start")} onClick={logout}>
<LogOut className="h-4 w-4" />
{!sidebarCollapsed && <span>退</span>}
</Button>
{!isMobile && (
<>
<Separator className="my-2" />
<Button type="button" variant="ghost" size={sidebarCollapsed ? "icon" : "sm"} className={cn(!sidebarCollapsed && "w-full justify-start")} onClick={toggleSidebar}>{sidebarCollapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}{!sidebarCollapsed && <span></span>}</Button>
</>
)}
</div>
</Sidebar>
)
return ( return (
<div className="h-svh bg-background"> <div className="h-svh overflow-hidden bg-background">
<SidebarProvider className="h-full min-h-0 w-full"> <SidebarProvider className="h-full min-h-0 w-full">
<ResizablePanelGroup direction="horizontal" className="h-full min-h-0 w-full"> {isMobile ? (
<ResizablePanel ref={sidebarPanelRef} collapsible collapsedSize={4} defaultSize={15} minSize={11} maxSize={24} onCollapse={() => setSidebarCollapsed(true)} onExpand={() => setSidebarCollapsed(false)}> <div className="flex h-full min-h-0 flex-col">
<Sidebar collapsible="none" className="h-full w-full border-r bg-sidebar"> <header className="flex h-14 shrink-0 items-center gap-2 border-b px-3">
<SidebarHeader className={cn("border-b py-4", sidebarCollapsed ? "px-2" : "px-4")}> <Sheet open={mobileSidebarOpen} onOpenChange={setMobileSidebarOpen}>
<AccountHeader collapsed={sidebarCollapsed} name={user.displayName || selectedMailbox?.address || "LanQin"} email={user.email || selectedMailbox?.address} darkMode={darkMode} onToggleTheme={() => setDarkMode((v) => !v)} onBack={() => navigate("/")} /> <SheetTrigger asChild>
</SidebarHeader> <Button size="icon" variant="ghost" aria-label="打开导航"><PanelLeftOpen className="h-4 w-4" /></Button>
<SidebarContent> </SheetTrigger>
<SidebarGroup> <SheetContent side="left" className="w-[86vw] max-w-80 p-0 [&>button]:hidden" aria-describedby={undefined}>
{!sidebarCollapsed && <SidebarGroupLabel></SidebarGroupLabel>} <SheetTitle className="sr-only"></SheetTitle>
<SidebarGroupContent> <div className="h-svh">{sidebarContent}</div>
<SidebarMenu>{tabKeys.map((key) => <SidebarMenuItem key={key}><SidebarMenuButton isActive={tab === key} className={cn(sidebarCollapsed && "justify-center px-0")} onClick={() => setTab(key)}>{tabs[key].icon}{!sidebarCollapsed && <span>{tabs[key].label}</span>}</SidebarMenuButton></SidebarMenuItem>)}</SidebarMenu> </SheetContent>
</SidebarGroupContent> </Sheet>
</SidebarGroup> <div className="min-w-0 flex-1 text-sm font-semibold">{tabs[tab].label}</div>
</SidebarContent> <Button type="button" variant="ghost" size="icon" onClick={() => navigate("/")} aria-label="返回邮箱"><ArrowLeft className="h-4 w-4" /></Button>
<div className={cn("mt-auto border-t p-2", sidebarCollapsed ? "flex flex-col items-center" : "")}> </header>
<Button type="button" variant="ghost" size={sidebarCollapsed ? "icon" : "sm"} className={cn("text-muted-foreground", !sidebarCollapsed && "w-full justify-start")} onClick={logout}> <ScrollArea className="min-h-0 flex-1"><main className="w-full p-4">{renderTab()}</main></ScrollArea>
<LogOut className="h-4 w-4" /> </div>
{!sidebarCollapsed && <span>退</span>} ) : (
</Button> <ResizablePanelGroup direction="horizontal" className="h-full min-h-0 w-full">
<Separator className="my-2" /> <ResizablePanel ref={sidebarPanelRef} collapsible collapsedSize={4} defaultSize={15} minSize={11} maxSize={24} onCollapse={() => setSidebarCollapsed(true)} onExpand={() => setSidebarCollapsed(false)}>
<Button type="button" variant="ghost" size={sidebarCollapsed ? "icon" : "sm"} className={cn(!sidebarCollapsed && "w-full justify-start")} onClick={toggleSidebar}>{sidebarCollapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}{!sidebarCollapsed && <span></span>}</Button> {sidebarContent}
</div> </ResizablePanel>
</Sidebar> <ResizableHandle withHandle />
</ResizablePanel> <ResizablePanel defaultSize={85} minSize={60}>
<ResizableHandle withHandle /> <section className="flex h-full min-h-0 flex-col">
<ResizablePanel defaultSize={85} minSize={60}> <header className="flex h-16 shrink-0 items-center justify-between gap-3 border-b px-5">
<section className="flex h-full min-h-0 flex-col"> <div className="text-sm font-semibold">{tabs[tab].label}</div>
<header className="flex h-16 shrink-0 items-center justify-between gap-3 border-b px-5"> </header>
<div className="text-sm font-semibold">{tabs[tab].label}</div> <ScrollArea className="min-h-0 flex-1"><main className="mx-auto w-full max-w-6xl p-6">{renderTab()}</main></ScrollArea>
</header> </section>
<ScrollArea className="min-h-0 flex-1"><main className="mx-auto w-full max-w-6xl p-6">{renderTab()}</main></ScrollArea> </ResizablePanel>
</section> </ResizablePanelGroup>
</ResizablePanel> )}
</ResizablePanelGroup>
</SidebarProvider> </SidebarProvider>
</div> </div>
) )
function renderTab() { function renderTab() {
if (tab === "mailboxes") return <MailboxManagement mailboxes={mailboxes.data?.items || []} applyOptions={mailboxApplyOptions.data} applyPending={applyMailbox.isPending} selectedMailboxId={mailboxId} onSelect={setMailboxId} onCopy={copy} onOpen={(id) => { setMailboxId(id); navigate("/") }} onApply={(payload) => applyMailbox.mutateAsync(payload).then(() => undefined)} /> if (tab === "mailboxes") return <MailboxManagement mailboxes={mailboxes.data?.items || []} applyOptions={mailboxApplyOptions.data} applyPending={applyMailbox.isPending} selectedMailboxId={mailboxId} onSelect={setMailboxId} onCopy={copy} onOpen={(id) => { setMailboxId(id); navigate("/") }} onApply={(payload) => applyMailbox.mutateAsync(payload).then(() => undefined)} />
if (tab === "clients") return <ClientSettingsSection mailboxes={mailboxes.data?.items || []} selectedMailboxId={mailboxId} hostname={publicSettings.data?.publicHostname} onSelectMailbox={setMailboxId} onCopy={copy} /> if (tab === "clients") return <ClientSettingsSection mailboxes={mailboxes.data?.items || []} selectedMailboxId={mailboxId} hostname={publicSettings.data?.publicHostname} onSelectMailbox={setMailboxId} onCopy={copy} />
@@ -439,7 +469,7 @@ function ApplyMailboxDialog({ options, pending, onApply }: { options: MailboxApp
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<Button type="button" onClick={() => setOpen(true)}><Plus className="h-4 w-4" /></Button> <Button type="button" onClick={() => setOpen(true)}><Plus className="h-4 w-4" /></Button>
<DialogContent className="sm:max-w-lg"> <DialogContent className="max-h-[92dvh] overflow-y-auto sm:max-w-lg">
<DialogHeader><DialogTitle></DialogTitle></DialogHeader> <DialogHeader><DialogTitle></DialogTitle></DialogHeader>
<form className="space-y-4" onSubmit={submit}> <form className="space-y-4" onSubmit={submit}>
<Field label="邮箱前缀"><Input name="localPart" autoFocus required placeholder="your-name" /></Field> <Field label="邮箱前缀"><Input name="localPart" autoFocus required placeholder="your-name" /></Field>
@@ -450,7 +480,7 @@ function ApplyMailboxDialog({ options, pending, onApply }: { options: MailboxApp
</Select> </Select>
</Field> </Field>
<Field label="显示名称"><Input name="displayName" placeholder="可选" /></Field> <Field label="显示名称"><Input name="displayName" placeholder="可选" /></Field>
<DialogFooter> <DialogFooter className="gap-2 [&>button]:w-full sm:[&>button]:w-auto">
<Button type="button" variant="outline" onClick={() => setOpen(false)}></Button> <Button type="button" variant="outline" onClick={() => setOpen(false)}></Button>
<Button disabled={pending || !domainId}>{pending ? "申请中..." : "申请"}</Button> <Button disabled={pending || !domainId}>{pending ? "申请中..." : "申请"}</Button>
</DialogFooter> </DialogFooter>
@@ -620,7 +650,7 @@ function SignaturesSection({ items, mailboxes, loading, pending, onCreate, onUpd
</CardContent> </CardContent>
</Card> </Card>
<Dialog open={!!editing} onOpenChange={(open) => { if (!open) setEditing(null) }}> <Dialog open={!!editing} onOpenChange={(open) => { if (!open) setEditing(null) }}>
<DialogContent className="sm:max-w-2xl"> <DialogContent className="max-h-[92dvh] overflow-y-auto sm:max-w-2xl">
<DialogHeader><DialogTitle></DialogTitle></DialogHeader> <DialogHeader><DialogTitle></DialogTitle></DialogHeader>
{editing && ( {editing && (
<form key={editing.id} className="space-y-4" onSubmit={(e) => { e.preventDefault(); const form = new FormData(e.currentTarget); form.set("mailboxId", editingMailboxId === "all" ? "" : editingMailboxId); form.set("isDefault", editingIsDefault ? "on" : ""); onUpdate(editing.id, form); setEditing(null) }}> <form key={editing.id} className="space-y-4" onSubmit={(e) => { e.preventDefault(); const form = new FormData(e.currentTarget); form.set("mailboxId", editingMailboxId === "all" ? "" : editingMailboxId); form.set("isDefault", editingIsDefault ? "on" : ""); onUpdate(editing.id, form); setEditing(null) }}>
@@ -637,7 +667,7 @@ function SignaturesSection({ items, mailboxes, loading, pending, onCreate, onUpd
<Checkbox checked={editingIsDefault} onCheckedChange={(value) => setEditing((current) => current ? { ...current, isDefault: value === true } : current)} /> <Checkbox checked={editingIsDefault} onCheckedChange={(value) => setEditing((current) => current ? { ...current, isDefault: value === true } : current)} />
<span></span> <span></span>
</label> </label>
<DialogFooter> <DialogFooter className="gap-2 [&>button]:w-full sm:[&>button]:w-auto">
<Button type="button" variant="outline" onClick={() => setEditing(null)}></Button> <Button type="button" variant="outline" onClick={() => setEditing(null)}></Button>
<Button disabled={pending}>{pending ? "保存中..." : "保存修改"}</Button> <Button disabled={pending}>{pending ? "保存中..." : "保存修改"}</Button>
</DialogFooter> </DialogFooter>
@@ -796,12 +826,12 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate }
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[min(94vw,84rem)] max-w-none gap-0 overflow-hidden p-0"> <DialogContent className="flex h-svh w-screen max-w-none gap-0 overflow-hidden p-0 sm:h-auto sm:max-h-[92vh] sm:w-[min(94vw,84rem)]">
<DialogHeader className="border-b px-8 py-6"> <DialogHeader className="border-b px-4 py-4 text-left sm:px-8 sm:py-6">
<DialogTitle className="text-2xl"></DialogTitle> <DialogTitle className="text-xl sm:text-2xl"></DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={submit}> <form className="flex min-h-0 flex-1 flex-col" onSubmit={submit}>
<div className="space-y-7 px-8 py-7"> <div className="min-h-0 flex-1 space-y-6 overflow-y-auto px-4 py-5 sm:space-y-7 sm:px-8 sm:py-7">
<Field label="名称"><Input value={name} onChange={(event) => setName(event.target.value)} placeholder="我的规则" /></Field> <Field label="名称"><Input value={name} onChange={(event) => setName(event.target.value)} placeholder="我的规则" /></Field>
<Field label="适用邮箱"><MailboxSelect value={mailboxId} mailboxes={mailboxes} onChange={setMailboxId} /></Field> <Field label="适用邮箱"><MailboxSelect value={mailboxId} mailboxes={mailboxes} onChange={setMailboxId} /></Field>
@@ -860,7 +890,7 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate }
</div> </div>
</div> </div>
</div> </div>
<DialogFooter className="border-t px-8 py-5"> <DialogFooter className="gap-2 border-t px-4 py-4 sm:px-8 sm:py-5 [&>button]:w-full sm:[&>button]:w-auto">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}></Button> <Button type="button" variant="outline" onClick={() => onOpenChange(false)}></Button>
<Button disabled={!canCreate}>{pending ? "创建中..." : "创建"}</Button> <Button disabled={!canCreate}>{pending ? "创建中..." : "创建"}</Button>
</DialogFooter> </DialogFooter>
+1 -1
View File
@@ -56,5 +56,5 @@ module.exports = {
} }
} }
}, },
plugins: [], plugins: [require("tailwindcss-animate")],
} }
+12
View File
@@ -138,6 +138,9 @@ importers:
tailwindcss: tailwindcss:
specifier: 3.4.15 specifier: 3.4.15
version: 3.4.15 version: 3.4.15
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@3.4.15)
typescript: typescript:
specifier: 5.6.3 specifier: 5.6.3
version: 5.6.3 version: 5.6.3
@@ -1491,6 +1494,11 @@ packages:
tailwind-merge@2.5.4: tailwind-merge@2.5.4:
resolution: {integrity: sha512-0q8cfZHMu9nuYP/b5Shb7Y7Sh1B7Nnl5GqNr1U+n2p6+mybvRtayrQ+0042Z5byvTA8ihjlP8Odo8/VnHbZu4Q==} resolution: {integrity: sha512-0q8cfZHMu9nuYP/b5Shb7Y7Sh1B7Nnl5GqNr1U+n2p6+mybvRtayrQ+0042Z5byvTA8ihjlP8Odo8/VnHbZu4Q==}
tailwindcss-animate@1.0.7:
resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==}
peerDependencies:
tailwindcss: '>=3.0.0 || insiders'
tailwindcss@3.4.15: tailwindcss@3.4.15:
resolution: {integrity: sha512-r4MeXnfBmSOuKUWmXe6h2CcyfzJCEk4F0pptO5jlnYSIViUkVmsawj80N5h2lO3gwcmSb4n3PuN+e+GC1Guylw==} resolution: {integrity: sha512-r4MeXnfBmSOuKUWmXe6h2CcyfzJCEk4F0pptO5jlnYSIViUkVmsawj80N5h2lO3gwcmSb4n3PuN+e+GC1Guylw==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
@@ -2892,6 +2900,10 @@ snapshots:
tailwind-merge@2.5.4: {} tailwind-merge@2.5.4: {}
tailwindcss-animate@1.0.7(tailwindcss@3.4.15):
dependencies:
tailwindcss: 3.4.15
tailwindcss@3.4.15: tailwindcss@3.4.15:
dependencies: dependencies:
'@alloc/quick-lru': 5.2.0 '@alloc/quick-lru': 5.2.0