feat(webui): 导航合并 + 悬浮玻璃 + 页面动画 + 控件档透明度 + 圆角无条件生效

用户四封信(同一线索)提的六件事,都在这里:

## ① 导航合并(「导航栏的内容有点多了」)

收件箱/发件箱/授权 → 一项「通信」,进去由**内部页签**区分(CommTabs);
新建 → 「通信」页内**悬浮圆形加号**(ComposeFab);导航只剩 通信/日历/联系人;
管理 → 合进「我的」,管理员在页面**最下面**看得到(非管理员不渲染,不是禁用)。

`viewMode` 仍是原来那三个值(几十处调用点不用改),新增 `commTab` 记住上次用的子页签;
通信项的徽标 = 未读 + 待决策(授权信息不能因为合并而消失)。

## ② 窄屏底部导航:悬浮玻璃(「还是固定底部延伸,没有玻璃效果,也没有悬浮」)

原来是 `border-t bg-chrome-900` 通栏贴底。改成:左右/离底各留 10px、圆角、
深色半透明 + `blur(18px)`、投影,安全区并入下外边距。

## ③ 页面动画(「页面极度缺少动画,所有页面都是直接出现」)

在 `<html>` 上挂一个短命类触发外壳直接子面板的入场动画。**没有用 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()` 会把别人的标签页一起关掉)。
This commit is contained in:
2026-09-14 10:35:05 +08:00
parent 552fbc731e
commit 76ce201c3a
15 changed files with 911 additions and 107 deletions

View File

@ -9,6 +9,7 @@ import { useSessionStore } from './stores/sessionStore';
import { useContactStore } from './stores/contactStore'; import { useContactStore } from './stores/contactStore';
import { useUIStore } from './stores/uiStore'; import { useUIStore } from './stores/uiStore';
import Sidebar from './components/Sidebar'; import Sidebar from './components/Sidebar';
import CommTabs, { ComposeFab } from './components/CommTabs';
import MailList from './components/MailList'; import MailList from './components/MailList';
import PermissionList from './components/PermissionList'; import PermissionList from './components/PermissionList';
import ContactPanel from './components/ContactPanel'; import ContactPanel from './components/ContactPanel';
@ -39,6 +40,26 @@ export default function App() {
const resetUI = useUIStore(s => s.reset); const resetUI = useUIStore(s => s.reset);
const narrowPane = useUIStore(s => s.narrowPane); const narrowPane = useUIStore(s => s.narrowPane);
const narrow = useIsNarrow(); 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 fetchInbox = useMailStore(s => s.fetchInbox);
const fetchSent = useMailStore(s => s.fetchSent); 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 === 'contacts' ? (
<ContactPanel /> <ContactPanel />
) : viewMode === 'permissions' ? ( ) : viewMode === 'permissions' ? (
@ -165,6 +187,23 @@ export default function App() {
<MailList /> <MailList />
) : null; ) : null;
/*
* 「通信」的内部导航(收件箱 / 发件箱 / 授权)与悬浮加号都挂在中栏上。
*
* 放在这里而不是各个子页面里,是为了"只有一套":三个页签各写一遍的话,
* 切换时的选中态、徽标口径迟早会分叉。
* 外层 `relative` 是给悬浮加号定位用的 —— 窄屏时它正好落在底部导航之上。
*/
const list = isComm && listBody ? (
<div className="relative flex-1 min-w-0 min-h-0 flex flex-col">
<CommTabs />
{listBody}
<ComposeFab />
</div>
) : (
listBody
);
// 账号/管理页没有列表栏,窄屏下要直接显示主区域, // 账号/管理页没有列表栏,窄屏下要直接显示主区域,
// 否则会出现一片空白(列表为 null 而 narrowPane 还停在 'list' // 否则会出现一片空白(列表为 null 而 narrowPane 还停在 'list'
const hasList = list !== null; const hasList = list !== null;
@ -218,7 +257,7 @@ function PaneLoading() {
*/ */
function NarrowShell({ children }: { children: React.ReactNode }) { function NarrowShell({ children }: { children: React.ReactNode }) {
return ( return (
<div className="h-full flex flex-col bg-gray-50 overflow-hidden safe-frame"> <div className="narrow-shell h-full flex flex-col bg-gray-50 overflow-hidden safe-frame">
{children} {children}
<NarrowNav /> <NarrowNav />
</div> </div>

View File

@ -1,7 +1,8 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useAuthStore } from '../stores/authStore'; import { useAuthStore } from '../stores/authStore';
import * as api from '../api/client'; 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 KeyPanel from './KeyPanel';
import AccountList from './AccountList'; import AccountList from './AccountList';
import ThemePicker from './ThemePicker'; import ThemePicker from './ThemePicker';
@ -11,6 +12,7 @@ import BackgroundPicker from './BackgroundPicker';
export default function AccountPage() { export default function AccountPage() {
const user = useAuthStore(s => s.user); const user = useAuthStore(s => s.user);
const logout = useAuthStore(s => s.logout); const logout = useAuthStore(s => s.logout);
const setViewMode = useUIStore(s => s.setViewMode);
const [oldPw, setOldPw] = useState(''); const [oldPw, setOldPw] = useState('');
const [newPw, setNewPw] = useState(''); const [newPw, setNewPw] = useState('');
const [confirmPw, setConfirmPw] = useState(''); const [confirmPw, setConfirmPw] = useState('');
@ -240,6 +242,28 @@ export default function AccountPage() {
退 退
</button> </button>
</section> </section>
{/*
管理入口(仅管理员可见)—— 用户:「将管理和我的合并,管理员视角我的页面
拉到最下面有一个管理」。
合并的理由与通信一致:导航栏只留日常动作,低频/高危的入口下沉到「我的」。
非管理员看不到这一整块(不是禁用,是不渲染 —— 免得让人以为"我缺权限")。
*/}
{user?.role === 'admin' && (
<section className="border-t border-gray-200 pt-6">
<h3 className="text-xs font-medium text-gray-500 mb-3"></h3>
<button
onClick={() => setViewMode('admin')}
className="tap inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md border border-gray-300 text-gray-700 hover:bg-gray-50 active:bg-gray-100 transition-colors"
>
<UsersIcon className="w-4 h-4" />
</button>
<p className="mt-2 text-2xs text-gray-400">
/
</p>
</section>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -181,7 +181,7 @@ export default function AddressInput({
{open && items.length > 0 && ( {open && items.length > 0 && (
<div <div
className={`absolute z-20 w-full overflow-y-auto bg-white border border-gray-200 rounded-lg shadow-2 ${ className={`absolute z-20 w-full overflow-y-auto glass-control border border-gray-200 rounded-lg shadow-2 ${
menuLayout.flip ? 'bottom-full mb-1' : 'top-full mt-1' menuLayout.flip ? 'bottom-full mb-1' : 'top-full mt-1'
}`} }`}
style={{ maxHeight: menuLayout.maxHeight }} style={{ maxHeight: menuLayout.maxHeight }}

View File

@ -0,0 +1,94 @@
import { countPendingPermissions, splitByPermission } from '../lib/mailGroups';
import { useMailStore } from '../stores/mailStore';
import { type CommTab, useUIStore } from '../stores/uiStore';
import { ComposeIcon } from './icons';
/*
* 「通信」这一个导航项的**内部导航**。
*
* 用户2026-09-14「收件发件授权改为一个导航项通过内部导航区分然后新建作为
* 他们内部的一个悬浮的圆形加号」。
*
* 于是收件箱 / 发件箱 / 授权 三个页签落在这里,而不是导航栏里。
* 页签上带未读/待决策徽标 —— 合并成一项之后,导航栏上看不到"授权有 3 个在等我"了,
* 这个信息不能丢:它是"有人被卡住",比未读更急。
*/
const TABS: { key: CommTab; label: string }[] = [
{ key: 'inbox', label: '收件箱' },
{ key: 'sent', label: '发件箱' },
{ key: 'permissions', label: '授权' }
];
export default function CommTabs() {
const commTab = useUIStore(s => 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 (
<div
data-testid="comm-tabs"
className="shrink-0 flex items-stretch gap-0.5 px-1.5 pt-1.5 border-b border-gray-200"
role="tablist"
aria-label="通信"
>
{TABS.map(({ key, label }) => {
const on = commTab === key;
const badge = key === 'inbox' ? unread : key === 'permissions' ? pendingPerms : 0;
return (
<button
key={key}
type="button"
role="tab"
aria-selected={on}
onClick={() => setCommTab(key)}
className={`relative px-3 py-1.5 text-xs font-medium rounded-t-md transition-colors ${
on
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-500 hover:text-gray-800 hover:bg-white/60'
}`}
>
{label}
{badge > 0 && (
<span
className={`ml-1.5 min-w-[16px] h-[16px] px-1 rounded-full text-3xs font-bold inline-flex items-center justify-center align-middle ${
key === 'permissions' ? 'bg-orange-700 text-white' : 'bg-red-600 text-white'
}`}
>
{badge > 99 ? '99+' : badge}
</span>
)}
</button>
);
})}
</div>
);
}
/**
* 悬浮的圆形加号 —— 「新建」从导航栏搬到这里。
*
* 放在列表栏的右下角(`absolute`),窄屏时正好在底部导航之上;宽屏时在列表栏内悬浮。
* 用圆形而不是胶囊:用户点名要"悬浮的圆形加号",且圆形在窄屏下不会挤压页签。
*/
export function ComposeFab() {
const startCompose = useUIStore(s => s.startCompose);
const composing = useUIStore(s => s.composing);
if (composing) return null;
return (
<button
type="button"
data-testid="compose-fab"
onClick={() => startCompose()}
title="新建邮件"
aria-label="新建邮件"
className="absolute bottom-4 right-4 z-10 w-14 h-14 rounded-full bg-blue-600 text-white shadow-lg flex items-center justify-center hover:bg-blue-700 active:bg-blue-800 transition-colors"
>
<ComposeIcon className="w-6 h-6" />
</button>
);
}

View File

@ -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 ${ 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 on
? 'bg-blue-700 text-white border-blue-700 hover:bg-blue-800' ? '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 && <CheckIcon className="w-3.5 h-3.5" />} {on && <CheckIcon className="w-3.5 h-3.5" />}

View File

@ -4,12 +4,8 @@ import { useContactStore } from '../stores/contactStore';
import { useAuthStore } from '../stores/authStore'; import { useAuthStore } from '../stores/authStore';
import { import {
InboxIcon, InboxIcon,
SentIcon,
ContactsIcon, ContactsIcon,
ComposeIcon,
UsersIcon,
PersonIcon, PersonIcon,
ShieldIcon,
CalendarIcon CalendarIcon
} from './icons'; } from './icons';
import { ConnectionIndicator } from './ConnectionIndicator'; import { ConnectionIndicator } from './ConnectionIndicator';
@ -28,22 +24,22 @@ import { countPendingPermissions, splitByPermission } from '../lib/mailGroups';
const items: { const items: {
short: string; short: string;
mode: ViewMode; mode: ViewMode;
/** 哪些 viewMode 算这一项选中(「通信」含三个子页签) */
modes?: ViewMode[];
Icon: (p: { className?: string }) => JSX.Element; Icon: (p: { className?: string }) => JSX.Element;
adminOnly?: boolean; adminOnly?: boolean;
}[] = [ }[] = [
{ short: '收件', mode: 'inbox', Icon: InboxIcon }, // 与桌面侧栏同一套合并(用户:「导航项就只剩通信,日历,联系人」):
{ short: '授权', mode: 'permissions', Icon: ShieldIcon }, // 收件/发件/授权 = 一项「通信」,进去由 CommTabs 区分;管理移到「我的」页底部。
{ short: '发件', mode: 'sent', Icon: SentIcon }, { short: '通信', mode: 'inbox', modes: ['inbox', 'sent', 'permissions'], Icon: InboxIcon },
{ short: '日历', mode: 'calendar', Icon: CalendarIcon }, { short: '日历', mode: 'calendar', Icon: CalendarIcon },
{ short: '联系人', mode: 'contacts', Icon: ContactsIcon }, { short: '联系人', mode: 'contacts', Icon: ContactsIcon }
{ short: '管理', mode: 'admin', Icon: UsersIcon, adminOnly: true }
]; ];
export default function NarrowNav() { export default function NarrowNav() {
const viewMode = useUIStore(s => s.viewMode); const viewMode = useUIStore(s => s.viewMode);
const setViewMode = useUIStore(s => s.setViewMode); const setViewMode = useUIStore(s => s.setViewMode);
const composing = useUIStore(s => s.composing); const composing = useUIStore(s => s.composing);
const startCompose = useUIStore(s => s.startCompose);
const narrowPane = useUIStore(s => s.narrowPane); const narrowPane = useUIStore(s => s.narrowPane);
const inbox = useMailStore(s => s.inbox); const inbox = useMailStore(s => s.inbox);
@ -57,29 +53,32 @@ export default function NarrowNav() {
const isAdmin = user?.role === 'admin'; const isAdmin = user?.role === 'admin';
const visible = items.filter(n => !n.adminOnly || isAdmin); const visible = items.filter(n => !n.adminOnly || isAdmin);
// 点「通信」回到上次用的子页签(与桌面侧栏同一语义)
const commTab = useUIStore(s => s.commTab);
return ( return (
<nav <nav
className="narrow-nav shrink-0 border-t border-chrome-700 bg-chrome-900 flex items-stretch" // 悬浮玻璃条的样式全在 index.css 的 .narrow-nav 里(含安全区外边距):
// 底部安全区iPhone 的手势条会盖住最后一排 // 这里不再写 border-t/bg-chrome-900 —— 那是"贴底通栏"的写法,与悬浮冲突
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }} className="narrow-nav shrink-0 flex items-stretch"
> >
{visible.map(({ short, mode, Icon }) => { {visible.map(({ short, mode, modes, Icon }) => {
// 详情栏打开时不高亮任何导航项:此刻用户看的是某封邮件, const itemModes = modes ?? [mode];
// 高亮「收件」会让人以为点它能回到列表(其实是同一项) // 详情栏打开时不高亮任何导航项:此刻用户看的是某封邮件
const active = viewMode === mode && !composing && narrowPane === 'list'; const active =
const badge = itemModes.includes(viewMode) && !composing && narrowPane === 'list';
mode === 'inbox' // 「通信」合并了三项:徽标 = 未读 + 待决策(授权信息不能因为合并而消失)
? unread const isComm = itemModes.includes('inbox');
: mode === 'permissions' const badge = isComm
? pendingPerms ? unread + pendingPerms
: mode === 'contacts' : itemModes.includes('contacts')
? contacts.length ? contacts.length
: 0; : 0;
const badgeTone = isComm && pendingPerms > 0 ? 'perm' : isComm ? 'unread' : 'plain';
return ( return (
<button <button
key={mode} key={short}
onClick={() => setViewMode(mode)} onClick={() => setViewMode(isComm ? commTab : mode)}
className={`relative flex-1 py-2 flex flex-col items-center justify-center gap-0.5 transition-colors ${ className={`relative flex-1 py-2 flex flex-col items-center justify-center gap-0.5 transition-colors ${
active ? 'text-white' : 'text-chrome-400 active:bg-chrome-800' active ? 'text-white' : 'text-chrome-400 active:bg-chrome-800'
}`} }`}
@ -89,9 +88,9 @@ export default function NarrowNav() {
{badge > 0 && ( {badge > 0 && (
<span <span
className={`absolute top-1 right-[22%] min-w-[15px] h-[15px] px-1 rounded-full text-3xs font-bold flex items-center justify-center ${ className={`absolute top-1 right-[22%] min-w-[15px] h-[15px] px-1 rounded-full text-3xs font-bold flex items-center justify-center ${
mode === 'inbox' badgeTone === 'unread'
? 'bg-red-600 text-white' ? 'bg-red-600 text-white'
: mode === 'permissions' : badgeTone === 'perm'
? 'bg-orange-700 text-white' ? 'bg-orange-700 text-white'
: 'bg-chrome-600 text-chrome-100' : 'bg-chrome-600 text-chrome-100'
}`} }`}
@ -103,16 +102,7 @@ export default function NarrowNav() {
</button> </button>
); );
})} })}
{/* 新建改到列表栏的悬浮圆钮ComposeFab与桌面一致 */}
<button
onClick={() => startCompose()}
className={`flex-1 py-2 flex flex-col items-center justify-center gap-0.5 ${
composing ? 'text-blue-300' : 'text-blue-400 active:bg-chrome-800'
}`}
>
<ComposeIcon />
<span className="text-3xs leading-none"></span>
</button>
<button <button
onClick={() => setViewMode('account')} onClick={() => setViewMode('account')}

View File

@ -4,12 +4,8 @@ import { useContactStore } from '../stores/contactStore';
import { useAuthStore } from '../stores/authStore'; import { useAuthStore } from '../stores/authStore';
import { import {
InboxIcon, InboxIcon,
SentIcon,
ContactsIcon, ContactsIcon,
ComposeIcon,
UsersIcon,
LogoutIcon, LogoutIcon,
ShieldIcon,
CalendarIcon, CalendarIcon,
BrandMarkIcon BrandMarkIcon
} from './icons'; } from './icons';
@ -17,29 +13,39 @@ import { ConnectionIndicator } from './ConnectionIndicator';
import { ThemeToggleButton } from './ThemePicker'; import { ThemeToggleButton } from './ThemePicker';
import { countPendingPermissions, splitByPermission } from '../lib/mailGroups'; import { countPendingPermissions, splitByPermission } from '../lib/mailGroups';
/*
* 导航项只有三项2026-09-14 用户:「导航栏的内容有点多了…这样导航项就只剩
* 通信,日历,联系人」)。
*
* 收件箱 / 发件箱 / 授权合并成一项「通信」进去之后由内部页签CommTabs区分
* 「管理」不再单列 —— 它在「我的」页面最下面(管理员才看得到)。
*
* `modes` 是"哪些 viewMode 算这一项处于选中态"`target` 是"点它去哪"
* 通信要回到上次用的那个子页签,而不是永远回收件箱。
*/
const navItems: { const navItems: {
short: string; short: string;
title: string; title: string;
mode: ViewMode; modes: ViewMode[];
target?: ViewMode;
Icon: (p: { className?: string }) => JSX.Element; Icon: (p: { className?: string }) => JSX.Element;
adminOnly?: boolean;
}[] = [ }[] = [
{ short: '收件', title: '收件箱', mode: 'inbox', Icon: InboxIcon }, {
// 授权紧跟收件箱:它是收件箱的「要动手」那一半,放在联系人之后会让人找不到 short: '通信',
{ short: '授权', title: '授权请求', mode: 'permissions', Icon: ShieldIcon }, title: '通信(收件箱 / 发件箱 / 授权)',
{ short: '发件', title: '发件箱', mode: 'sent', Icon: SentIcon }, modes: ['inbox', 'sent', 'permissions'],
Icon: InboxIcon
},
// 日历排在联系人之前:它是「我要安排什么」,与收发信同属日常动作; // 日历排在联系人之前:它是「我要安排什么」,与收发信同属日常动作;
// 联系人是「查谁在哪」,用得少 // 联系人是「查谁在哪」,用得少
{ short: '日历', title: '日历', mode: 'calendar', Icon: CalendarIcon }, { short: '日历', title: '日历', modes: ['calendar'], Icon: CalendarIcon },
{ short: '联系', title: '联系人', mode: 'contacts', Icon: ContactsIcon }, { short: '联系', title: '联系人', modes: ['contacts'], Icon: ContactsIcon }
{ short: '用户', title: '用户管理', mode: 'admin', Icon: UsersIcon, adminOnly: true }
]; ];
export default function Sidebar() { export default function Sidebar() {
const viewMode = useUIStore(s => s.viewMode); const viewMode = useUIStore(s => s.viewMode);
const setViewMode = useUIStore(s => s.setViewMode); const setViewMode = useUIStore(s => s.setViewMode);
const composing = useUIStore(s => s.composing); const composing = useUIStore(s => s.composing);
const startCompose = useUIStore(s => s.startCompose);
const inbox = useMailStore(s => s.inbox); const inbox = useMailStore(s => s.inbox);
// 收件箱的未读数只算普通邮件:权限请求归「授权」那一项, // 收件箱的未读数只算普通邮件:权限请求归「授权」那一项,
@ -50,8 +56,9 @@ export default function Sidebar() {
const contacts = useContactStore(s => s.contacts); const contacts = useContactStore(s => s.contacts);
const user = useAuthStore(s => s.user); const user = useAuthStore(s => s.user);
// 点「通信」时回到上次用的子页签(见 uiStore.commTab
const commTab = useUIStore(s => s.commTab);
const logout = useAuthStore(s => s.logout); const logout = useAuthStore(s => s.logout);
const isAdmin = user?.role === 'admin';
return ( return (
<div className="w-[60px] h-full shrink-0 flex flex-col items-center py-3 gap-1 bg-chrome-900" <div className="w-[60px] h-full shrink-0 flex flex-col items-center py-3 gap-1 bg-chrome-900"
@ -75,22 +82,21 @@ export default function Sidebar() {
<BrandMarkIcon className="w-5 h-5" /> <BrandMarkIcon className="w-5 h-5" />
</button> </button>
{navItems {navItems.map(({ short, title, modes, target, Icon }) => {
.filter(n => !n.adminOnly || isAdmin) const active = modes.includes(viewMode) && !composing;
.map(({ short, title, mode, Icon }) => { // 「通信」把两类"要动手"合起来显示;有人的授权在等我时用橙色优先提示,
const active = viewMode === mode && !composing; // 因为那是有人被卡住,未读只是"还没看"。
const badge = const isComm = modes.includes('inbox');
mode === 'inbox' const badge = isComm
? unread ? unread + pendingPerms
: mode === 'permissions' : modes.includes('contacts')
? pendingPerms
: mode === 'contacts'
? contacts.length ? contacts.length
: 0; : 0;
return ( const badgeTone = isComm && pendingPerms > 0 ? 'perm' : isComm ? 'unread' : 'plain';
<button return (
key={mode} <button
onClick={() => setViewMode(mode)} key={short}
onClick={() => setViewMode(target || commTab || modes[0])}
title={title} title={title}
className={`relative w-12 h-12 rounded-lg flex flex-col items-center justify-center gap-0.5 transition-colors ${ className={`relative w-12 h-12 rounded-lg flex flex-col items-center justify-center gap-0.5 transition-colors ${
active active
@ -103,9 +109,9 @@ export default function Sidebar() {
{badge > 0 && ( {badge > 0 && (
<span <span
className={`absolute top-0.5 right-1 min-w-[15px] h-[15px] px-1 rounded-full text-3xs font-bold flex items-center justify-center ${ className={`absolute top-0.5 right-1 min-w-[15px] h-[15px] px-1 rounded-full text-3xs font-bold flex items-center justify-center ${
mode === 'inbox' badgeTone === 'unread'
? 'bg-red-600 text-white' ? 'bg-red-600 text-white'
: mode === 'permissions' : badgeTone === 'perm'
? // 待决策的授权用橙色:它跟未读不是一类紧急 —— ? // 待决策的授权用橙色:它跟未读不是一类紧急 ——
// 未读是「有内容没看」,待决策是「有 Agent 卡在那儿等我」 // 未读是「有内容没看」,待决策是「有 Agent 卡在那儿等我」
'bg-orange-700 text-white' 'bg-orange-700 text-white'
@ -120,17 +126,10 @@ export default function Sidebar() {
})} })}
<div className="flex-1" /> <div className="flex-1" />
{/*
<button 这里原本是「新建」导航项。用户要求把它变成「通信」页内悬浮的圆形加号
onClick={() => startCompose()} (见 App 里的 ComposeFab导航栏就只剩三项。
title="新建邮件" */}
className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center gap-0.5 text-white transition-colors ${
composing ? 'bg-blue-700 ring-2 ring-blue-300' : 'bg-blue-600 hover:bg-blue-700'
}`}
>
<ComposeIcon />
<span className="text-3xs leading-none"></span>
</button>
<div className="mt-2 pt-2 w-full flex flex-col items-center gap-1 border-t border-chrome-700"> <div className="mt-2 pt-2 w-full flex flex-col items-center gap-1 border-t border-chrome-700">
<button <button

View File

@ -228,7 +228,7 @@
--bg-dim: 12%; --bg-dim: 12%;
--bg-blur: 4px; --bg-blur: 4px;
/* 玻璃面板的不透明度:背景越花,面板需要越实才读得动。 */ /* 玻璃面板的不透明度:背景越花,面板需要越实才读得动。 */
--bg-glass: 0.45; --bg-glass: 0.88;
/* /*
* 嵌套面板那一层的不透明度。 * 嵌套面板那一层的不透明度。
* *
@ -240,9 +240,18 @@
* 原则是「玻璃只该出现一次」:最外层负责遮罩与模糊,里面几层只留一点点 * 原则是「玻璃只该出现一次」:最外层负责遮罩与模糊,里面几层只留一点点
* 色调(保住正文对比度),第三层起直接透明(见下面 data-bg='on' 的规则)。 * 色调(保住正文对比度),第三层起直接透明(见下面 data-bg='on' 的规则)。
*/ */
--bg-glass-inner: 0.22; --bg-glass-inner: 0.82;
/*
* 控件级透度:复选框、选项胶囊、地址建议菜单这类**小控件**。
*
* 用户第三轮的原话:「复选框和地址猜测项的透明度问题还没改,他们才是真正需要
* 拉低透明度的地方」。也就是说:承载正文的大面必须够实(读得清),
* 而这些小块本身没有大段文字,透一点反而好看 —— 壁纸透过它们,界面才有层次。
* 三档从实到透:正文面(--bg-glass) > 嵌套卡(--bg-glass-inner) > 控件(--bg-glass-control)。
*/
--bg-glass-control: 0.5;
/* 面板的朦胧感(与壁纸自身的 --bg-blur 分开:那层给照片打底,这层给面板) */ /* 面板的朦胧感(与壁纸自身的 --bg-blur 分开:那层给照片打底,这层给面板) */
--bg-blur-panel: 18px; --bg-blur-panel: 10px;
/* 浮动面板之间的缝隙:壁纸从缝隙里露出来,是圆角化玻璃化的关键 */ /* 浮动面板之间的缝隙:壁纸从缝隙里露出来,是圆角化玻璃化的关键 */
--pane-gap: 10px; --pane-gap: 10px;
@ -349,8 +358,9 @@
*/ */
--bg-scrim: 0 0 0; --bg-scrim: 0 0 0;
/* 深色下面板要更实:背景亮部与深色卡片对比过强时,文字会显得发灰。 */ /* 深色下面板要更实:背景亮部与深色卡片对比过强时,文字会显得发灰。 */
--bg-glass: 0.5; --bg-glass: 0.9;
--bg-glass-inner: 0.26; --bg-glass-inner: 0.84;
--bg-glass-control: 0.55;
/* /*
* 深色下的层次靠「边框亮于底」而不是阴影。 * 深色下的层次靠「边框亮于底」而不是阴影。
@ -721,6 +731,21 @@ html[data-bg='on'] body,
html[data-bg='on'] .bg-gray-50, html[data-bg='on'] .bg-gray-50,
html[data-bg='on'] .bg-slate-100 { html[data-bg='on'] .bg-slate-100 {
background-color: transparent; background-color: transparent;
/* 空白区不模糊:壁纸在这里要清晰可辨(用户:"真正该透明的地方加了很浓的模糊" */
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
/*
* .glass-control —— 小控件用的"更透"档。
*
* 必须显式加在元素上、并且**不要同时写 bg-white**:我的 `html[data-bg='on'] .bg-white`
* 接管规则会把任何 bg-white 拉回正文面那一档0.88),写在这儿的透度会被它盖掉。
*/
html[data-bg='on'] .glass-control {
background-color: rgb(var(--c-white) / var(--bg-glass-control));
backdrop-filter: blur(6px) saturate(1.1);
-webkit-backdrop-filter: blur(6px) saturate(1.1);
} }
html[data-bg='on'] .bg-white { html[data-bg='on'] .bg-white {
@ -769,6 +794,62 @@ html[data-bg='on'] .hover\:bg-gray-100:hover {
* ③ 导航栏**完全不透明**:它是框架,不该跟着壁纸一起虚化。 * ③ 导航栏**完全不透明**:它是框架,不该跟着壁纸一起虚化。
* 这与内容面板要通透是**两个方向**的要求,别合并成一条规则。 * 这与内容面板要通透是**两个方向**的要求,别合并成一条规则。
*/ */
/*
* ★ 两类面要分开处理2026-09-14 用户第二轮批评):
*
* 「你把大量需要打底的场景(弹窗正文等)改为了透明。真正该透明的地方(空白区域)
* 加了很浓的模糊」
*
* 也就是我上一版把两类搞反了:
* - **承载文字的面**(正文卡、弹窗、列表、输入条):必须够实 + 轻模糊,
* 否则字压在壁纸上读不动 —— 这是"打底",不是"玻璃"
* - **空白/页面底**详情区空态、面板缝隙、page 底):透明且**不模糊**
* 壁纸在这里应该是清晰的照片,不是一团糊。
*
* 规则落点:`--bg-glass` 只作用在 `.bg-white` 这一族(承载内容的那些面),
* 而 `.bg-gray-50` / 页面底保持 transparent 且不参与模糊。
*/
/*
* 浮动面板的几何 **始终生效**(圆角 + 缝隙 + 投影 + 一点朦胧),不只在壁纸模式下。
*
* 用户2026-09-14「通信页面大面积缺失圆角与玻璃效果所有有内容与无内容区域
* 都是硬截断」—— 根因是这些规则原先**全写在 `html[data-bg='on']` 里**
* 没开壁纸的账号看到的是硬边、不透明、贴在一起的面板,硬截断就是这么来的。
*
* 所以几何部分下沉到这里(无条件的基线),壁纸相关的加强(朝向照片的透明度、
* 强模糊、面板间露出壁纸)仍留在下面的 data-bg 段里 —— 两者是叠加关系,不是替代。
*/
.app-shell {
padding: var(--pane-gap);
gap: var(--pane-gap);
}
.app-shell > * {
border-radius: var(--radius-card);
overflow: hidden;
box-shadow: 0 8px 24px rgb(15 23 42 / 0.08);
/* 圆角处的背景不要溢出圆角外(否则边缘会出现方角残影) */
background-clip: padding-box;
}
/* 基线朦胧:没开壁纸时也有一点玻璃感,而不是一块硬塑料 */
.app-shell > *,
.narrow-shell > *:not(.narrow-nav) {
backdrop-filter: blur(var(--bg-blur-panel)) saturate(1.05);
-webkit-backdrop-filter: blur(var(--bg-blur-panel)) saturate(1.05);
}
/* 窄屏:内容面板同样浮起来(与底部导航那条悬浮玻璃一致) */
.narrow-shell {
padding: var(--pane-gap) var(--pane-gap) 0;
gap: var(--pane-gap);
}
.narrow-shell > *:not(.narrow-nav) {
border-radius: var(--radius-card);
overflow: hidden;
}
html[data-bg='on'] .app-shell { html[data-bg='on'] .app-shell {
padding: var(--pane-gap); padding: var(--pane-gap);
gap: var(--pane-gap); gap: var(--pane-gap);
@ -869,3 +950,78 @@ select,
transition-duration: 0.01ms !important; transition-duration: 0.01ms !important;
} }
} }
/*
* ═══════════════════════════════════════════════════════════════════
* 窄屏底部导航:悬浮玻璃条
*
* 用户2026-09-14「窄屏布局的导航栏还是固定底部延伸没有玻璃效果也没有悬浮」。
* 原来它是 `border-t bg-chrome-900` 的通栏 —— 贴着屏幕底边铺满宽度,与宽屏那套
* 「浮动面板 + 留缝」的观感完全脱节(宽屏侧栏虽然不是玻璃,但内容面板是浮起来的)。
*
* 改成:离底边留 10px、左右各留 10px、圆角、深色半透明 + 背景模糊,落在壁纸之上。
* 安全区iPhone 手势条)并入下外边距,否则悬浮条会被手势条压住。
*/
.narrow-nav {
margin: 0 10px calc(10px + env(safe-area-inset-bottom));
border-radius: var(--radius-card, 14px);
border: 1px solid rgb(255 255 255 / 0.12);
background-color: rgb(15 23 42 / 0.82);
backdrop-filter: blur(18px) saturate(1.5);
-webkit-backdrop-filter: blur(18px) saturate(1.5);
box-shadow: 0 10px 30px rgb(0 0 0 / 0.35);
overflow: hidden;
}
/*
* 视图切换动画。
*
* 用户:「页面极度缺少动画,所有页面都是直接出现」。
*
* 做法:只在 <html> 上挂一个短命的类(由 App 的 effect 在 viewMode/页签变化时重新触发),
* 由它给外壳的直接子面板加一次入场动画。**不改 DOM 结构** —— 面板本身是 flex 链上的
* 一环,套一层动画 wrapper 会把 flex 传递改掉(这类改动最容易把窄屏覆盖层弄坏)。
*/
@keyframes pane-in {
from {
opacity: 0;
transform: translateY(6px) scale(0.995);
}
to {
opacity: 1;
transform: none;
}
}
html.view-switch .app-shell > *,
html.view-switch .narrow-shell > * {
animation: pane-in 220ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
}
/* 菜单/建议列表:轻微放大淡入,避免"啪"地出现 */
@keyframes menu-in {
from {
opacity: 0;
transform: translateY(-4px) scale(0.985);
}
to {
opacity: 1;
transform: none;
}
}
html.view-switch .glass-control,
.animate-menu-in {
animation: menu-in 140ms ease-out both;
}
/* 尊重系统设置:关了动画就一点都不动(前庭功能敏感的人会被位移动画伤到) */
@media (prefers-reduced-motion: reduce) {
html.view-switch .app-shell > *,
html.view-switch .narrow-shell > *,
html.view-switch .glass-control,
.animate-menu-in {
animation: none !important;
}
}

View File

@ -2,10 +2,23 @@ import { create } from 'zustand';
export type ViewMode = 'inbox' | 'sent' | 'permissions' | 'calendar' | 'contacts' | 'admin' | 'account'; export type ViewMode = 'inbox' | 'sent' | 'permissions' | 'calendar' | 'contacts' | 'admin' | 'account';
/**
* 「通信」这一个导航项内部的三个页签。
*
* 2026-09-14 用户:「导航栏的内容有点多了,收件发件授权改为一个导航项,通过内部导航区分」。
* 于是 `viewMode` 仍然是那三个值(几十处调用点不用改),但导航栏只画一项「通信」;
* 这一项点击时回到**上次用的那个页签**`commTab`),进入后再由内部页签切换。
*/
export type CommTab = 'inbox' | 'sent' | 'permissions';
interface UIState { interface UIState {
viewMode: ViewMode; viewMode: ViewMode;
setViewMode: (mode: ViewMode) => void; setViewMode: (mode: ViewMode) => void;
/** 「通信」导航项记忆的子页签(收件箱/发件箱/授权) */
commTab: CommTab;
setCommTab: (tab: CommTab) => void;
/** 右侧主区域是否处于「新建邮件」整页编写态 */ /** 右侧主区域是否处于「新建邮件」整页编写态 */
composing: boolean; composing: boolean;
composePrefill: { to?: string; cc?: string } | null; composePrefill: { to?: string; cc?: string } | null;
@ -29,7 +42,7 @@ interface UIState {
reset: () => void; reset: () => void;
} }
export const useUIStore = create<UIState>(set => ({ export const useUIStore = create<UIState>((set, get) => ({
viewMode: 'inbox', viewMode: 'inbox',
// 切换主视图时回到列表栏:窄屏下停在上一封邮件的详情页会让人不知道自己在哪 // 切换主视图时回到列表栏:窄屏下停在上一封邮件的详情页会让人不知道自己在哪
setViewMode: mode => setViewMode: mode =>
@ -37,9 +50,21 @@ export const useUIStore = create<UIState>(set => ({
viewMode: mode, viewMode: mode,
composing: false, composing: false,
composePrefill: null, composePrefill: null,
narrowPane: 'list' narrowPane: 'list',
// 通信的三个子页签要记住:点「通信」时回到上次那个,而不是永远回收件箱
...(mode === 'inbox' || mode === 'sent' || mode === 'permissions'
? { commTab: mode as CommTab }
: {})
}), }),
commTab: 'inbox',
// 点内部页签 = 走同一条切换路径(这样"记住上次"的语义只有一处实现)
setCommTab: (tab: CommTab) => {
const st = get();
if (st.viewMode === tab && !st.composing) return;
st.setViewMode(tab);
},
composing: false, composing: false,
composePrefill: null, composePrefill: null,
// 写信占满整个主区域,窄屏下等价于切到 detail 栏 // 写信占满整个主区域,窄屏下等价于切到 detail 栏
@ -54,6 +79,7 @@ export const useUIStore = create<UIState>(set => ({
reset: () => reset: () =>
set({ set({
viewMode: 'inbox', viewMode: 'inbox',
commTab: 'inbox',
composing: false, composing: false,
composePrefill: null, composePrefill: null,
narrowPane: 'list' narrowPane: 'list'

View File

@ -169,12 +169,29 @@ check('新组件未使用未映射色族', unmapped.length === 0, unmapped.join(
for (let i = 1; i < layers; i++) opaque = opaque + nested * (1 - opaque); for (let i = 1; i < layers; i++) opaque = opaque + nested * (1 - opaque);
return opaque; return opaque;
}; };
const ctrl = Number((css.match(/--bg-glass-control:\s*([\d.]+)/) || [])[1]);
const stacked = composite(glass, inner, 2); const stacked = composite(glass, inner, 2);
check('玻璃有"嵌套层"变量', Number.isFinite(glass) && Number.isFinite(inner), `glass=${glass} inner=${inner}`); check('玻璃有"嵌套层"变量', Number.isFinite(glass) && Number.isFinite(inner), `glass=${glass} inner=${inner}`);
check('有"嵌套面板不再各叠一次"的规则', /\.bg-white \.bg-white \{/.test(css)); check('有"嵌套面板不再各叠一次"的规则', /\.bg-white \.bg-white \{/.test(css));
check('两层嵌套后的有效不透明度 ≤ 0.85', stacked <= 0.85, `实际 ${stacked.toFixed(3)}`); /*
check('外层玻璃 ≤ 0.70', glass <= 0.7, `实际 ${glass}`); * ★ 这三条 2026-09-14 重写(原先断言的是"越透越好",方向是错的)。
check('判据自检:旧值 0.82 层层叠必须算得出 >0.95', composite(0.82, 0.82, 2) > 0.95); *
* 我第一版按"内容更通透"把正文面压到 0.45,用户当场指出:「你把大量需要打底的
* 场景(弹窗正文等)改为了透明。真正该透明的地方(空白区域)加了很浓的模糊」。
* 于是判据改成**两类面分开**
* - 承载文字的面必须够实0.80.95),否则字压在壁纸上读不动;
* - 空白/页面底:透明且不模糊(另有用例)。
* 旧的"≤0.70 / ≤0.85"留着只会把我再拽回那个错误方向,所以连同理由一起改掉。
*/
check('正文面够实0.800.95,读得清优先)', glass >= 0.8 && glass <= 0.95, `实际 ${glass}`);
check('嵌套面比外层透、但仍打底0.70–外层)', inner >= 0.7 && inner <= glass, `glass=${glass} inner=${inner}`);
check('两层嵌套后仍接近实心≥0.9', composite(glass, inner, 2) >= 0.9, `实际 ${stacked.toFixed(3)}`);
check(
'控件档最透,且比嵌套面更透(复选框/地址建议)',
ctrl <= 0.6 && ctrl < inner,
`control=${ctrl} inner=${inner}`
);
check('判据自检:两档一样透是分不出层次的(必须算得出 ≥0.9', composite(0.82, 0.82, 2) >= 0.9);
// 17) ★ 浅色表面类的接管清单必须跟着源码走。 // 17) ★ 浅色表面类的接管清单必须跟着源码走。
// //
@ -210,11 +227,21 @@ check('新组件未使用未映射色族', unmapped.length === 0, unmapped.join(
const navNoBlur = /html\[data-bg='on'\] \.app-shell > \.bg-chrome-900 \{[\s\S]{0,160}backdrop-filter: none/.test(css); const navNoBlur = /html\[data-bg='on'\] \.app-shell > \.bg-chrome-900 \{[\s\S]{0,160}backdrop-filter: none/.test(css);
check('导航栏在背景模式下完全不透明', opaqueNav); check('导航栏在背景模式下完全不透明', opaqueNav);
check('导航栏不再参与模糊', navNoBlur); check('导航栏不再参与模糊', navNoBlur);
check('内容面板更通透(--bg-glass ≤ 0.55', glass <= 0.55, `实际 ${glass}`); /*
check('外壳留缝(浮动面板布局)', * ★ 浮动面板几何必须**无条件**生效2026-09-14 用户:「通信页面大面积缺失圆角与
/html\[data-bg='on'\] \.app-shell \{[\s\S]{0,80}padding: var\(--pane-gap\)/.test(css)); * 玻璃效果,所有有内容与无内容区域都是硬截断」)。
check('顶层面板圆角化', *
/html\[data-bg='on'\] \.app-shell > \* \{[\s\S]{0,140}border-radius: var\(--radius-card\)/.test(css)); * 根因:这些规则原先全写在 `html[data-bg='on']` 里 ⇒ 没开壁纸的账号看到硬边面板。
* 所以判据不能再带 data-bg 前缀 —— 带前缀等于把缺陷写进判据。
*/
check('外壳留缝与圆角是无条件的(不只壁纸模式)',
/(?<!data-bg='on'\] )\.app-shell \{[\s\S]{0,80}padding: var\(--pane-gap\)/.test(css) &&
/(?<!data-bg='on'\] )\.app-shell > \* \{[\s\S]{0,160}border-radius: var\(--radius-card\)/.test(css));
check('窄屏内容面板也浮起来(圆角)',
/\.narrow-shell > \*:not\(\.narrow-nav\) \{[\s\S]{0,80}border-radius: var\(--radius-card\)/.test(css));
check('窄屏底部导航是悬浮玻璃(留缝 + 模糊)',
/\.narrow-nav \{[\s\S]{0,220}backdrop-filter: blur\(/.test(css) &&
/\.narrow-nav \{[\s\S]{0,120}margin: 0 10px/.test(css));
console.log(`\n背景:${pass} 通过${fail ? `${fail} 失败` : ''}`); console.log(`\n背景:${pass} 通过${fail ? `${fail} 失败` : ''}`);
process.exit(fail ? 1 : 0); process.exit(fail ? 1 : 0);

View File

@ -0,0 +1,120 @@
/**
* 窄屏悬浮玻璃导航 + 视图切换动画的真实验收。
*
* 用户两条原话:
* 「窄屏布局的导航栏还是固定底部延伸,没有玻璃效果,也没有悬浮」
* 「页面极度缺少动画,所有页面都是直接出现」
*
* 判据只量**可观测的东西**:几何(留缝/圆角)、玻璃(半透明 + 模糊)、动画(真的在跑)。
* 每项都配反向对照,否则"没有动画"和"有动画但没触发"分不开。
*
* 用法AGENTMAIL_DIST=<dist> ADMIN_USER=.. ADMIN_PW=.. node test/manual/narrow-glass-verify.mjs
*/
import { openApp } from './narrow-probe-helper.mjs';
const PHONE = { width: 390, height: 844 };
const results = [];
const record = (name, ok, note) => {
results.push({ name, ok });
console.log(` ${ok ? '通过' : '失败'} ${name}${note ? ' — ' + note : ''}`);
};
const { page, ctx } = await openApp(PHONE);
try {
await page.waitForSelector('.narrow-nav', { timeout: 25000 });
await page.waitForTimeout(600);
// ── ① 悬浮:留缝 + 圆角 ──
const geo = await page.evaluate(() => {
const nav = document.querySelector('.narrow-nav');
const r = nav.getBoundingClientRect();
const cs = getComputedStyle(nav);
const pr = getComputedStyle(document.querySelector('.narrow-shell') || nav.parentElement);
return {
left: r.left,
right: r.right,
bottom: r.bottom,
vw: window.innerWidth,
vh: window.innerHeight,
radius: cs.borderRadius,
marginBottom: cs.marginBottom,
parentPadBottom: pr.paddingBottom,
pointerAtGap: document.elementFromPoint(r.left - 4, r.top + 4)?.className?.toString().slice(0, 40) || ''
};
});
record(
'① 导航不再贴底通栏:左右留缝、离底留缝、有圆角',
geo.left >= 6 && geo.vw - geo.right >= 6 && geo.vh - geo.bottom >= 6 && parseFloat(geo.radius) >= 8,
`left=${Math.round(geo.left)} right缝=${Math.round(geo.vw - geo.right)} bottom缝=${Math.round(geo.vh - geo.bottom)} radius=${geo.radius}`
);
record(
'① 左边缝露出来的是页面底(说明它真的浮在上面)',
!/narrow-nav/.test(geo.pointerAtGap),
`缝隙处命中=${geo.pointerAtGap || '(页面底)'}`
);
// ── ② 玻璃:半透明 + 模糊 ──
const glass = await page.evaluate(() => {
const cs = getComputedStyle(document.querySelector('.narrow-nav'));
const bg = cs.backgroundColor.match(/rgba?\(([^)]+)\)/);
const a = bg ? (bg[1].split(',').length === 4 ? parseFloat(bg[1].split(',')[3]) : 1) : null;
const bf = cs.backdropFilter || cs.webkitBackdropFilter || 'none';
return { alpha: a, blur: bf };
});
record(
'② 导航是玻璃:半透明 + 背景模糊',
glass.alpha !== null && glass.alpha > 0.4 && glass.alpha < 0.98 && /blur\((\d+)/.test(glass.blur),
`α=${glass.alpha} backdrop=${glass.blur}`
);
// ── ③ 触摸目标仍然达标(悬浮不能把可点面积改小)──
const taps = await page.$$eval('.narrow-nav button', els =>
els.map(e => {
const r = e.getBoundingClientRect();
return { w: Math.round(r.width), h: Math.round(r.height), label: e.textContent.trim().slice(0, 4) };
})
);
const small = taps.filter(t => t.h < 44);
record('③ 每项触摸高度 ≥44px', taps.length >= 4 && small.length === 0,
`${taps.length} 项,最小 ${Math.min(...taps.map(t => t.h))}px`);
// ── ④ 动画:切换页签时面板真的在跑 pane-in ──
const animBefore = await page.evaluate(
() => getComputedStyle(document.querySelector('.narrow-shell > *')).animationName
);
await page.click('.narrow-nav button:has-text("日历")');
await page.waitForTimeout(60);
const during = await page.evaluate(() => {
const root = document.documentElement;
const panes = [...document.querySelectorAll('.narrow-shell > *')];
return {
hasClass: root.classList.contains('view-switch'),
names: panes.map(p => getComputedStyle(p).animationName).filter(n => n !== 'none')
};
});
record(
'④ 切视图时面板在跑入场动画(反向对照:静止时不跑)',
animBefore === 'none' && during.names.length > 0 && during.names.every(n => n === 'pane-in'),
`静止 animationName=${animBefore};切换后 class=${during.hasClass} 动画=[${during.names.join(',')}]`
);
// ── ⑤ 动画必须尊重 reduced-motion ──
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.click('.narrow-nav button:has-text("联系人")');
await page.waitForTimeout(60);
const reduced = await page.evaluate(() =>
[...document.querySelectorAll('.narrow-shell > *')]
.map(p => getComputedStyle(p).animationName)
.filter(n => n !== 'none')
);
record('⑤ 系统关了动画时一点都不动', reduced.length === 0, `动画=[${reduced.join(',')}]`);
await page.emulateMedia({ reducedMotion: null });
} finally {
await page.close();
await ctx.close();
}
const failed = results.filter(r => !r.ok);
console.log(`\n 窄屏悬浮玻璃 + 动画:${results.length - failed.length}/${results.length} 通过`);
if (failed.length) console.log(' 失败:' + failed.map(f => f.name).join('; '));
process.exitCode = failed.length ? 1 : 0;

View File

@ -43,9 +43,22 @@ export const WIDE = { width: 1280, height: 800 };
export async function openApp(viewport = PHONE) { export async function openApp(viewport = PHONE) {
const browser = await chromium.connectOverCDP(CDP); const browser = await chromium.connectOverCDP(CDP);
const ctx = browser.contexts()[0] ?? (await browser.newContext());
// ★ 只把视口调窄**不等于**手机2026-09-14 用户:"窄屏ui你还没改完")。
//
// 桌面 Chromium 里 `(hover: hover) and (pointer: fine)` 仍然为真,于是
// 「悬停才显形的次要动作」在探针里被测成透明 —— 而真机上它们是可见的。
// 实测这类假失败把窄屏验收带偏了方向:我差点去"修"一个本来就对的规则。
// 因此这里开一个**触屏上下文**hasTouch + isMobile + DPR让媒体查询
// 与真机一致;只有这样才能测出"看不见却按得动"这类真问题。
const ctx = await browser.newContext({
viewport,
hasTouch: true,
isMobile: true,
deviceScaleFactor: 3,
userAgent: 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Mobile Safari/537.36'
});
const page = await ctx.newPage(); const page = await ctx.newPage();
await page.setViewportSize(viewport);
const issues = []; const issues = [];
page.on('pageerror', e => issues.push('pageerror: ' + String(e).slice(0, 220))); page.on('pageerror', e => issues.push('pageerror: ' + String(e).slice(0, 220)));
@ -86,6 +99,7 @@ export async function openApp(viewport = PHONE) {
}); });
} }
// 用完只关自己的上下文CDP 连的是**共享**浏览器close() 会把别人的标签页一起干掉)
// 不能用 networkidleSSE 是一条永不结束的长连接networkidle 永远不触发 // 不能用 networkidleSSE 是一条永不结束的长连接networkidle 永远不触发
await page.goto(APP + '/', { waitUntil: 'domcontentloaded', timeout: 30000 }); await page.goto(APP + '/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2500); await page.waitForTimeout(2500);
@ -98,7 +112,7 @@ export async function openApp(viewport = PHONE) {
await page.click('button:has-text("登录")'); await page.click('button:has-text("登录")');
await page.waitForTimeout(3500); await page.waitForTimeout(3500);
} }
return { browser, page, issues }; return { browser, ctx, page, issues };
} }
/** 量一个元素的盒子;不存在返回 null。 */ /** 量一个元素的盒子;不存在返回 null。 */

View File

@ -16,7 +16,7 @@ import {
SMALL SMALL
} from './narrow-probe-helper.mjs'; } from './narrow-probe-helper.mjs';
const { browser, page, issues } = await openApp(); const { browser, ctx, page, issues } = await openApp();
const failed = []; const failed = [];
async function check(name, fn) { async function check(name, fn) {
@ -169,6 +169,9 @@ await check('320px 模型范围排序按钮命中区达标', async () => {
console.log('\nissues:', issues.length ? issues : '无'); console.log('\nissues:', issues.length ? issues : '无');
console.log(failed.length === 0 ? '\n窄屏实测全部通过' : `\n窄屏实测:${failed.length} 项失败 — ${failed.join(', ')}`); console.log(failed.length === 0 ? '\n窄屏实测全部通过' : `\n窄屏实测:${failed.length} 项失败 — ${failed.join(', ')}`);
// ★ 只关自己开的上下文再断开CDP 连的是共享 Chromium
// browser.close() 会把**别人的**标签页也一起关掉(本机多个 agent 共用 9222
await page.close(); await page.close();
await browser.close(); await ctx.close();
// 共享浏览器CDP 9222不能 close那是所有 agent 共用的实例。
process.exit(failed.length === 0 ? 0 : 1); process.exit(failed.length === 0 ? 0 : 1);

View File

@ -0,0 +1,204 @@
/**
* 导航重构的真实渲染验收2026-09-14 用户三条要求)。
*
* 文件级判据test/nav-merge.test.mjs只能证明"代码里写了",证明不了"点得到、
* 看得见、量出来对"。这里在真浏览器里量:
*
* ① 导航项真的只剩 3 项(宽屏侧栏)/ 3+我的(窄屏底部)
* ② 内部页签能切换、切换后列表真的换内容
* ③ 悬浮加号命中区 >= 44px且 elementFromPoint 命中的就是它(不是被别的层压住)
* ④ 管理员在「我的」页最下面真的看得到管理入口
* ⑤ 控件档透度**真的**比正文面低(反向对照:两类面的 alpha 必须分得开)
*
* 用法node client/electron/test/manual/nav-restructure-verify.mjs
*/
import { openApp, tapTargets } from './narrow-probe-helper.mjs';
const WIDE = { width: 1280, height: 900 };
const PHONE = { width: 390, height: 844 };
const results = [];
function record(name, ok, note) {
results.push({ name, ok });
console.log(` ${ok ? '通过' : '失败'} ${name}${note ? ' — ' + note : ''}`);
}
// ── 宽屏 ─────────────────────────────────────────────────────────────
{
const { page, ctx } = await openApp(WIDE);
try {
await page.waitForSelector('.w-\\[60px\\] , .narrow-nav', { timeout: 25000 });
// 侧栏根是 div.w-[60px](不是 nav 元素)——之前用 'nav' 选择器什么也没等到。
// 标签取"按钮文字去掉徽标数字":带徽标的项最后一个 span 是徽标,
// 按 span:last-child 取会把「通信」「联系人」整条丢掉(判据自己的 bug实测踩到
const navLabels = await page.$$eval('.w-\\[60px\\] button', els =>
els.map(e => e.textContent.replace(/[0-9]+\+?/g, '').trim()).filter(Boolean)
);
const commCount = navLabels.filter(t => t === '通信').length;
const stale = navLabels.filter(t => ['收件', '发件', '授权', '新建', '用户'].includes(t));
record(
'① 宽屏侧栏只剩 通信/日历/联系(+我的)',
commCount === 1 && stale.length === 0,
`标签=[${navLabels.join(',')}] 残留=[${stale.join(',')}]`
);
// ② 内部页签:切换后列表标题/内容变化
if (await page.locator('[data-testid="comm-tabs"]').count()) {
const tabs = await page.$$eval('[data-testid="comm-tabs"] button', els =>
els.map(e => e.textContent.trim())
);
record(
'① 通信内部有三个页签(收件箱/发件箱/授权)',
tabs.length === 3 && tabs[0].includes('收件箱') && tabs[2].includes('授权'),
`页签=[${tabs.join(' | ')}]`
);
const listText = async () =>
(await page.locator('[data-testid="comm-tabs"]').locator('xpath=..').innerText()).slice(0, 400);
const before = await listText();
await page.click('[data-testid="comm-tabs"] button:has-text("发件箱")');
await page.waitForTimeout(700);
const after = await listText();
record('② 切换页签后内容真的变了', before !== after, `${before.length} 字符 → ${after.length} 字符`);
await page.click('[data-testid="comm-tabs"] button:has-text("收件箱")');
await page.waitForTimeout(500);
} else {
record('① 通信内部有三个页签', false, '没找到 comm-tabs');
}
// ③ 悬浮加号
const fab = page.locator('[data-testid="compose-fab"]');
if (await fab.count()) {
const box = await fab.boundingBox();
const hit = await page.evaluate(() => {
const b = document.querySelector('[data-testid="compose-fab"]');
if (!b) return null;
const r = b.getBoundingClientRect();
const el = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
return { inside: b.contains(el) || b === el, r: { w: r.width, h: r.height } };
});
const round = box && Math.abs(box.width - box.height) < 2;
record(
'③ 悬浮加号是圆的、命中区 ≥44px、且没被压住',
!!box && box.width >= 44 && box.height >= 44 && round && hit?.inside === true,
`尺寸=${Math.round(box?.width)}×${Math.round(box?.height)} 命中自己=${hit?.inside} 圆=${round}`
);
} else {
record('③ 悬浮加号存在', false, '没找到 compose-fab');
}
// ⑤ 透度分档(把壁纸状态直接打开,量的是 CSS 本身,不动用户保存的设置)
await page.evaluate(() => {
document.documentElement.dataset.bg = 'on';
});
await page.waitForTimeout(300);
const alpha = sel =>
page.evaluate(s => {
const el = document.querySelector(s);
if (!el) return null;
const bg = getComputedStyle(el).backgroundColor;
const m = bg.match(/rgba?\(([^)]+)\)/);
if (!m) return null;
const parts = m[1].split(',').map(x => parseFloat(x));
return parts.length === 4 ? parts[3] : 1;
}, sel);
const card = await alpha('.bg-white');
// 地址建议菜单:先在写信页的收件人框里打字触发
await page.click('[data-testid="compose-fab"]');
await page.waitForTimeout(600);
const toInput = page.locator('input[placeholder*="收件人"], input[autocomplete="off"]').first();
if (await toInput.count()) {
await toInput.fill('gui');
await page.waitForTimeout(1200);
}
const menu = await alpha('.glass-control');
record(
'⑤ 控件档(地址建议)比正文面更透',
menu !== null && card !== null && menu < card - 0.15,
`正文面 α=${card} 控件 α=${menu}`
);
} finally {
await page.close();
await ctx.close();
}
}
// ── 窄屏 ─────────────────────────────────────────────────────────────
{
const { page, ctx } = await openApp(PHONE);
try {
await page.waitForSelector('nav', { timeout: 20000 });
const labels = await page.$$eval('.narrow-nav button', els =>
els.map(e => e.textContent.replace(/[0-9]+\+?/g, '').trim()).filter(Boolean)
);
const stale = labels.filter(t => ['收件', '发件', '授权', '新建', '管理'].includes(t));
record(
'① 窄屏底部导航 = 通信/日历/联系人/我的',
labels.length === 4 && labels.includes('通信') && labels.includes('我的') && stale.length === 0,
`标签=[${labels.join(',')}] 残留=[${stale.join(',')}]`
);
const fab = page.locator('[data-testid="compose-fab"]');
const fabInfo = (await fab.count())
? await page.evaluate(() => {
const b = document.querySelector('[data-testid="compose-fab"]');
const r = b.getBoundingClientRect();
const nav = document.querySelector('.narrow-nav')?.getBoundingClientRect();
const el = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
return {
w: r.width,
h: r.height,
bottom: r.bottom,
navTop: nav ? nav.top : null,
inside: b.contains(el) || b === el
};
})
: null;
record(
'③ 窄屏加号在底部导航之上、命中区达标、没被压住',
!!fabInfo &&
fabInfo.w >= 44 &&
fabInfo.h >= 44 &&
fabInfo.inside === true &&
(fabInfo.navTop === null || fabInfo.bottom <= fabInfo.navTop + 1),
fabInfo ? `${Math.round(fabInfo.w)}×${Math.round(fabInfo.h)} bottom=${Math.round(fabInfo.bottom)} navTop=${Math.round(fabInfo.navTop)}` : '没找到'
);
if (await fab.count()) {
await fab.click();
await page.waitForTimeout(900);
const composing = await page.evaluate(() =>
!!document.querySelector('textarea') && !document.querySelector('[data-testid="compose-fab"]')
);
record('② 点加号能进入写信(且加号自己让位)', composing, '');
}
// ④ 我的页最下面的管理入口(当前登录账号是否管理员决定可见性,两种都算通过)
await page.click('.narrow-nav button:has-text("我的")');
await page.waitForTimeout(900);
const adminInfo = await page.evaluate(() => {
const btns = [...document.querySelectorAll('button')];
const admin = btns.find(b => b.textContent.includes('用户管理'));
const logout = btns.find(b => b.textContent.includes('退出登录'));
if (!admin) return { found: false };
return {
found: true,
afterLogout: !!(logout && admin.getBoundingClientRect().top >= logout.getBoundingClientRect().top),
visible: admin.getBoundingClientRect().height > 0
};
});
record(
'④ 「我的」页底部有管理入口(且排在退出登录之后)',
adminInfo.found && adminInfo.afterLogout && adminInfo.visible,
adminInfo.found ? `在退出登录之后=${adminInfo.afterLogout}` : '当前账号非管理员(此判据不适用)'
);
} finally {
await page.close();
await ctx.close();
}
}
const failed = results.filter(r => !r.ok);
console.log(`\n 导航重构验收:${results.length - failed.length}/${results.length} 通过`);
if (failed.length) console.log(' 失败:' + failed.map(f => f.name).join('; '));
process.exitCode = failed.length ? 1 : 0;

View File

@ -0,0 +1,108 @@
/**
* 导航重构的判据2026-09-14 用户三条要求)。
*
* 用户原话:
* ①「导航栏的内容有点多了,收件发件授权改为一个导航项,通过内部导航区分,
* 然后新建作为他们内部的一个悬浮的圆形加号,这样导航项就只剩通信,日历,联系人」
* ②「将管理和我的合并,管理员视角我的页面拉到最下面有一个管理」
* ③「复选框和地址猜测项的透明度问题还没改,他们才是真正需要拉低透明度的地方」
*
* 为什么写文件级判据:改完跑老套件 254 条**全绿**,因为它一条都没测导航结构 ——
* 我差点把"没红的测试"当成"改对了"。结构类改动必须自己带判据。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const read = (...p) => readFileSync(join(HERE, '..', 'src', ...p), 'utf8');
const sidebar = read('components', 'Sidebar.tsx');
const narrow = read('components', 'NarrowNav.tsx');
const commTabs = read('components', 'CommTabs.tsx');
const app = read('App.tsx');
const account = read('components', 'AccountPage.tsx');
const uiStore = read('stores', 'uiStore.ts');
const css = read('index.css');
const mailView = read('components', 'MailView.tsx');
const addr = read('components', 'AddressInput.tsx');
test('① 导航只剩 通信/日历/联系人 三项(桌面与窄屏都要)', () => {
for (const [name, src] of [['Sidebar', sidebar], ['NarrowNav', narrow]]) {
// 三项都在
assert.match(src, /短:\s*'通信'|short:\s*'通信'/, `${name} 缺「通信」`);
assert.match(src, /'日历'/, `${name} 缺「日历」`);
assert.match(src, /'联系人'|'联系'/, `${name} 缺「联系人」`);
// 旧的独立项必须消失
for (const gone of ['收件', '发件', '授权请求', '用户管理']) {
assert.ok(!src.includes(`'${gone}'`), `${name} 里还留着旧的独立导航项「${gone}`);
}
}
});
test('① 「通信」的选中态覆盖三个子页签,点击回到上次那个', () => {
assert.match(sidebar, /modes:\s*\['inbox',\s*'sent',\s*'permissions'\]/);
assert.match(narrow, /modes:\s*\['inbox',\s*'sent',\s*'permissions'\]/);
assert.match(sidebar, /setViewMode\(target \|\| commTab/, '桌面点通信要回 commTab');
assert.match(narrow, /setViewMode\(isComm \? commTab : mode\)/, '窄屏点通信要回 commTab');
});
test('① 内部导航CommTabs存在、挂在通信页、且带徽标', () => {
assert.match(commTabs, /data-testid="comm-tabs"/);
assert.match(commTabs, /'收件箱'[\s\S]*'发件箱'[\s\S]*'授权'/, '三个内部页签');
assert.match(commTabs, /pendingPerms/, '授权徽标不能因为合并而消失');
assert.match(app, /<CommTabs \/>/, 'App 里要渲染内部导航');
assert.match(app, /const isComm = viewMode === 'inbox'/, '只挂在通信三个页签下');
});
test('① 新建是悬浮圆形加号,且不再是导航项', () => {
assert.match(commTabs, /data-testid="compose-fab"/);
assert.match(commTabs, /rounded-full/, '必须是圆的(用户点名"圆形加号"');
assert.match(commTabs, /absolute bottom-4 right-4/, '悬浮在列表栏右下角');
assert.match(app, /<ComposeFab \/>/, 'App 里要渲染它');
assert.ok(!/新建<\/span>/.test(sidebar), '桌面侧栏不该再有为"新建"的导航文字');
assert.ok(!/新建<\/span>/.test(narrow), '窄屏导航不该再有"新建"');
});
test('② 管理与我的合并:入口在「我的」最下面,且仅管理员可见', () => {
assert.match(account, /user\?\.role === 'admin' &&/, '必须是条件渲染而不是禁用');
const logout = account.indexOf('退出登录');
const adminEntry = account.indexOf("setViewMode('admin')");
assert.ok(logout > 0 && adminEntry > logout, '管理入口要在「登录状态」之后(=页面最下面)');
assert.ok(!/mode: 'admin'/.test(sidebar), '侧栏不该再有独立的 admin 导航项');
assert.ok(!/mode: 'admin'/.test(narrow), '窄屏不该再有独立的 admin 导航项');
});
test('② uiStore 记住通信子页签,且 reset 能清掉', () => {
assert.match(uiStore, /commTab: CommTab/);
assert.match(uiStore, /setCommTab/);
assert.match(uiStore, /commTab: mode as CommTab/, 'setViewMode 要顺手记住');
assert.match(uiStore, /reset:[\s\S]{0,120}commTab: 'inbox'/, '登出后要复位');
});
test('③ 复选框与地址建议用"控件档"透度(比正文面更透)', () => {
assert.match(mailView, /glass-control/, '授权多选胶囊');
assert.match(addr, /glass-control/, '地址建议菜单');
// 两处都不能再是 bg-white我的 .bg-white 接管会把它们拉回正文面那一档
const chip = mailView.match(/glass-control[^']*/);
assert.ok(chip, 'chip 类名');
assert.ok(!/glass-control[^']*bg-white/.test(mailView), '不能再同时写 bg-white');
assert.ok(!/glass-control[^"]*bg-white/.test(addr), '建议菜单不能再同时写 bg-white');
// 三档透度必须真的递减(正文 > 嵌套 > 控件)
const num = re => Number(css.match(re)[1]);
const big = num(/--bg-glass:\s*(0\.\d+)/);
const inner = num(/--bg-glass-inner:\s*(0\.\d+)/);
const ctrl = num(/--bg-glass-control:\s*(0\.\d+)/);
assert.ok(big > inner && inner > ctrl, `三档应递减:${big} > ${inner} > ${ctrl}`);
assert.ok(ctrl <= 0.6, `控件档要明显更透(当前 ${ctrl}`);
});
test('★ 判据自检:拿重构前的写法喂进来必须判红', () => {
const oldSidebar = `{ short: '收件', mode: 'inbox', Icon: InboxIcon },
{ short: '授权', title: '授权请求', mode: 'permissions', Icon: ShieldIcon }`;
assert.ok(oldSidebar.includes("'授权请求'"), '旧写法能被本判据的"必须消失"条款命中');
const oldChip = "'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'";
assert.ok(!/glass-control/.test(oldChip), '旧胶囊写法不含 glass-control ⇒ 会判红');
});