feat(web): 优化邮箱与个人中心移动端体验

- 为管理页、邮件页和个人中心补充移动端侧栏与抽屉导航,提升小屏可用性。
- 将多处表格/详情/编辑器改为自适应布局,优化邮件列表、消息详情、撰写与规则弹窗的移动端显示。
- 调整顶部栏、按钮与内容区域间距,统一不同屏幕下的阅读与操作体验。
This commit is contained in:
LanQin
2026-06-20 02:13:29 +08:00
parent 9b70ba5927
commit 4dd843b50a
4 changed files with 611 additions and 392 deletions
+77 -47
View File
@@ -10,6 +10,7 @@ import { applyTheme, getInitialTheme } from "@/lib/theme"
import { DisplayMode, useDisplayMode } from "@/lib/display-mode"
import { useMe } from "@/hooks/use-me"
import { useLogout } from "@/hooks/use-logout"
import { useIsMobile } from "@/hooks/use-mobile"
import { validatePasswordConfirm } from "@/lib/validation"
import { Button } from "@/components/ui/button"
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 { Checkbox } from "@/components/ui/checkbox"
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 { Separator } from "@/components/ui/separator"
import { ScrollArea } from "@/components/ui/scroll-area"
@@ -60,6 +62,8 @@ export function ProfilePage() {
const [displayMode, setDisplayMode] = useDisplayMode()
const [blockedMailboxId, setBlockedMailboxId] = React.useState("all")
const [ruleDialogOpen, setRuleDialogOpen] = React.useState(false)
const [mobileSidebarOpen, setMobileSidebarOpen] = React.useState(false)
const isMobile = useIsMobile()
const themeMountedRef = React.useRef(false)
const rawTab = params.get("tab") as Tab | null
@@ -187,52 +191,78 @@ export function ProfilePage() {
const logout = useLogout()
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)) }
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>
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 (
<div className="h-svh bg-background">
<div className="h-[100dvh] overflow-hidden bg-background">
<SidebarProvider className="h-full min-h-0 w-full">
<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)}>
<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>
<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>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={85} minSize={60}>
<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">
<div className="text-sm font-semibold">{tabs[tab].label}</div>
</header>
<ScrollArea className="min-h-0 flex-1"><main className="mx-auto w-full max-w-6xl p-6">{renderTab()}</main></ScrollArea>
</section>
</ResizablePanel>
</ResizablePanelGroup>
{isMobile ? (
<div className="flex h-full min-h-0 flex-col">
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-3">
<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">
<SheetTitle className="sr-only"></SheetTitle>
<div className="h-[100dvh]">{sidebarContent}</div>
</SheetContent>
</Sheet>
<div className="min-w-0 flex-1 text-sm font-semibold">{tabs[tab].label}</div>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/")} aria-label="返回邮箱"><ArrowLeft className="h-4 w-4" /></Button>
</header>
<ScrollArea className="min-h-0 flex-1"><main className="w-full p-4">{renderTab()}</main></ScrollArea>
</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>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={85} minSize={60}>
<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">
<div className="text-sm font-semibold">{tabs[tab].label}</div>
</header>
<ScrollArea className="min-h-0 flex-1"><main className="mx-auto w-full max-w-6xl p-6">{renderTab()}</main></ScrollArea>
</section>
</ResizablePanel>
</ResizablePanelGroup>
)}
</SidebarProvider>
</div>
)
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 === "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 (
<Dialog open={open} onOpenChange={setOpen}>
<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>
<form className="space-y-4" onSubmit={submit}>
<Field label="邮箱前缀"><Input name="localPart" autoFocus required placeholder="your-name" /></Field>
@@ -450,7 +480,7 @@ function ApplyMailboxDialog({ options, pending, onApply }: { options: MailboxApp
</Select>
</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 disabled={pending || !domainId}>{pending ? "申请中..." : "申请"}</Button>
</DialogFooter>
@@ -620,7 +650,7 @@ function SignaturesSection({ items, mailboxes, loading, pending, onCreate, onUpd
</CardContent>
</Card>
<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>
{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) }}>
@@ -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)} />
<span></span>
</label>
<DialogFooter>
<DialogFooter className="gap-2 [&>button]:w-full sm:[&>button]:w-auto">
<Button type="button" variant="outline" onClick={() => setEditing(null)}></Button>
<Button disabled={pending}>{pending ? "保存中..." : "保存修改"}</Button>
</DialogFooter>
@@ -796,12 +826,12 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate }
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[min(94vw,84rem)] max-w-none gap-0 overflow-hidden p-0">
<DialogHeader className="border-b px-8 py-6">
<DialogTitle className="text-2xl"></DialogTitle>
<DialogContent className="flex h-[100dvh] 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-4 py-4 text-left sm:px-8 sm:py-6">
<DialogTitle className="text-xl sm:text-2xl"></DialogTitle>
</DialogHeader>
<form onSubmit={submit}>
<div className="space-y-7 px-8 py-7">
<form className="flex min-h-0 flex-1 flex-col" onSubmit={submit}>
<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="适用邮箱"><MailboxSelect value={mailboxId} mailboxes={mailboxes} onChange={setMailboxId} /></Field>
@@ -860,7 +890,7 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate }
</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 disabled={!canCreate}>{pending ? "创建中..." : "创建"}</Button>
</DialogFooter>