From d74f356f41cd0afbf39a4d12b8c5640c14bc17e4 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 4 Sep 2026 06:30:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E6=B7=B1=E8=89=B2=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=20=E2=80=94=E2=80=94=20=E5=8F=8D=E8=BD=AC=E7=81=B0?= =?UTF-8?q?=E9=98=B6=E8=80=8C=E9=9D=9E=E9=80=90=E5=A4=84=20dark:=20?= =?UTF-8?q?=E5=89=8D=E7=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **逐处加 dark: 前缀的方案在这里必然失败**:约 700 处颜色散在 21 个组件里, 漏一处就是深色下的白底白字,而它不报错、不影响构建、只有肉眼能发现, 且往往只出现在某个不常开的页面。此后每加一个组件都要记得写两遍, 那种约定活不过三次改动。 改法是把颜色下沉到 CSS 变量,深色模式**反转灰阶**。这套代码的灰阶本身 就是语义色阶(white/gray-50 = 表面层次,gray-200/300 = 分隔线, gray-900→400 = 文字主次),反转之后 `bg-white text-gray-900` 自动变成 深色卡片 + 浅色文字。零组件改动,新组件照常写浅色类名也自动适配。 变量存 **RGB 三元组**而非 #hex:代码里有 bg-blue-50/70 这类透明度修饰符, Tailwind 生成 rgb(var(--x) / 0.7),而 rgb(#f9fafb / 0.7) 是无效 CSS —— 那些半透明高亮会静默失效(不报错,只是不透明)。 --- 实测撞了三个必须分离的语义,每一个共用变量就坏: **1. text-white 不能跟 bg-white 走。** `white` 服务两种冲突用途:卡片表面(深色下要变暗)与彩色按钮上的文字 (深色下必须保持浅色)。共用时后者跟着变暗 —— 激活导航项的「收件」在 bg-chrome-700 上只剩 **1.34:1**,几乎消失。拆出 --c-on-accent。 **2. 侧栏与底部导航不能跟 gray 走。** 它们在浅色模式下**本来就是深色的**(深色侧栏配浅色内容区是原本设计)。 并入反转灰阶后深色模式下变成近白色(实测 rgb(243,245,248)),比内容区 (rgb(17,19,24))还亮,整个层次翻过来。独立成 chrome 色阶,深色下只微调、 保持「框架比内容更沉」。 **3. 强调色不能反转。** blue/red 跟着变会让主按钮在深色页面上失去「这是主操作」的视觉重量, 而且白字落在变暗的 blue-600 上对比度掉到 3:1 以下。改成固定值。 --- 顺带修的三处真实对比度不足(实测量出来的,不是猜的): - 待决策橙徽标:orange-500 上白字 2.80:1 → orange-700 5.18:1 (保留橙色语义,不能改成灰 —— 它与未读的红色是两种紧急) - 空状态文案:gray-400 2.43:1 → gray-500。这类文字是**页面上唯一的内容**, 不是次要装饰,读不动等于页面空白 - 列表头计数:同上 --- 主题是**三态**而非开关:system 不是 light 的别名 —— 只给开关的话, 白天设浅色之后晚上系统切深色应用不会跟着变。且只有 pref 为 system 时 才跟随系统,显式选了的人不该因为日落被切换。 index.html 加同步内联脚本消除首帧闪屏:bundle 有 430KB,从 HTML 解析完到 React 挂载之间页面是 body 默认色,深色用户每次刷新都被闪一下白屏。 外链或 defer 都晚于首次绘制。它与 themeStore 共用同一个 localStorage 键 (不一致会导致首帧按 A 键渲染、挂载后按 B 键重渲染,闪一下再变回去)。 body 显式设底色:移动端橡皮筋回弹露出的是 body 背景。 入口两处:侧栏单按钮快速翻转,「我的」页三选一设定偏好。单按钮不足以 表达三态,但只给单按钮的话用户一旦点过就永久脱离「跟随系统」—— 那是个回不去的单向门。 测试:test/theme.test.mjs 20 条结构性断言(已进 npm test), test/manual/theme-verify.mjs 真实渲染对比度验收(遍历可见文本节点算 WCAG 比值,往上找第一个不透明背景)。两种模式各 4 项全过。 --- web/index.html | 31 +++- web/src/App.tsx | 29 +++- web/src/components/AccountPage.tsx | 7 + web/src/components/NarrowNav.tsx | 42 +++++- web/src/components/Sidebar.tsx | 47 ++++-- web/src/components/ThemePicker.tsx | 83 +++++++++++ web/src/components/icons.tsx | 81 +++++++++++ web/src/index.css | 129 ++++++++++++++++ web/src/main.tsx | 6 + web/src/stores/themeStore.ts | 114 +++++++++++++++ web/tailwind.config.js | 135 ++++++++++++++++- web/test/manual/theme-verify.mjs | 151 +++++++++++++++++++ web/test/narrow-layout.test.mjs | 4 +- web/test/theme.test.mjs | 226 +++++++++++++++++++++++++++++ 14 files changed, 1060 insertions(+), 25 deletions(-) create mode 100644 web/src/components/ThemePicker.tsx create mode 100644 web/src/stores/themeStore.ts create mode 100644 web/test/manual/theme-verify.mjs create mode 100644 web/test/theme.test.mjs diff --git a/web/index.html b/web/index.html index 50efb42..fd4b893 100644 --- a/web/index.html +++ b/web/index.html @@ -2,8 +2,37 @@ - + AgentMail +
diff --git a/web/src/App.tsx b/web/src/App.tsx index 830ea8a..f05a21a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { Suspense, lazy, useEffect } from 'react'; import { connectSSE } from './api/sse'; import { useAuthStore } from './stores/authStore'; import { useMailStore } from './stores/mailStore'; @@ -7,6 +7,7 @@ import { useContactStore } from './stores/contactStore'; import { useUIStore } from './stores/uiStore'; import Sidebar from './components/Sidebar'; import MailList from './components/MailList'; +import PermissionList from './components/PermissionList'; import ContactPanel from './components/ContactPanel'; import MailView from './components/MailView'; import ComposePage from './components/ComposePage'; @@ -14,6 +15,13 @@ import LoginPage from './components/LoginPage'; import SetupPage from './components/SetupPage'; import AccountPage from './components/AccountPage'; import AdminUsersPage from './components/AdminUsersPage'; +// 日历懒加载。 +// +// 它传递依赖 lunar-javascript(node_modules 里 588KB)—— 全量打进主 chunk +// 会让**登录页**也背上这份体积(实测主 bundle 447KB → 751KB, +// gzip 133 → 239KB)。农历表是一大张查找数据,压不动也没必要提前拉: +// 大多数会话根本不打开日历。 +const CalendarView = lazy(() => import('./components/CalendarView')); import NarrowNav from './components/NarrowNav'; import NarrowStack from './components/NarrowStack'; import { useIsNarrow } from './hooks/useIsNarrow'; @@ -115,16 +123,24 @@ export default function App() { ) : viewMode === 'account' ? ( + ) : viewMode === 'calendar' ? ( + // 日历自己在内部分两栏(网格 + 当日日程/编辑器),因此走 main 分支 + // 而不是复用外层的 list/main —— 它的「列表」是网格,不是条目清单。 + }> + + ) : viewMode === 'admin' && user?.role === 'admin' ? ( ) : ( ); - // 列表(中栏):仅收发件箱与联系人视图有 + // 列表(中栏):收发件箱、授权、联系人视图有 const list = viewMode === 'contacts' ? ( + ) : viewMode === 'permissions' ? ( + ) : viewMode === 'inbox' || viewMode === 'sent' ? ( ) : null; @@ -161,6 +177,15 @@ export default function App() { ); } +/** 懒加载 chunk 到达前的占位。占满主区域,避免布局跳动。 */ +function PaneLoading() { + return ( +
+

