From b5a67ff65060d5225e3d58df1be2dc4163581f98 Mon Sep 17 00:00:00 2001 From: duanshuwen Date: Tue, 14 Apr 2026 23:38:42 +0800 Subject: [PATCH] feat: implement custom window controls and replace header bar with title bar - Add window handlers for minimize, maximize, close, and check if maximized in ipcMain. - Update preload script to use new window control IPC events. - Refactor window service to remove old IPC event handlers and use new handlers. - Remove old HeaderBar and DragRegion components, replacing them with a new TitleBar component. - Update Layout component to use TitleBar instead of HeaderBar. - Remove useWinManager hook as its functionality is now integrated into TitleBar. - Update login page to remove HeaderBar and adjust layout accordingly. - Update constants to remove old window IPC events. - Update package dependencies to replace @iconify/vue with @lucide/vue. --- dist-electron/main/main.js | 123 ++++--- dist-electron/preload/preload.js | 14 +- dist/index.html | 6 +- docs/WindowControlsMigrationPlan.md | 423 +++++++++++++++++++++++ electron/ipc/window-handlers.ts | 23 ++ electron/preload/index.ts | 12 +- electron/service/window-service/index.ts | 38 +- electron/wins/index.ts | 2 + global.d.ts | 23 +- package-lock.json | 42 +-- package.json | 3 +- pnpm-lock.yaml | 40 +-- src/components/DragRegion/index.vue | 13 - src/components/HeaderBar/index.vue | 76 ---- src/components/Layout/TitleBar/index.vue | 60 ++++ src/components/Layout/index.vue | 17 +- src/hooks/useWinManager.ts | 31 -- src/lib/constants.ts | 4 - src/main.ts | 4 - src/pages/login/index.vue | 28 +- 20 files changed, 642 insertions(+), 340 deletions(-) create mode 100644 docs/WindowControlsMigrationPlan.md create mode 100644 electron/ipc/window-handlers.ts delete mode 100644 src/components/DragRegion/index.vue delete mode 100644 src/components/HeaderBar/index.vue create mode 100644 src/components/Layout/TitleBar/index.vue delete mode 100644 src/hooks/useWinManager.ts diff --git a/dist-electron/main/main.js b/dist-electron/main/main.js index 9e1dc48..1c7656d 100644 --- a/dist-electron/main/main.js +++ b/dist-electron/main/main.js @@ -22,11 +22,11 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge mod )); const electron = require("electron"); -require("js-base64"); const util = require("util"); const log = require("electron-log"); const path = require("path"); const fs = require("fs"); +require("js-base64"); const path$1 = require("node:path"); const crypto = require("crypto"); const started = require("electron-squirrel-startup"); @@ -58,10 +58,6 @@ const path__namespace = /* @__PURE__ */ _interopNamespaceDefault(path); const fs__namespace = /* @__PURE__ */ _interopNamespaceDefault(fs); var IPC_EVENTS = /* @__PURE__ */ ((IPC_EVENTS2) => { IPC_EVENTS2["EXTERNAL_OPEN"] = "external-open"; - IPC_EVENTS2["WINDOW_MINIMIZE"] = "window-minimize"; - IPC_EVENTS2["WINDOW_MAXIMIZE"] = "window-maximize"; - IPC_EVENTS2["WINDOW_CLOSE"] = "window-close"; - IPC_EVENTS2["IS_WINDOW_MAXIMIZED"] = "is-window-maximized"; IPC_EVENTS2["APP_SET_FRAMELESS"] = "app:set-frameless"; IPC_EVENTS2["APP_LOAD_PAGE"] = "app:load-page"; IPC_EVENTS2["TAB_CREATE"] = "tab:create"; @@ -175,40 +171,6 @@ var MESSAGE_ITEM_MENU_IDS = /* @__PURE__ */ ((MESSAGE_ITEM_MENU_IDS2) => { MESSAGE_ITEM_MENU_IDS2["SELECT"] = "select"; return MESSAGE_ITEM_MENU_IDS2; })(MESSAGE_ITEM_MENU_IDS || {}); -function debounce(fn, delay) { - let timer = null; - return function(...args) { - if (timer) { - clearTimeout(timer); - } - timer = setTimeout(() => { - fn.apply(this, args); - }, delay); - }; -} -function cloneDeep(obj) { - if (obj === null || typeof obj !== "object") { - return obj; - } - if (Array.isArray(obj)) { - return obj.map((item) => cloneDeep(item)); - } - const clone = Object.assign({}, obj); - for (const key in clone) { - if (Object.prototype.hasOwnProperty.call(clone, key)) { - clone[key] = cloneDeep(clone[key]); - } - } - return clone; -} -function simpleCloneDeep(obj) { - try { - return JSON.parse(JSON.stringify(obj)); - } catch (error) { - console.error("simpleCloneDeep failed:", error); - return obj; - } -} const readdirAsync = util.promisify(fs__namespace.readdir); const statAsync = util.promisify(fs__namespace.stat); const unlinkAsync = util.promisify(fs__namespace.unlink); @@ -336,6 +298,40 @@ class LogService { } } const logManager = LogService.getInstance(); +function debounce(fn, delay) { + let timer = null; + return function(...args) { + if (timer) { + clearTimeout(timer); + } + timer = setTimeout(() => { + fn.apply(this, args); + }, delay); + }; +} +function cloneDeep(obj) { + if (obj === null || typeof obj !== "object") { + return obj; + } + if (Array.isArray(obj)) { + return obj.map((item) => cloneDeep(item)); + } + const clone = Object.assign({}, obj); + for (const key in clone) { + if (Object.prototype.hasOwnProperty.call(clone, key)) { + clone[key] = cloneDeep(clone[key]); + } + } + return clone; +} +function simpleCloneDeep(obj) { + try { + return JSON.parse(JSON.stringify(obj)); + } catch (error) { + console.error("simpleCloneDeep failed:", error); + return obj; + } +} const DEFAULT_CONFIG = { [CONFIG_KEYS.THEME_MODE]: "system", [CONFIG_KEYS.PRIMARY_COLOR]: "#BB5BE7", @@ -534,10 +530,13 @@ class ThemeService { } } const themeManager = ThemeService.getInstance(); +const isMac = process.platform === "darwin"; +const isWindows = process.platform === "win32"; +const useCustomTitleBar = isWindows; const SHARED_WINDOW_OPTIONS = { - frame: false, - titleBarStyle: "hidden", - trafficLightPosition: { x: -100, y: -100 }, + frame: isMac || !useCustomTitleBar, + titleBarStyle: isMac ? "hiddenInset" : useCustomTitleBar ? "hidden" : "default", + trafficLightPosition: isMac ? { x: 16, y: 16 } : void 0, show: false, title: "NIANXX", darkTheme: themeManager.isDark, @@ -574,24 +573,6 @@ class WindowService { return true; } _setupIpcEvents() { - const handleCloseWindow = (e) => { - const target = electron.BrowserWindow.fromWebContents(e.sender); - const winName = this.getName(target); - this.close(target, this._isReallyClose(winName)); - }; - const handleMinimizeWindow = (e) => { - electron.BrowserWindow.fromWebContents(e.sender)?.minimize(); - }; - const handleMaximizeWindow = (e) => { - this.toggleMax(electron.BrowserWindow.fromWebContents(e.sender)); - }; - const handleIsWindowMaximized = (e) => { - return electron.BrowserWindow.fromWebContents(e.sender)?.isMaximized() ?? false; - }; - electron.ipcMain.on(IPC_EVENTS.WINDOW_CLOSE, handleCloseWindow); - electron.ipcMain.on(IPC_EVENTS.WINDOW_MINIMIZE, handleMinimizeWindow); - electron.ipcMain.on(IPC_EVENTS.WINDOW_MAXIMIZE, handleMaximizeWindow); - electron.ipcMain.handle(IPC_EVENTS.IS_WINDOW_MAXIMIZED, handleIsWindowMaximized); electron.ipcMain.handle(IPC_EVENTS.APP_LOAD_PAGE, (e, page) => { const win = electron.BrowserWindow.fromWebContents(e.sender); if (win) this._loadPage(win, page); @@ -625,16 +606,13 @@ class WindowService { return window2; } _setupWinLifecycle(window2, name) { - const updateWinStatus = debounce(() => !window2?.isDestroyed() && window2?.webContents?.send(IPC_EVENTS.WINDOW_MAXIMIZE + "back", window2?.isMaximized()), 80); window2.once("closed", () => { this._winStates[name].onClosed.forEach((callback) => callback(window2)); window2?.destroy(); - window2?.removeListener("resize", updateWinStatus); this._winStates[name].instance = void 0; this._winStates[name].isHidden = false; logManager.info(`Window closed: ${name}`); }); - window2.on("resize", updateWinStatus); return this; } _listenWinReady(params) { @@ -1092,6 +1070,24 @@ class TabManager { }; } } +function registerWindowHandlers(mainWindow) { + electron.ipcMain.handle("window:minimize", () => { + mainWindow.minimize(); + }); + electron.ipcMain.handle("window:maximize", () => { + if (mainWindow.isMaximized()) { + mainWindow.unmaximize(); + } else { + mainWindow.maximize(); + } + }); + electron.ipcMain.handle("window:close", () => { + mainWindow.close(); + }); + electron.ipcMain.handle("window:isMaximized", () => { + return mainWindow.isMaximized(); + }); +} const handleTray = (minimizeToTray) => { if (minimizeToTray) { trayManager.create(); @@ -1184,6 +1180,7 @@ function setupMainWindow() { }); handleTray(minimizeToTray); registerMenus(mainWindow); + registerWindowHandlers(mainWindow); const tabManager = new TabManager(mainWindow); tabManager.enable(); mainWindow.on("closed", () => { diff --git a/dist-electron/preload/preload.js b/dist-electron/preload/preload.js index 274c56d..d7b783e 100644 --- a/dist-electron/preload/preload.js +++ b/dist-electron/preload/preload.js @@ -2,10 +2,6 @@ const electron = require("electron"); var IPC_EVENTS = /* @__PURE__ */ ((IPC_EVENTS2) => { IPC_EVENTS2["EXTERNAL_OPEN"] = "external-open"; - IPC_EVENTS2["WINDOW_MINIMIZE"] = "window-minimize"; - IPC_EVENTS2["WINDOW_MAXIMIZE"] = "window-maximize"; - IPC_EVENTS2["WINDOW_CLOSE"] = "window-close"; - IPC_EVENTS2["IS_WINDOW_MAXIMIZED"] = "is-window-maximized"; IPC_EVENTS2["APP_SET_FRAMELESS"] = "app:set-frameless"; IPC_EVENTS2["APP_LOAD_PAGE"] = "app:load-page"; IPC_EVENTS2["TAB_CREATE"] = "tab:create"; @@ -69,11 +65,11 @@ const api = { external: { open: (url) => electron.ipcRenderer.invoke("external-open", url) }, - closeWindow: () => electron.ipcRenderer.send(IPC_EVENTS.WINDOW_CLOSE), - minimizeWindow: () => electron.ipcRenderer.send(IPC_EVENTS.WINDOW_MINIMIZE), - maximizeWindow: () => electron.ipcRenderer.send(IPC_EVENTS.WINDOW_MAXIMIZE), - onWindowMaximized: (callback) => electron.ipcRenderer.on(IPC_EVENTS.WINDOW_MAXIMIZE + "back", (_, isMaximized) => callback(isMaximized)), - isWindowMaximized: () => electron.ipcRenderer.invoke(IPC_EVENTS.IS_WINDOW_MAXIMIZED), + platform: process.platform, + windowMinimize: () => electron.ipcRenderer.invoke("window:minimize"), + windowMaximize: () => electron.ipcRenderer.invoke("window:maximize"), + windowClose: () => electron.ipcRenderer.invoke("window:close"), + windowIsMaximized: () => electron.ipcRenderer.invoke("window:isMaximized"), viewIsReady: () => electron.ipcRenderer.send(IPC_EVENTS.RENDERER_IS_READY), app: { setFrameless: (route) => electron.ipcRenderer.invoke(IPC_EVENTS.APP_SET_FRAMELESS, route), diff --git a/dist/index.html b/dist/index.html index 5cb195e..693abfa 100644 --- a/dist/index.html +++ b/dist/index.html @@ -6,10 +6,10 @@ - - + +
diff --git a/docs/WindowControlsMigrationPlan.md b/docs/WindowControlsMigrationPlan.md new file mode 100644 index 0000000..9b12458 --- /dev/null +++ b/docs/WindowControlsMigrationPlan.md @@ -0,0 +1,423 @@ +# Window Controls 迁移计划(对齐 ClawX) + +> 目标:将 zn-ai 的窗口控制体系**全面对齐 ClawX**,废弃 zn-ai 现有相关代码,按平台重建跨平台标题栏方案。UI 视觉延用 zn-ai 设计,图标库统一使用 `@lucide/vue`。 + +--- + +## 1. 现状分析 + +### 1.1 ClawX 实现思路 + +| 平台 | 方案 | 关键配置 | +|------|------|----------| +| **macOS** | 原生 traffic lights | `titleBarStyle: 'hiddenInset'` | +| **Windows** | 自定义 React 标题栏 | `titleBarStyle: 'hidden'` + `frame: false` | +| **Linux** | 保留原生窗口边框 | `frame: true`(默认) | + +- **macOS**:前端仅渲染一个 `drag-region`(高度 40px),红绿灯由系统原生绘制。 +- **Windows**:前端自定义最小化、最大化/恢复、关闭三个按钮,通过 `invoke` IPC 调用主进程 `BrowserWindow` API。 +- **Linux**:出于 IME 兼容性考虑,不隐藏原生边框,前端不渲染任何自定义标题栏。 + +### 1.2 zn-ai 现状(待废弃) + +| 项 | 现状 | 决策 | +|----|------|------| +| 主进程窗口配置 | 所有平台统一 `frame: false` + `titleBarStyle: 'hidden'` | **废弃,重写** | +| 前端组件 | `HeaderBar/index.vue` 同时包含 macOS 自定义按钮 + Windows 自定义按钮 | **废弃,删除** | +| 窗口管理 Hook | `useWinManager.ts` 封装 `ref` + `onMounted` 状态管理 | **废弃,删除** | +| 拖拽区域组件 | `DragRegion/index.vue` 独立组件 | **废弃,删除**(直接内联到 TitleBar) | +| Layout 集成 | `Layout/index.vue` 嵌套 `header-bar` + `drag-region` | **重写** | +| IPC 通信 | `WINDOW_CLOSE` / `WINDOW_MINIMIZE` / `WINDOW_MAXIMIZE` / `IS_WINDOW_MAXIMIZED`(`send`/`on` 混合) | **废弃,改为 `invoke/handle` 对齐 ClawX** | +| 图标库 | `@iconify/vue` + `@iconify-json/material-symbols` | **废弃,替换为 `@lucide/vue`** | + +### 1.3 核心差异 + +1. **macOS**:ClawX 使用原生 traffic lights;zn-ai 使用自定义按钮(将原生按钮移出可视区域),行为不完整。 +2. **Linux**:ClawX 保留原生边框;zn-ai 隐藏了原生边框,存在 IME 兼容性风险。 +3. **IPC 模式**:ClawX 使用 `ipcMain.handle` + `invokeIpc`(请求-响应),zn-ai 使用 `ipcMain.on` + `ipcRenderer.send`(事件驱动),模式不统一。 +4. **组件层级**:ClawX 的 `TitleBar` 是一个自包含组件,内部自带 `drag-region`/`no-drag` 逻辑;zn-ai 将其拆成了 `HeaderBar` + `DragRegion` + `useWinManager` 三个文件,过度拆分。 + +--- + +## 2. 迁移方案 + +### 2.1 设计原则 + +- **全面对齐 ClawX**:架构、IPC 模式、平台分支逻辑直接复刻 ClawX 实现。 +- **zn-ai 既有代码可抛弃**:`HeaderBar`、`DragRegion`、`useWinManager` 及相关 IPC 直接删除,不用做兼容改造。 +- **UI 视觉延用 zn-ai**:Windows 按钮的 hover 颜色、尺寸保持 zn-ai 现有设计(`#999` / `#ff0000`)。 +- **图标统一使用 `@lucide/vue`**:废弃 `@iconify/vue`。 + +### 2.2 废弃清单(Delete List) + +| 文件/目录 | 说明 | +|-----------|------| +| `src/components/HeaderBar/index.vue` | 既有自定义按钮组件,完全废弃 | +| `src/components/DragRegion/index.vue` | 拖拽区域组件,逻辑内联到新的 `TitleBar` | +| `src/hooks/useWinManager.ts` | 窗口管理 Hook,逻辑内联到新的 `TitleBar` | +| `src/main.ts` 中 `HeaderBar` / `DragRegion` 的全局注册 | 删除全局组件注册 | + +### 2.3 废弃的 IPC 与常量 + +zn-ai 现有窗口控制 IPC(基于 `send`/`on`): +```ts +// constants.ts 中废弃以下事件 +WINDOW_MINIMIZE = 'window-minimize' +WINDOW_MAXIMIZE = 'window-maximize' +WINDOW_CLOSE = 'window-close' +IS_WINDOW_MAXIMIZED = 'is-window-maximized' +``` + +**废弃后替换为 ClawX 风格的 `invoke` channel**: +```ts +// 新增(对齐 ClawX) +'window:minimize' +'window:maximize' +'window:close' +'window:isMaximized' +``` + +> 若项目中其他模块也使用了旧的 `WINDOW_MINIMIZE` 等常量,需要一并迁移。经排查,旧常量仅在 `HeaderBar` / `useWinManager` / `window-service` / `preload` 中使用,可随本次重构一并删除。 + +### 2.4 新建/重写清单(Create/Rewrite List) + +| 新建/重写项 | 说明 | +|-------------|------| +| `src/components/layout/TitleBar/index.vue` | **新建**。对齐 ClawX `TitleBar.tsx`,Vue 实现。 | +| `src/components/layout/Layout/index.vue` | **重写**。移除 `header-bar`/`drag-region` 引用,改用 `TitleBar`。 | +| `electron/service/window-service/index.ts` | **重写 `SHARED_WINDOW_OPTIONS`**。按平台设置 `frame`/`titleBarStyle`/`trafficLightPosition`。 | +| `electron/main.ts` 或新建 `electron/main/ipc-handlers.ts` | **新增/重写**。注册 `window:*` 的 `ipcMain.handle`。 | +| `electron/preload/index.ts` | **重写**。移除旧 `send/on` API,新增 `invoke('window:*')` 和 `platform`。 | +| `src/lib/api-client.ts`(或类似文件) | **新增 `invokeIpc` 辅助函数**。对齐 ClawX 的 `invokeIpc` 调用风格。 | +| `src/styles/index.css` | 保留 `.drag-region` / `.no-drag`,供 `TitleBar` 使用。 | + +### 2.5 主进程窗口配置改造 + +**目标代码(对齐 ClawX)**: +```ts +// electron/service/window-service/index.ts +const isMac = process.platform === 'darwin'; +const isWindows = process.platform === 'win32'; +const useCustomTitleBar = isWindows; + +const SHARED_WINDOW_OPTIONS = { + frame: isMac || !useCustomTitleBar, + titleBarStyle: isMac ? 'hiddenInset' : useCustomTitleBar ? 'hidden' : 'default', + trafficLightPosition: isMac ? { x: 16, y: 16 } : undefined, + show: false, + title: 'NIANXX', + darkTheme: themeManager.isDark, + backgroundColor: themeManager.isDark ? '#2C2C2C' : '#FFFFFF', + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + backgroundThrottling: false, + preload: MAIN_WINDOW_VITE_DEV_SERVER_URL + ? path.join(process.cwd(), 'dist-electron/preload/preload.js') + : path.join(__dirname, 'preload.js'), + }, +} as BrowserWindowConstructorOptions; +``` + +**注意**: +- 删除旧的 `trafficLightPosition: { x: -100, y: -100 }`。 +- macOS 使用 `hiddenInset` 后,红绿灯由系统原生绘制,前端无需硬编码左侧占位。 + +### 2.6 新增 IPC Handlers(对齐 ClawX) + +新建或复用文件(建议新建 `electron/ipc/window-handlers.ts`,方便模块化): + +```ts +import { ipcMain, BrowserWindow } from 'electron'; + +export function registerWindowHandlers(mainWindow: BrowserWindow): void { + ipcMain.handle('window:minimize', () => { + mainWindow.minimize(); + }); + + ipcMain.handle('window:maximize', () => { + if (mainWindow.isMaximized()) { + mainWindow.unmaximize(); + } else { + mainWindow.maximize(); + } + }); + + ipcMain.handle('window:close', () => { + mainWindow.close(); + }); + + ipcMain.handle('window:isMaximized', () => { + return mainWindow.isMaximized(); + }); +} +``` + +在 `electron/main.ts` 或 `electron/wins/index.ts` 的适当时机调用 `registerWindowHandlers(mainWindow)`。 + +### 2.7 Preload 改造(对齐 ClawX `invokeIpc` 风格) + +```ts +// electron/preload/index.ts +const api: WindowApi = { + // ... 保留既有 API + + platform: process.platform, + + // 窗口控制 — 对齐 ClawX 的 invoke 风格 + windowMinimize: () => ipcRenderer.invoke('window:minimize'), + windowMaximize: () => ipcRenderer.invoke('window:maximize'), + windowClose: () => ipcRenderer.invoke('window:close'), + windowIsMaximized: () => ipcRenderer.invoke('window:isMaximized'), + + // 移除旧的 minimizeWindow / maximizeWindow / closeWindow / onWindowMaximized / isWindowMaximized +} +``` + +### 2.8 前端 `invokeIpc` 辅助函数(可选,强烈建议) + +ClawX 使用统一的 `invokeIpc` 函数调用主进程,zn-ai 建议新增该辅助函数以保持一致风格: + +```ts +// src/lib/api-client.ts +export function invokeIpc(channel: string, ...args: any[]): Promise { + return window.api.invoke(channel, ...args); +} +``` + +> 如果 zn-ai 已有类似封装(如 `window.api.invoke`),可直接使用,无需额外文件。 + +### 2.9 新建 TitleBar 组件(对齐 ClawX) + +**新建文件**:`src/components/layout/TitleBar/index.vue` + +```vue +