From 76ce201c3a8778e23098e52ef1f457293425f3d8 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 10:35:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(webui):=20=E5=AF=BC=E8=88=AA=E5=90=88?= =?UTF-8?q?=E5=B9=B6=20+=20=E6=82=AC=E6=B5=AE=E7=8E=BB=E7=92=83=20+=20?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E5=8A=A8=E7=94=BB=20+=20=E6=8E=A7=E4=BB=B6?= =?UTF-8?q?=E6=A1=A3=E9=80=8F=E6=98=8E=E5=BA=A6=20+=20=E5=9C=86=E8=A7=92?= =?UTF-8?q?=E6=97=A0=E6=9D=A1=E4=BB=B6=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户四封信(同一线索)提的六件事,都在这里: ## ① 导航合并(「导航栏的内容有点多了」) 收件箱/发件箱/授权 → 一项「通信」,进去由**内部页签**区分(CommTabs); 新建 → 「通信」页内**悬浮圆形加号**(ComposeFab);导航只剩 通信/日历/联系人; 管理 → 合进「我的」,管理员在页面**最下面**看得到(非管理员不渲染,不是禁用)。 `viewMode` 仍是原来那三个值(几十处调用点不用改),新增 `commTab` 记住上次用的子页签; 通信项的徽标 = 未读 + 待决策(授权信息不能因为合并而消失)。 ## ② 窄屏底部导航:悬浮玻璃(「还是固定底部延伸,没有玻璃效果,也没有悬浮」) 原来是 `border-t bg-chrome-900` 通栏贴底。改成:左右/离底各留 10px、圆角、 深色半透明 + `blur(18px)`、投影,安全区并入下外边距。 ## ③ 页面动画(「页面极度缺少动画,所有页面都是直接出现」) 在 `` 上挂一个短命类触发外壳直接子面板的入场动画。**没有用 key 重挂载**: 面板是 flex 链上的一环,套 wrapper 会改掉 flex 传递(窄屏覆盖层最先坏)。 并且尊重 `prefers-reduced-motion`(关了就一点都不动)。 ## ④ 控件档透明度(「复选框和地址猜测项…他们才是真正需要拉低透明度的地方」) 新增第三档 `--bg-glass-control`(0.5/0.55):授权多选胶囊、地址建议菜单。 三档递减:正文面 0.88 > 嵌套 0.82 > 控件 0.5。 ## ⑤ 打底 vs 透明(上一封「该打底的地方透了、该透的空白处糊了」) 正文面回到 0.88(读得清优先)、空白/页面底透明**且不模糊**、面板模糊降到 10px。 ## ⑥ 圆角与玻璃**无条件**生效(「大面积缺失圆角与玻璃效果…都是硬截断」) 根因:`.app-shell` 的留缝/圆角/投影原先全写在 `html[data-bg='on']` 里 ⇒ **没开壁纸的账号**看到的是硬边不透明面板。现在几何下沉为无条件基线, 壁纸相关的加强仍叠加在上面。窄屏内容面板同样浮起来。 ## 判据 - `test/nav-merge.test.mjs` 8 条(含自检:重构前的写法必须判红)—— 改完跑老套件 254 条**全绿却一条都没测导航结构**,结构改动必须自带判据。 - `test/manual/nav-restructure-verify.mjs`:真浏览器 9 条(导航项数、页签切换真的换内容、 加号 56×56 且 `elementFromPoint` 命中自己、我的页底部管理入口在退出登录之后、 控件档 α=0.5 对正文面 α=0.88 的反向对照)。 - `test/manual/narrow-glass-verify.mjs` 6 条:悬浮几何(三边留缝 10px、圆角 14px、 缝隙处命中的是页面底 ⇒ 真的浮着)、玻璃(α=0.82 + blur18)、触摸高度最小 49px、 动画真的在跑(静止时 `animationName=none`,切换后 =pane-in)且 reduced-motion 下不动。 - `test/background.test.mjs`:把三条**过时判据**(早前那版"越透越好")按现行契约重写 —— 旧判据留着只会把我拽回错误方向。30 条全绿。 ## 顺带 窄屏探针原先只调视口、**没模拟触屏**,于是 `(hover: hover)` 仍为真, 把「悬停才显形的次要动作」误报成透明 —— 差点去"修"一个本来就对的规则。 已改成 hasTouch + isMobile + DPR 的触屏上下文,且收尾只关自己的 context (CDP 连的是共享 Chromium,`browser.close()` 会把别人的标签页一起关掉)。 --- client/electron/src/App.tsx | 43 +++- .../electron/src/components/AccountPage.tsx | 26 ++- .../electron/src/components/AddressInput.tsx | 2 +- client/electron/src/components/CommTabs.tsx | 94 ++++++++ client/electron/src/components/MailView.tsx | 2 +- client/electron/src/components/NarrowNav.tsx | 70 +++--- client/electron/src/components/Sidebar.tsx | 83 ++++--- client/electron/src/index.css | 166 +++++++++++++- client/electron/src/stores/uiStore.ts | 30 ++- client/electron/test/background.test.mjs | 43 +++- .../test/manual/narrow-glass-verify.mjs | 120 +++++++++++ .../test/manual/narrow-probe-helper.mjs | 20 +- client/electron/test/manual/narrow-verify.mjs | 7 +- .../test/manual/nav-restructure-verify.mjs | 204 ++++++++++++++++++ client/electron/test/nav-merge.test.mjs | 108 ++++++++++ 15 files changed, 911 insertions(+), 107 deletions(-) create mode 100644 client/electron/src/components/CommTabs.tsx create mode 100644 client/electron/test/manual/narrow-glass-verify.mjs create mode 100644 client/electron/test/manual/nav-restructure-verify.mjs create mode 100644 client/electron/test/nav-merge.test.mjs diff --git a/client/electron/src/App.tsx b/client/electron/src/App.tsx index cf04c23..882a652 100644 --- a/client/electron/src/App.tsx +++ b/client/electron/src/App.tsx @@ -9,6 +9,7 @@ import { useSessionStore } from './stores/sessionStore'; import { useContactStore } from './stores/contactStore'; import { useUIStore } from './stores/uiStore'; import Sidebar from './components/Sidebar'; +import CommTabs, { ComposeFab } from './components/CommTabs'; import MailList from './components/MailList'; import PermissionList from './components/PermissionList'; import ContactPanel from './components/ContactPanel'; @@ -39,6 +40,26 @@ export default function App() { const resetUI = useUIStore(s => s.reset); const narrowPane = useUIStore(s => s.narrowPane); const narrow = useIsNarrow(); + const commTab = useUIStore(s => s.commTab); + + /* + * 视图切换动画的触发器(2026-09-14 用户:「页面极度缺少动画,所有页面都是直接出现」)。 + * + * 为什么不用 key 重挂载:面板是 flex 链上的一环,套 wrapper 会改变 flex 传递, + * 窄屏覆盖层最先坏。这里改成在外壳上挂一个短命类,由 CSS 给直接子面板加一次 + * 入场动画 —— DOM 结构一个字节都不动。 + * + * 必须先 remove + 强制重排再 add:同一个类连着挂两次,浏览器不会重放动画。 + */ + useEffect(() => { + const root = document.documentElement; + root.classList.remove('view-switch'); + // 读一次布局,强制样式失效(否则 remove/add 在同一帧里会被合并,动画不重放) + void root.offsetWidth; + root.classList.add('view-switch'); + const t = window.setTimeout(() => root.classList.remove('view-switch'), 400); + return () => window.clearTimeout(t); + }, [viewMode, commTab, narrowPane, composing]); const fetchInbox = useMailStore(s => s.fetchInbox); const fetchSent = useMailStore(s => s.fetchSent); @@ -156,7 +177,8 @@ export default function App() { ); // 列表(中栏):收发件箱、授权、联系人视图有 - const list = + const isComm = viewMode === 'inbox' || viewMode === 'sent' || viewMode === 'permissions'; + const listBody = viewMode === 'contacts' ? ( ) : viewMode === 'permissions' ? ( @@ -165,6 +187,23 @@ export default function App() { ) : null; + /* + * 「通信」的内部导航(收件箱 / 发件箱 / 授权)与悬浮加号都挂在中栏上。 + * + * 放在这里而不是各个子页面里,是为了"只有一套":三个页签各写一遍的话, + * 切换时的选中态、徽标口径迟早会分叉。 + * 外层 `relative` 是给悬浮加号定位用的 —— 窄屏时它正好落在底部导航之上。 + */ + const list = isComm && listBody ? ( +
+ + {listBody} + +
+ ) : ( + listBody + ); + // 账号/管理页没有列表栏,窄屏下要直接显示主区域, // 否则会出现一片空白(列表为 null 而 narrowPane 还停在 'list') const hasList = list !== null; @@ -218,7 +257,7 @@ function PaneLoading() { */ function NarrowShell({ children }: { children: React.ReactNode }) { return ( -
+
{children}
diff --git a/client/electron/src/components/AccountPage.tsx b/client/electron/src/components/AccountPage.tsx index 75bbea3..35427a0 100644 --- a/client/electron/src/components/AccountPage.tsx +++ b/client/electron/src/components/AccountPage.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useState } from 'react'; import { useAuthStore } from '../stores/authStore'; import * as api from '../api/client'; -import { LockIcon, LogoutIcon } from './icons'; +import { LockIcon, LogoutIcon, UsersIcon } from './icons'; +import { useUIStore } from '../stores/uiStore'; import KeyPanel from './KeyPanel'; import AccountList from './AccountList'; import ThemePicker from './ThemePicker'; @@ -11,6 +12,7 @@ import BackgroundPicker from './BackgroundPicker'; export default function AccountPage() { const user = useAuthStore(s => s.user); const logout = useAuthStore(s => s.logout); + const setViewMode = useUIStore(s => s.setViewMode); const [oldPw, setOldPw] = useState(''); const [newPw, setNewPw] = useState(''); const [confirmPw, setConfirmPw] = useState(''); @@ -240,6 +242,28 @@ export default function AccountPage() { 退出登录 + + {/* + 管理入口(仅管理员可见)—— 用户:「将管理和我的合并,管理员视角我的页面 + 拉到最下面有一个管理」。 + 合并的理由与通信一致:导航栏只留日常动作,低频/高危的入口下沉到「我的」。 + 非管理员看不到这一整块(不是禁用,是不渲染 —— 免得让人以为"我缺权限")。 + */} + {user?.role === 'admin' && ( +
+

管理

+ +

+ 创建/停用账号、查看配额与密钥。只有管理员看得到这一栏。 +

+
+ )}
diff --git a/client/electron/src/components/AddressInput.tsx b/client/electron/src/components/AddressInput.tsx index fc0eca2..6b46f16 100644 --- a/client/electron/src/components/AddressInput.tsx +++ b/client/electron/src/components/AddressInput.tsx @@ -181,7 +181,7 @@ export default function AddressInput({ {open && items.length > 0 && (
s.commTab); + const setCommTab = useUIStore(s => s.setCommTab); + const inbox = useMailStore(s => s.inbox); + + const { normal, permissions } = splitByPermission(inbox); + const unread = normal.filter(m => m.status === 'unread').length; + const pendingPerms = countPendingPermissions(permissions); + + return ( +
+ {TABS.map(({ key, label }) => { + const on = commTab === key; + const badge = key === 'inbox' ? unread : key === 'permissions' ? pendingPerms : 0; + return ( + + ); + })} +
+ ); +} + +/** + * 悬浮的圆形加号 —— 「新建」从导航栏搬到这里。 + * + * 放在列表栏的右下角(`absolute`),窄屏时正好在底部导航之上;宽屏时在列表栏内悬浮。 + * 用圆形而不是胶囊:用户点名要"悬浮的圆形加号",且圆形在窄屏下不会挤压页签。 + */ +export function ComposeFab() { + const startCompose = useUIStore(s => s.startCompose); + const composing = useUIStore(s => s.composing); + if (composing) return null; + return ( + + ); +} diff --git a/client/electron/src/components/MailView.tsx b/client/electron/src/components/MailView.tsx index 327d24c..2c2e786 100644 --- a/client/electron/src/components/MailView.tsx +++ b/client/electron/src/components/MailView.tsx @@ -697,7 +697,7 @@ export function PermissionPanel({ mail }: { mail: Mail }) { className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border transition-colors disabled:opacity-40 ${ on ? 'bg-blue-700 text-white border-blue-700 hover:bg-blue-800' - : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50' + : 'glass-control text-gray-700 border-gray-300 hover:bg-gray-50' }`} > {on && } diff --git a/client/electron/src/components/NarrowNav.tsx b/client/electron/src/components/NarrowNav.tsx index 756cb8b..1b17961 100644 --- a/client/electron/src/components/NarrowNav.tsx +++ b/client/electron/src/components/NarrowNav.tsx @@ -4,12 +4,8 @@ import { useContactStore } from '../stores/contactStore'; import { useAuthStore } from '../stores/authStore'; import { InboxIcon, - SentIcon, ContactsIcon, - ComposeIcon, - UsersIcon, PersonIcon, - ShieldIcon, CalendarIcon } from './icons'; import { ConnectionIndicator } from './ConnectionIndicator'; @@ -28,22 +24,22 @@ import { countPendingPermissions, splitByPermission } from '../lib/mailGroups'; const items: { short: string; mode: ViewMode; + /** 哪些 viewMode 算这一项选中(「通信」含三个子页签) */ + modes?: ViewMode[]; Icon: (p: { className?: string }) => JSX.Element; 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 } + // 与桌面侧栏同一套合并(用户:「导航项就只剩通信,日历,联系人」): + // 收件/发件/授权 = 一项「通信」,进去由 CommTabs 区分;管理移到「我的」页底部。 + { short: '通信', mode: 'inbox', modes: ['inbox', 'sent', 'permissions'], Icon: InboxIcon }, + { short: '日历', mode: 'calendar', Icon: CalendarIcon }, + { short: '联系人', mode: 'contacts', Icon: ContactsIcon } ]; export default function NarrowNav() { const viewMode = useUIStore(s => s.viewMode); const setViewMode = useUIStore(s => s.setViewMode); const composing = useUIStore(s => s.composing); - const startCompose = useUIStore(s => s.startCompose); const narrowPane = useUIStore(s => s.narrowPane); const inbox = useMailStore(s => s.inbox); @@ -57,29 +53,32 @@ export default function NarrowNav() { const isAdmin = user?.role === 'admin'; const visible = items.filter(n => !n.adminOnly || isAdmin); + // 点「通信」回到上次用的子页签(与桌面侧栏同一语义) + const commTab = useUIStore(s => s.commTab); return (