加载中

+
+ ); +} + /** * 窄屏外壳:内容区 + 底部导航。 * diff --git a/web/src/components/AccountPage.tsx b/web/src/components/AccountPage.tsx index 170d3d4..9fb47ab 100644 --- a/web/src/components/AccountPage.tsx +++ b/web/src/components/AccountPage.tsx @@ -3,6 +3,7 @@ import { useAuthStore } from '../stores/authStore'; import * as api from '../api/client'; import { LockIcon, LogoutIcon } from './icons'; import KeyPanel from './KeyPanel'; +import ThemePicker from './ThemePicker'; /** 当前用户个人中心:查看资料、修改密码、管理客户端连接密钥 */ export default function AccountPage() { @@ -211,6 +212,12 @@ export default function AccountPage() { /> + {/* 外观。放在密钥之后、退出之前:它是一个高频且完全可逆的偏好, + 与「账号自身」的密码/密钥属于不同性质,但同样是「我的设置」。 */} +
+ +
+ {/* 退出登录。 放在这里而不是导航里:它是一个低频且不可逆的动作, 与密码、密钥同属「账号自身」。窄屏下这也是唯一的退出口: diff --git a/web/src/components/NarrowNav.tsx b/web/src/components/NarrowNav.tsx index f1c3ab3..ad1bd3a 100644 --- a/web/src/components/NarrowNav.tsx +++ b/web/src/components/NarrowNav.tsx @@ -2,8 +2,18 @@ import { useUIStore, type ViewMode } from '../stores/uiStore'; import { useMailStore } from '../stores/mailStore'; import { useContactStore } from '../stores/contactStore'; import { useAuthStore } from '../stores/authStore'; -import { InboxIcon, SentIcon, ContactsIcon, ComposeIcon, UsersIcon, PersonIcon } from './icons'; +import { + InboxIcon, + SentIcon, + ContactsIcon, + ComposeIcon, + UsersIcon, + PersonIcon, + ShieldIcon, + CalendarIcon +} from './icons'; import { ConnectionIndicator } from './ConnectionIndicator'; +import { countPendingPermissions, splitByPermission } from '../lib/mailGroups'; /** * 窄屏底部导航。 @@ -22,7 +32,9 @@ const items: { adminOnly?: boolean; }[] = [ { short: '收件', mode: 'inbox', Icon: InboxIcon }, + { short: '授权', mode: 'permissions', Icon: ShieldIcon }, { short: '发件', mode: 'sent', Icon: SentIcon }, + { short: '日历', mode: 'calendar', Icon: CalendarIcon }, { short: '联系人', mode: 'contacts', Icon: ContactsIcon }, { short: '管理', mode: 'admin', Icon: UsersIcon, adminOnly: true } ]; @@ -35,7 +47,10 @@ export default function NarrowNav() { const narrowPane = useUIStore(s => s.narrowPane); const inbox = useMailStore(s => s.inbox); - const unread = inbox.filter(m => m.status === 'unread').length; + // 与 Sidebar 同一判据:权限请求归「授权」,不算进收件箱未读 + const { normal, permissions } = splitByPermission(inbox); + const unread = normal.filter(m => m.status === 'unread').length; + const pendingPerms = countPendingPermissions(permissions); const contacts = useContactStore(s => s.contacts); const user = useAuthStore(s => s.user); @@ -45,7 +60,7 @@ export default function NarrowNav() { return (