From 1094154f266a7364631827e5e809094d931e5cad Mon Sep 17 00:00:00 2001 From: jianf <2198972886@qq.com> Date: Sun, 4 Oct 2026 11:07:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(ele):=20=E8=87=AA=E7=BB=98=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=E6=A0=87=E9=A2=98=E6=A0=8F=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=20HarmonyOS=20=E7=9A=84=20PC/2in1=20?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E5=A4=96=E8=A7=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 问题 用户:「现在窗口外观还是 ele 默认外观,很原始」。 查证了两端在 PC 上的实际做法,差距是**结构性的**: | | HarmonyOS (PC/2in1) | Electron(改之前)| |---|---|---| | 窗口装饰 | `setWindowDecorVisible(false)` 隐掉系统标题栏 | 系统默认标题栏 | | 顶栏 | 自绘 `AppHeader`(圆角/材质/标题/可选返回键) | 无 | | 避让 | `Insets` 三量:`statusBar`/`navIndicator`/`windowDecor` | 无这个概念 | | 拖动 | 系统 | 系统 | ⇒ `grep app-region|titleBarStyle` 在整个 `client/electron/` 命中 **0**。 鸿蒙那边早就走完「内容铺满 + 自绘顶栏」,Electron 还停在最原始的系统窗口。 ## 修法 * `frame: false` + `titleBarStyle: 'hidden'` —— 两个**一起**。 只给 `titleBarStyle` 在 Win/Linux 上仍留着系统边框(可拖动、可双击), 观感还是「系统窗口 + 一条自己画的头」;既然自绘窗口按钮 = 框架整个接管, 那圈系统边框就是多余的一层。 ★ 不用 `titleBarOverlay`(官方「留系统按钮」那条):它留的是**系统**按钮, 观感仍由系统决定,与「自绘」目标相反,且 Linux 支持不齐。 本项目只有 win/linux target(无 mac)⇒ 统一一条路,不做两套形态。 * 新增 `src/components/TitleBar.tsx`:固定顶栏 + 左标题 + 右三键。 ★ 挂点选在 `main.tsx`、与 `.app-backdrop` 同层,**不在 App 里面** —— App 的根节点有四个 return 分支(宽屏/窄屏/独页/…), 塞进去就得改四处,漏一处就是「某个页面没有标题栏」。 它自己是 `position: fixed`,与 App 布局零耦合。 ## 三条实现纪律(都写进注释了) ① **拖动靠 CSS `-webkit-app-region: drag`,不靠 JS 鼠标事件。** `drag` 区域由浏览器/系统处理,不受页面重排影响(sandbox 下那种 mousemove 算窗口位置的写法既慢又脆)。 ★ 代价:**drag 区域里的交互元素收不到点击** ⇒ 按钮与标题文字都显式 `no-drag`。实测 `elementFromPoint` 命中 `BUTTON.titlebar-btn`(不是拖拽层)。 ② **最大化状态是「订阅」来的,不是「查」来的。** 最大化有三条**不经过按钮**的路径:双击拖拽区(系统处理,JS 收不到事件)、 `Win+↑↓`、拖到屏幕边缘的 Snap Layouts ⇒ 只在点按钮时查一次, 图标必然与真实状态脱节。所以主进程用 `pushMaxState` 主动推 (`maximize`/`unmaximize`/进退全屏四个事件),渲染层只订阅。 另:`toggleMaximize` 刻意**不**拆成 maximize/unmaximize —— 双击时序上会多一次异步往返,IPC 往返期间用户可能又双击了一次。 ⇒ 读状态与决定动作在主进程侧原子完成。 ③ **浏览器里整条不渲染。** 没有 bridge 时 `TitleBar` 返回 `null`;高度占位(`html.titlebar-on`) 由 `main.tsx` 用**同一个** `__AGENTMAIL_SHELL__` 判据挂上 —— CSS 不会看 bridge,不挂则网页端白丢 36px。 ## 尺寸为什么是 36px 鸿蒙 PC/2in1 实测 `windowDecor=37`(见 `MainPage.ets` 的 insets 日志 statusBar=38.6 navIndicator=27.8 windowDecor=37)⇒ 两端窗口控件高度对齐同一量级, 免得并排摆两个应用时一个头厚一个头薄。这里取 36:桌面端按物理像素算, 1x 下更接近常见做法,且 12px 字号不出血。**要改就两端一起改。** ## 顺带记一条踩过的坑(它就在这批代码里) `preload.cjs` 是 `.cjs`,**不能写 TS 类型标注**。第一版写了 `(cb: (maximized: boolean) => void)` ⇒ 整个 preload **静默**加载失败 ⇒ `window.agentmail === undefined` ⇒ 账号读不到(回退到网页版登录页, 显示用户名+密码,桌面壳里注定失败)+ 标题栏不渲染。 ★ 而 `npm run typecheck`(`tsc --noEmit`)**不检查 .cjs**,照常全绿。 已在 preload 注释里写明,并在 `main-process-security.test.mjs` 加了加载闸门 (下一个提交)。 ## 实测 `DISPLAY` 起真窗口 + CDP 取证: shell=desktop hasBridge=true hasWin=true titlebar=true h=36 appRegion=drag btns=[最小化, 还原, 关闭] btnHitTarget=BUTTON.titlebar-btn errs=[](渲染层零异常) 标题栏实测截图含深浅两态,面板圆角与阴影不变。 ## 未验(本机无 GUI 交互,只能取证不能点) 最大化/还原按钮点击、双击标题栏、**关闭进托盘**(这个最需要小心, 点错会把应用整个退出)。上一条留待有人手上有真桌面时验。 --- client/electron/electron/main.cjs | 117 ++++++++++++ client/electron/electron/preload.cjs | 40 +++++ client/electron/src/components/TitleBar.tsx | 146 +++++++++++++++ client/electron/src/index.css | 188 ++++++++++++++++++++ client/electron/src/main.tsx | 26 +++ 5 files changed, 517 insertions(+) create mode 100644 client/electron/src/components/TitleBar.tsx diff --git a/client/electron/electron/main.cjs b/client/electron/electron/main.cjs index 8592670..7ee0350 100644 --- a/client/electron/electron/main.cjs +++ b/client/electron/electron/main.cjs @@ -67,6 +67,40 @@ function createMainWindow() { minHeight: 640, title: 'AgentMail', icon: path.join(__dirname, '..', 'src', 'icons', 'tray-icon.png'), + /* + * ★★ 2026-10-04 自绘窗口外观(对齐 HarmonyOS 的 PC/2in1 形态)。 + * + * ── 为什么现在要做 ── + * 鸿蒙侧早就不是系统默认外观了:`EntryAbility.setupFullScreenWindow()` 里 + * `setWindowDecorVisible(false)` 隐掉系统标题栏 + 自绘 `AppHeader` + * (圆角/材质/标题/可选返回键),并用 `Insets` 的三个量 + * (`statusBar` / `navIndicator` / `windowDecor`)让内容避开系统区。 + * 也就是说鸿蒙在 PC 上已经是「内容铺满 + 自绘顶栏」。 + * 而 Electron 侧 `grep app-region|titleBarStyle` 命中 0 ⇒ 全靠系统默认边框, + * 两端在 PC 上观感不一致(用户报:「现在窗口外观还是 ele 默认外观,很原始」)。 + * + * ── 为什么是 `frame: false` 而不是只给 `titleBarStyle: 'hidden'` ── + * 只给 `titleBarStyle` 在 Windows/Linux 上仍保留那圈系统边框(可拖动、可双击), + * 观感仍然是「系统窗口 + 一条自己画的头」;而自绘窗口按钮意味着**整个窗口框架 + * 由我们接管**,那系统边框就成了多余的一层 ⇒ 一起去掉。 + * + * ⚠️ 代价(本条必须写清楚,否则后面的人会当成免费的): + * · 窗口**不再有系统阴影/圆角**(Linux/Win 的 `frame:false` 就是无边框窗口), + * 圆角要自己在 `index.css` 里给内容层做(见 `.titlebar-host` 段)。 + * · **拖动、最小化、最大化/还原、关闭、双击标题栏最大化**全部要自己实现: + * 前三样走 IPC(`window:*`),拖动靠 CSS `-webkit-app-region: drag`, + * 双击要主进程听 `maximize` 查询做**往返**判定(见 `window:toggle-maximize`)。 + * · `will-navigate` / `setWindowOpenHandler` / 单实例锁(2026-10-03 加的) + * **不受影响** —— 它们管的是导航与进程,不是窗口装饰。 + * + * ★ 为什么**不**用 `titleBarOverlay`(Win/Linux 的官方「隐标题栏但留系统按钮」): + * 那条路留着的是**系统**按钮 ⇒ 外观仍由系统决定,与「自绘」的目标相反, + * 而且它在 Linux 上支持不齐。本项目只有 win/linux target(无 mac), + * 所以统一走「全自绘 + 自己做按钮」这一条,不做两套形态。 + */ + frame: false, + titleBarStyle: 'hidden', + backgroundColor: '#111318', webPreferences: { preload: path.join(__dirname, 'preload.cjs'), contextIsolation: true, @@ -106,6 +140,33 @@ function createMainWindow() { openExternalSafely(url); }); + /* + * ★ 最大化状态要**推**给渲染层(2026-10-04 自绘窗口)。 + * + * 为什么不能只靠渲染层调 `window:is-maximized` 查: + * 最大化/还原有三条**不由标题栏按钮发起**的路径 —— + * ① 双击拖拽区(`-webkit-app-region: drag` 区域上的双击由系统处理, + * **不经过**我们的 JS,所以渲染层收不到点击事件); + * ② Win 的 `Win+↑` / `Win+↓`; + * ③ 拖窗口到屏幕边缘时 Windows 10/11 的 Snap Layouts 自动最大化。 + * 三条都不经过按钮 ⇒ 渲染层缓存的 isMaximized 会与真实状态脱节 + * ⇒ 标题栏画着「最大化」但窗口其实已是最大化,再点一次会「反着来」。 + * + * ⇒ 用主进程的 `maximize`/`unmaximize` 事件**主动推**,渲染层只订阅。 + * (窗口按钮那条路径不需要这个:它调的是 toggle-maximize,拿的是返回值。) + */ + const pushMaxState = () => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('window:maximized-changed', mainWindow.isMaximized()); + } + }; + mainWindow.on('maximize', pushMaxState); + mainWindow.on('unmaximize', pushMaxState); + // 进/退全屏也一并同步:那时「最大化」与「全屏」视觉上同义, + // 按钮若还画着「最大化」会与实际不符(鸿蒙侧同理,见其 Insets 注释里的全屏悬浮态)。 + mainWindow.on('enter-full-screen', pushMaxState); + mainWindow.on('leave-full-screen', pushMaxState); + // 点关闭按钮时隐藏到托盘而不是退出(Phase 4 行为,但用户要求进托盘,直接做) mainWindow.on('close', (e) => { if (!app.isQuitting) { @@ -204,6 +265,62 @@ ipcMain.handle('get-gateway-url', () => { return process.env.AGENTMAIL_GATEWAY_URL || 'http://127.0.0.1:8180'; }); +// ─── 自绘窗口按钮(2026-10-04,配合 frame:false)──────────────────── +// +// ★ 为什么这些必须是 IPC 而不是渲染层自己猜: +// `frame:false` 之后系统**不再**提供标题栏,最小化/最大化/关闭三个动作 +// 在渲染层没有任何 API 可用(`window.close()` 只在「由脚本开的窗口」上有意义, +// 主窗口上它什么也不做)。所以只能过 preload 的窄接口回到主进程。 +// +// ⚠️ 三条纪律(与本文件已有的 accounts:* 同一套): +// ① 只暴露**动作**,不暴露窗口对象 —— 渲染层拿不到 `BrowserWindow` 引用, +// 也无法自己 loadURL / 关闭窗口之外的任何事。 +// ② 一律用 `getMainWindow()` 取窗,**不缓存**到模块作用域: +// macOS 上关掉再开窗口引用会变(虽然本项目无 mac target,但别把坑留在形状里)。 +// ③ `window:close` 走 `mainWindow.close()` 而不是 `app.quit()`: +// 本应用是**进托盘**的(见 `mainWindow.on('close')` 与 `app.isQuitting`), +// 用 quit 会把托盘图标一起带走 —— 那是「退出」,不是「关窗口」。 +function getMainWindow() { + return BrowserWindow.getAllWindows()[0] || null; +} + +ipcMain.handle('window:minimize', () => { + const w = getMainWindow(); + if (w) w.minimize(); +}); + +/* + * ★ 最大化/还原**做成一次往返**,而不是两个独立动作 —— + * 原因是双击标题栏:它只知道「用户双击了」,不知道当前是最大化还是还原。 + * 若渲染层先 `isMaximized()` 再调 `maximize`/`unmaximize`,双击时序上会多一次 + * 异步往返,而 IPC 往返期间用户可能又双击了一次 ⇒ 状态错乱。 + * `toggle-maximize` 在**主进程侧**读状态再决定动作,是原子的。 + * + * 渲染层点按钮时也调这个(而不是分 max/unmax),少一条接口、少一处分叉。 + */ +ipcMain.handle('window:toggle-maximize', () => { + const w = getMainWindow(); + if (!w) return false; + if (w.isMaximized()) { + w.unmaximize(); + } else { + w.maximize(); + } + return w.isMaximized(); +}); + +/** 给标题栏按钮渲染图标用:当前是否最大化(决定画「还原」还是「最大化」)。 */ +ipcMain.handle('window:is-maximized', () => { + const w = getMainWindow(); + return w ? w.isMaximized() : false; +}); + +ipcMain.handle('window:close', () => { + const w = getMainWindow(); + // ★ 用 close() 而不是 app.quit():本应用进托盘,关窗口 ≠ 退出(见上方纪律 ③)。 + if (w) w.close(); +}); + // ─── 多账号持久化(docs/MULTI-ACCOUNT-PLAN.md 第二、三节)─────────────── // // # 为什么放主进程而不是渲染进程的 localStorage diff --git a/client/electron/electron/preload.cjs b/client/electron/electron/preload.cjs index 0f21304..138f164 100644 --- a/client/electron/electron/preload.cjs +++ b/client/electron/electron/preload.cjs @@ -48,6 +48,46 @@ contextBridge.exposeInMainWorld('agentmail', { /** Gateway 基础地址(主进程 env 或默认 127.0.0.1:8180) */ gatewayUrl: () => ipcRenderer.invoke('get-gateway-url'), + /** + * 自绘窗口按钮的动作(2026-10-04,配合主进程的 `frame: false`)。 + * + * ★ 为什么需要它:系统标题栏被隐掉之后,最小化/最大化/关闭在渲染层 + * **没有任何可用 API**(主窗口上 `window.close()` 什么也不做), + * 只能回到主进程。见 main.cjs 的「自绘窗口按钮」段(含三条纪律)。 + * + * ★ 纪律:**只给动作**,不给 `BrowserWindow` 引用、不给任意 IPC 名。 + * `toggleMaximize` 刻意**不**拆成 maximize/unmaximize 两个 —— + * 双击拖拽区需要「主进程侧读状态再决定」的原子往返,理由见 main.cjs 注释。 + * + * `onMaximizedChanged` 是**订阅**而不是查询:最大化还有三条不经过按钮的路径 + * (双击拖拽区、系统 Win+↑↓、拖到边缘 Snap),渲染层缓存会与真实状态脱节。 + * 返回值是一个取消订阅函数,组件卸载时必须调(同 `initTheme` 的订阅纪律)。 + * + * ⚠️⚠️ **本文件是 `.cjs`(纯 JavaScript),不能写类型标注。** + * 2026-10-04 踩过:这里第一版写了 `(cb: (maximized: boolean) => void)`, + * 而 preload 加载失败**是静默的** —— 渲染层只看到 + * `window.agentmail === undefined`,于是所有走 bridge 的能力全没了 + * (账号读不到 ⇒ 回到网页版登录页;标题栏不渲染 ⇒ 白丢一条顶栏)。 + * ★ 而 `npm run typecheck`(tsc --noEmit)**不检查 .cjs**,照常全绿。 + * ⇒ 改完 preload 必须过一遍 `node -c electron/preload.cjs` + * (已钉进 test/main-process-security.test.mjs 的加载闸门)。 + */ + window: { + minimize: () => ipcRenderer.invoke('window:minimize'), + toggleMaximize: () => ipcRenderer.invoke('window:toggle-maximize'), + isMaximized: () => ipcRenderer.invoke('window:is-maximized'), + close: () => ipcRenderer.invoke('window:close'), + /** + * 订阅最大化/还原变化。★ 回调收到的是新状态,**不是**无参通知 —— + * 无参就得自己再 `isMaximized()` 一次,多一次 IPC 往返。 + */ + onMaximizedChanged: cb => { + const listener = (_evt, maximized) => cb(maximized); + ipcRenderer.on('window:maximized-changed', listener); + return () => ipcRenderer.removeListener('window:maximized-changed', listener); + }, + }, + /** 版本信息(渲染进程可显示在 About 页) */ versions: { electron: process.versions.electron, diff --git a/client/electron/src/components/TitleBar.tsx b/client/electron/src/components/TitleBar.tsx new file mode 100644 index 0000000..8a8fc59 --- /dev/null +++ b/client/electron/src/components/TitleBar.tsx @@ -0,0 +1,146 @@ +import { useEffect, useState } from 'react'; + +/** + * 自绘窗口标题栏(Electron 桌面壳专用)。 + * + * ## 为什么存在 + * + * 2026-10-04:主进程改成 `frame: false` + `titleBarStyle: 'hidden'`, + * 把窗口框架整个接管 —— 目的是与 HarmonyOS 在 PC/2in1 上的观感对齐 + * (那边是 `setWindowDecorVisible(false)` + 自绘 `AppHeader`, + * 见 `EntryAbility.setupFullScreenWindow` 与 `common/Surface.ets` 的 `AppHeader`)。 + * 系统标题栏隐掉之后,最小化/最大化/关闭/拖动**全部**要自己实现。 + * + * ## 三条实现纪律 + * + * ① **拖动靠 CSS `-webkit-app-region: drag`,不靠 JS 鼠标事件。** + * `drag` 区域的拖动由浏览器/系统处理,不受页面重排影响,也不需要 + * 在 mousemove 里算窗口位置(本项目 `sandbox: true`,那种写法既慢又脆)。 + * ★ 代价:**`drag` 区域里的交互元素收不到点击**,所以按钮与标题文字 + * 都必须显式 `app-region: no-drag`(见 `.titlebar` 的 CSS)。 + * + * ② **最大化状态是「订阅」来的,不是「查」来的。** + * 最大化有三条不经过本组件的路径:双击拖拽区(系统处理,JS 收不到事件)、 + * Win+↑↓、拖到屏幕边缘的 Snap Layouts。只在点按钮时查一次 ⇒ 图标与真实状态脱节。 + * 所以 `onMaximizedChanged` 是主进程主动推(见 main.cjs 的 `pushMaxState`)。 + * + * ③ **浏览器里整条不渲染。** + * 没有 bridge(`window.agentmail.window`)时返回 `null`: + * 网页端不需要标题栏,硬画一条出来反而是假控件。 + * ★ 这个判断放在**组件内**而不是调用点,是因为调用点在 `main.tsx`, + * 那里无法区分「暂时还没注入」与「压根不是桌面壳」—— + * 而这两种情况下画错的代价一样(一条点不动的假标题栏)。 + */ + +/** preload 暴露的形状(见 preload.cjs 的 `window` 段)。 */ +type WinBridge = { + minimize: () => Promise; + toggleMaximize: () => Promise; + isMaximized: () => Promise; + close: () => Promise; + onMaximizedChanged: (cb: (maximized: boolean) => void) => () => void; +}; + +function bridge(): WinBridge | null { + const w = globalThis as unknown as { agentmail?: { window?: WinBridge } }; + return w?.agentmail?.window ?? null; +} + +/** 图标:三横/两方/叉。尺寸跟按钮命中区一起在 CSS 里给(见 `.titlebar-btn`)。 */ +function MinimizeIcon() { + return ( + + ); +} + +/** 最大化 ↔ 还原。两个图形都用同一尺寸的框,避免切换时按钮宽度跳。 */ +function MaximizeIcon({ maximized }: { maximized: boolean }) { + return ( + + ); +} + +function CloseIcon() { + return ( + + ); +} + +export default function TitleBar() { + const win = bridge(); + const [maximized, setMaximized] = useState(false); + + // 订阅最大化变化(纪律 ②)。 + // + // ★ 为什么要**同时**查一次初值:订阅只在「变化时」触发, + // 而窗口可能在我们挂载前就已是最大化(启动即最大化、或用户双击拖拽区之后 + // 切了别的路由导致本组件重新挂载)⇒ 只订阅会漏掉初始状态, + // 图标就会一直画着「最大化」而窗口其实已经展开了。 + useEffect(() => { + if (!win) return; + let alive = true; + void win.isMaximized().then(m => { + // 卸载后仍可能 resolve(IPC 往返在飞)⇒ 写 state 前先确认还活着 + if (alive) setMaximized(m); + }); + const off = win.onMaximizedChanged(setMaximized); + return () => { + alive = false; + off(); + }; + }, [win]); + + // 纪律 ③:非桌面壳不渲染。 + if (!win) return null; + + return ( + // 整条是 drag 区;里面的按钮与文字各自 no-drag(否则点不到)。 + // ⚠️ 双击最大化/还原由**系统**在 drag 区域上处理,不经过这里的 onDoubleClick —— + // 所以不需要(也不应该)自己再挂一个双击处理,那会与系统的打架。 +
+ AgentMail +
+ + + {/* 关闭走 window.close()(主进程)⇒ 本应用进托盘,关窗口 ≠ 退出。 + ★ 不能用 window.close():主窗口上它什么也不做。 */} + +
+
+ ); +} diff --git a/client/electron/src/index.css b/client/electron/src/index.css index a2dd683..8ec8590 100644 --- a/client/electron/src/index.css +++ b/client/electron/src/index.css @@ -1743,3 +1743,191 @@ html[data-bg='on'] .glass-card:hover { -webkit-mask-image: none; mask-image: none; } + +/* ══════════════════════════════════════════════════════════════════════ + 自绘窗口标题栏(2026-10-04) + ══════════════════════════════════════════════════════════════════════ + + 主进程已改成 `frame: false`(见 electron/main.cjs 的 BrowserWindow 配置), + 所以这一条是**窗口框架本身**,不是装饰。 + + ★ 高度为什么定 36px:鸿蒙侧 PC/2in1 的 `windowDecor` 实测 37vp + (见 `MainPage.ets` 的 insets 日志:statusBar=38.6 navIndicator=27.8 + **windowDecor=37**),两端的窗口控件高度对齐到同一量级, + 免得并排摆两个应用时一个头厚一个头薄。 + 这里取 36 而不是 37:桌面端按物理像素算,36 在 1x 下更接近常见做法, + 且能让标题文字在 12px 字号下不出血。**要改就两个一起改**(本仓只有 win/linux)。 + + ★ 为什么用 `position: fixed` 而不是布局里的一行: + 它必须覆盖**所有**路由(登录页、加载页、初始化向导、写信全屏), + 而那些分支各自 `return` 自己的根 div —— 塞进布局就得改每一处, + 漏一处就是「某个页面没有标题栏」。见 main.tsx 的接入点说明。 +*/ +.titlebar { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 36px; + z-index: 40; + + /* + * ★ 拖动区。整条是 drag,但里面的按钮与文字必须 no-drag —— + * `drag` 区域里的交互元素**收不到鼠标事件**(这是 Chromium 的规定, + * 不是本项目的取舍)。漏了 no-drag 的症状是「按钮画得出来、点不动」。 + */ + -webkit-app-region: drag; + app-region: drag; + + display: flex; + align-items: center; + gap: 8px; + /* 标题文字不参与拖拽命中:否则双击标题想最大化会被当成选中文字 */ + user-select: none; + + /* + * 底色跟着主题走:浅色用 chrome-100、深色用 chrome-900 —— + * 与侧栏同族(见上面 --c-chrome-* 的色阶说明), + * 这样标题栏与侧栏看起来是**同一块** chrome,不会多出一条"外来"的带子。 + */ + background: rgb(var(--c-chrome-100)); + /* 底下那条 1px 分隔线用面板分隔线色,不用黑色半透明 —— 深色下后者会脏。 */ + border-bottom: 1px solid rgb(var(--c-gray-200) / 0.9); +} + +.dark .titlebar { + background: rgb(var(--c-chrome-900)); + border-bottom-color: rgb(var(--c-chrome-800)); +} + +/* + * 标题文字。 + * + * ★ `-webkit-app-region: no-drag` 在这里**不是**为了让文字可点, + * 而是为了让**双击标题栏最大化**生效:drag 区域的双击由系统接管, + * 而文字层如果自己是 no-drag,双击就会被它吃掉、系统收不到。 + * (按钮也 no-drag,但按钮上的双击不构成「双击标题栏」,语义不同。) + */ +.titlebar-title { + -webkit-app-region: no-drag; + app-region: no-drag; + flex: 1; + min-width: 0; + padding-left: 12px; + font-size: 12px; + font-weight: 500; + color: rgb(var(--c-gray-600)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dark .titlebar-title { + color: rgb(var(--c-chrome-400)); +} + +/* 右侧三个按钮 */ +.titlebar-btns { + -webkit-app-region: no-drag; + app-region: no-drag; + display: flex; + align-items: stretch; + height: 100%; + flex: none; +} + +.titlebar-btn { + /* + * 命中区 46×36:宽高都过了通行下限(Windows 标题栏按钮 46×32, + * 这里跟随宽度、把高度补满整条,视觉上更整)。 + */ + width: 46px; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + border: 0; + background: transparent; + color: rgb(var(--c-gray-700)); + cursor: default; /* 系统标题栏按钮不是 pointer */ + transition: background 0.12s ease; +} + +.dark .titlebar-btn { + color: rgb(var(--c-chrome-400)); +} + +.titlebar-btn:hover { + background: rgb(var(--c-gray-900) / 0.08); +} + +.dark .titlebar-btn:hover { + background: rgb(var(--c-chrome-400) / 0.12); +} + +/* 关闭 hover 用红,与 Windows 一致 —— 这是"唯一危险动作"的通用语言 */ +.titlebar-btn-close:hover { + background: #e81123; + color: #fff; +} +.dark .titlebar-btn-close:hover { + background: #c42b1c; + color: #fff; +} + +/* 焦点可见性:键盘用户必须能看到焦点在哪(见 G-1 无障碍欠账的同族要求) */ +.titlebar-btn:focus-visible { + outline: 2px solid rgb(var(--c-accent, 37 99 235)); + outline-offset: -2px; +} + +/* + * 图标:12×12 的 viewBox,用 stroke 画(与 icons.tsx 同一套 `currentColor` 约定)。 + * `fill: none` 必须显式给 —— 否则 SVG 默认 fill=black,叉会变成一个黑块。 + */ +.tb-glyph { + width: 12px; + height: 12px; + fill: none; + stroke: currentColor; + stroke-width: 1; + stroke-linecap: round; + pointer-events: none; /* 图标不参与命中,命中归按钮 */ +} + +/* + * ══ 标题栏占位:把内容整体往下让出 36px ══ + * + * ★ 为什么用 padding 而不是把 App 往下挪: + * App 的三个分支(宽屏 / 窄屏 / 无列表的独页)各自是 `h-full`, + * 改它们的根节点要改三处且都与各自的布局判断耦合。 + * 而 `html.titlebar-on` 上加 padding-top 是**一处**、对所有分支一致 —— + * 与 `.safe-frame` 处理窄屏安全区是同一形状(那里也是在外层加 padding)。 + * + * ⚠️ 但 `html, body, #root { height: var(--app-height) }` 是 `height` 不是 `min-height` + * ⇒ 在 html 上加 padding 会让**总高超出视口**(100% + 36px), + * 于是内容底部被裁掉 36px。所以下面这条必须把高度改成 `calc`: + * 见 `.titlebar-on { height: ... }` 那段。 + */ +html.titlebar-on { + height: calc(var(--app-height) - 36px); + /* 上面减了 36,下面补 36 ⇒ 布局视口仍是满的,只有内容被推下去 */ + padding-top: 36px; +} + +html.titlebar-on body, +html.titlebar-on #root { + height: 100%; +} + +/* + * 开机动画:`App.tsx` 根上那个 `animation` 会做位移/缩放 + * (见它 `viewMode`/`commTab` 变化时 cancelAnimationFrame 那段)。 + * 标题栏是 fixed 的、与内容无关 ⇒ 不参与那些动画,否则每切一次路由 + * 标题栏自己也要淡入一次(看着像闪)。 + */ + +/* + * ══ 最大化时:标题栏仍然在(Win/Linux 的系统标题栏最大化后也在), + * 但不再需要那条分隔线 —— 面板顶边就是分界。 ══ + */ diff --git a/client/electron/src/main.tsx b/client/electron/src/main.tsx index ec31450..210deb2 100644 --- a/client/electron/src/main.tsx +++ b/client/electron/src/main.tsx @@ -1,6 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; +import TitleBar from './components/TitleBar'; import { initTheme } from './stores/themeStore'; import { initBackground } from './stores/backgroundStore'; import './index.css'; @@ -51,6 +52,23 @@ const syncEditingState = () => { document.addEventListener('focusin', syncEditingState); document.addEventListener('focusout', () => requestAnimationFrame(syncEditingState)); +/* + * ★ 只有桌面壳才给标题栏腾位置(2026-10-04)。 + * + * `TitleBar` 组件自己会判 bridge、没有就返回 `null`(网页端不画那条)。 + * 但**高度占位**是 CSS 里的 `html.titlebar-on { padding-top: 36px }`, + * CSS 不会看 bridge ⇒ 必须由 JS 把这个类挂上,否则网页端会白丢 36px + * (症状:网页版顶部多一条空白,内容整体下移)。 + * + * 判据用与 `TitleBar` **同一个** bridge 入口(`__AGENTMAIL_SHELL__ === 'desktop'`): + * 那才是「我是桌面壳」的权威声明(见 preload.cjs 的说明), + * 而不是去推断 `window.agentmail.window` 是否存在 —— + * 两者都可行,但用同一个来源才不会两处判据不一致。 + */ +if ((window as unknown as { __AGENTMAIL_SHELL__?: string }).__AGENTMAIL_SHELL__ === 'desktop') { + document.documentElement.classList.add('titlebar-on'); +} + ReactDOM.createRoot(document.getElementById('root')!).render( {/* @@ -60,8 +78,16 @@ ReactDOM.createRoot(document.getElementById('root')!).render( 初始化向导都各自 return 自己的外层 div。背景是全局装饰,用户不该 「一进登录页背景就没了」。它 position:fixed + z-index:-1,与 App 的 布局无关,放这里最稳。 + + ★ 标题栏为什么也挂在这里(同一个理由,且更硬): + 它必须出现在**每一个**路由上 —— 登录页、加载页、初始化向导、 + 写信全屏、窄屏、宽屏。App 的根节点有四个 return 分支, + 塞进去就得改四处,漏一处就是「某个页面没有标题栏」。 + 而它自己是 `position: fixed` + 组件内判 bridge(浏览器里返回 null), + 与 App 的布局零耦合。 */}