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.
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
6
dist/index.html
vendored
6
dist/index.html
vendored
@@ -6,10 +6,10 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' http://8.138.234.141 https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com; connect-src 'self' http://8.138.234.141 https://api.iconify.design wss://onefeel.brother7.cn"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: http://8.138.234.141 https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com; connect-src 'self' http://8.138.234.141 https://api.iconify.design wss://onefeel.brother7.cn"
|
||||
/>
|
||||
<script type="module" crossorigin src="./assets/index-CBOhT_7U.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-Dov57n46.css">
|
||||
<script type="module" crossorigin src="./assets/index-DVErU5RB.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-CjolQV3k.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
423
docs/WindowControlsMigrationPlan.md
Normal file
423
docs/WindowControlsMigrationPlan.md
Normal file
@@ -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<any> {
|
||||
return window.api.invoke(channel, ...args);
|
||||
}
|
||||
```
|
||||
|
||||
> 如果 zn-ai 已有类似封装(如 `window.api.invoke`),可直接使用,无需额外文件。
|
||||
|
||||
### 2.9 新建 TitleBar 组件(对齐 ClawX)
|
||||
|
||||
**新建文件**:`src/components/layout/TitleBar/index.vue`
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- macOS: 仅渲染拖拽区域 -->
|
||||
<div v-if="platform === 'darwin'" class="drag-region h-10 shrink-0 border-b bg-background" />
|
||||
|
||||
<!-- Linux: 不渲染任何自定义标题栏 -->
|
||||
<template v-else-if="platform !== 'win32'" />
|
||||
|
||||
<!-- Windows: 自定义标题栏 -->
|
||||
<div v-else class="drag-region flex h-10 shrink-0 items-center justify-end border-b bg-background">
|
||||
<div class="no-drag flex h-full">
|
||||
<button
|
||||
@click="handleMinimize"
|
||||
class="flex h-full w-11 items-center justify-center text-muted-foreground hover:bg-[#999] hover:text-white transition-colors"
|
||||
title="Minimize"
|
||||
>
|
||||
<Minus class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
@click="handleMaximize"
|
||||
class="flex h-full w-11 items-center justify-center text-muted-foreground hover:bg-[#999] hover:text-white transition-colors"
|
||||
:title="maximized ? 'Restore' : 'Maximize'"
|
||||
>
|
||||
<Copy v-if="maximized" class="h-3.5 w-3.5" />
|
||||
<Square v-else class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
@click="handleClose"
|
||||
class="flex h-full w-11 items-center justify-center text-muted-foreground hover:bg-[#ff0000] hover:text-white transition-colors"
|
||||
title="Close"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Minus, Square, Copy, X } from '@lucide/vue'
|
||||
|
||||
const platform = window.api?.platform ?? '';
|
||||
const maximized = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
maximized.value = await window.api.windowIsMaximized();
|
||||
});
|
||||
|
||||
const handleMinimize = () => {
|
||||
window.api.windowMinimize();
|
||||
};
|
||||
|
||||
const handleMaximize = async () => {
|
||||
await window.api.windowMaximize();
|
||||
maximized.value = await window.api.windowIsMaximized();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
window.api.windowClose();
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
**样式说明**:
|
||||
- `.drag-region` / `.no-drag` 复用 `src/styles/index.css` 中已有定义。
|
||||
- `bg-background` / `text-muted-foreground` 等 Token 若 zn-ai 未定义,可替换为具体色值(如 `bg-white`、`text-[#525866]`),保持视觉一致即可。
|
||||
|
||||
### 2.10 Layout 重写
|
||||
|
||||
**重写文件**:`src/components/Layout/index.vue`
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="bg-color h-screen flex flex-col">
|
||||
<!-- Linux 不渲染 TitleBar -->
|
||||
<title-bar v-if="platform !== 'linux'" />
|
||||
|
||||
<main class="box-border w-full flex pt-2 pb-2 pl-2"
|
||||
:style="{ height: platform === 'linux' ? '100vh' : 'calc(100vh - 40px)' }">
|
||||
<div class="flex-1 flex">
|
||||
<slot />
|
||||
</div>
|
||||
<SideMenus />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="Layout">
|
||||
import { computed } from 'vue'
|
||||
import SideMenus from '@src/components/SideMenus/index.vue'
|
||||
import TitleBar from '@components/layout/TitleBar/index.vue'
|
||||
|
||||
const platform = computed(() => window.api?.platform ?? '')
|
||||
</script>
|
||||
```
|
||||
|
||||
**注意**:`src/main.ts` 中删除 `HeaderBar` 和 `DragRegion` 的全局注册。
|
||||
|
||||
### 2.11 Icon 体系迁移:iconify → @lucide/vue
|
||||
|
||||
**依赖变更**:
|
||||
```bash
|
||||
# 安装
|
||||
pnpm add @lucide/vue
|
||||
|
||||
# 卸载(确认项目内无其他 iconify 引用后执行)
|
||||
pnpm remove @iconify/vue @iconify-json/material-symbols
|
||||
```
|
||||
|
||||
**图标映射关系**:
|
||||
|
||||
| 功能 | 原 iconify icon | lucide-vue 替代 |
|
||||
|------|-----------------|-----------------|
|
||||
| 最小化 | `material-symbols:check-indeterminate-small` | `Minus` |
|
||||
| 最大化 | `material-symbols:chrome-maximize-outline-sharp` | `Square` |
|
||||
| 恢复 | `material-symbols:chrome-restore-outline-sharp` | `Copy` |
|
||||
| 关闭 | `material-symbols:close` | `X` |
|
||||
|
||||
**类型声明清理**:
|
||||
删除 `global.d.ts` 中 `@iconify/vue` 的 `declare module` 段落(约第 230-240 行)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 改造清单(Task Breakdown)
|
||||
|
||||
### Task 1:主进程窗口配置重写
|
||||
- **文件**:`electron/service/window-service/index.ts`
|
||||
- **内容**:
|
||||
1. 引入 `isMac` / `isWindows` 平台判断。
|
||||
2. 重写 `SHARED_WINDOW_OPTIONS` 的 `frame`、`titleBarStyle`、`trafficLightPosition`。
|
||||
3. 移除 `trafficLightPosition: { x: -100, y: -100 }`。
|
||||
- **验收标准**:
|
||||
- macOS 窗口出现原生 traffic lights。
|
||||
- Windows 窗口无原生标题栏。
|
||||
- Linux 窗口保留原生标题栏。
|
||||
|
||||
### Task 2:新增 IPC Handlers(对齐 ClawX)
|
||||
- **新建文件**:`electron/ipc/window-handlers.ts`(或写入现有 ipc 文件)
|
||||
- **内容**:
|
||||
1. 使用 `ipcMain.handle` 注册 `window:minimize`、`window:maximize`、`window:close`、`window:isMaximized`。
|
||||
2. 在 `electron/wins/index.ts` 或 `electron/main.ts` 中引入并调用 `registerWindowHandlers(mainWindow)`。
|
||||
- **验收标准**:
|
||||
- 通过 DevTools Console 测试 `await window.api.windowIsMaximized()` 返回布尔值。
|
||||
- `windowMinimize` / `windowMaximize` / `windowClose` 调用后窗口行为正确。
|
||||
|
||||
### Task 3:Preload 重写
|
||||
- **文件**:`electron/preload/index.ts`
|
||||
- **内容**:
|
||||
1. 移除旧的 `minimizeWindow`、`maximizeWindow`、`closeWindow`、`onWindowMaximized`、`isWindowMaximized`。
|
||||
2. 新增 `platform`、`windowMinimize`、`windowMaximize`、`windowClose`、`windowIsMaximized`。
|
||||
3. 同步更新 `global.d.ts` 中的 `WindowApi` 类型定义。
|
||||
- **验收标准**:
|
||||
- 渲染进程 TypeScript 无报错。
|
||||
- `window.api.platform` 能正确读取当前平台。
|
||||
|
||||
### Task 4:前端组件废弃与重建
|
||||
- **废弃文件**:
|
||||
- `src/components/HeaderBar/index.vue`(删除)
|
||||
- `src/components/DragRegion/index.vue`(删除)
|
||||
- `src/hooks/useWinManager.ts`(删除)
|
||||
- **新建文件**:
|
||||
- `src/components/layout/TitleBar/index.vue`(对齐 ClawX `TitleBar.tsx`)
|
||||
- **重写文件**:
|
||||
- `src/components/Layout/index.vue`(改用 `TitleBar`,Linux 下隐藏)
|
||||
- `src/main.ts`(移除 `HeaderBar` / `DragRegion` 全局注册)
|
||||
- **验收标准**:
|
||||
- macOS 仅显示顶部 40px 拖拽区域,无自定义按钮。
|
||||
- Windows 显示自定义最小化/最大化/关闭按钮,功能正常。
|
||||
- Linux 不渲染任何自定义标题栏元素。
|
||||
|
||||
### Task 5:依赖清理
|
||||
- **文件**:`package.json`、`global.d.ts`
|
||||
- **内容**:
|
||||
1. 安装 `@lucide/vue`。
|
||||
2. 卸载 `@iconify/vue` 和 `@iconify-json/material-symbols`。
|
||||
3. 删除 `global.d.ts` 中 `@iconify/vue` 的类型声明。
|
||||
- **验收标准**:
|
||||
- `pnpm install` 后无 iconify 包残留。
|
||||
- 项目编译通过。
|
||||
|
||||
### Task 6:跨平台回归测试
|
||||
- **内容**:
|
||||
1. **macOS**:验证 traffic lights 位置、拖拽行为、窗口最大化/恢复、关闭。
|
||||
2. **Windows**:验证自定义按钮 hover 样式、最大化/恢复状态切换、最小化到任务栏、关闭。
|
||||
3. **Linux**:验证原生边框存在、无自定义标题栏残留、窗口操作正常。
|
||||
|
||||
---
|
||||
|
||||
## 4. 工作量评估(Sub Agent 分配)
|
||||
|
||||
本任务涉及 **Electron 主进程**、**前端组件重建**、**跨平台回归** 三个独立领域,推荐由 **3 个 Sub Agent** 并行/串行完成:
|
||||
|
||||
### Agent 1:Electron 主进程专家
|
||||
- **职责**:Task 1 + Task 2 + Task 3
|
||||
- **技能要求**:熟悉 Electron BrowserWindow 选项、IPC `handle/invoke` 模式、preload 安全模型。
|
||||
- **预计耗时**:2~3 小时
|
||||
- **产出**:`window-service` + `ipc-handlers` + `preload` 的改造 PR。
|
||||
|
||||
### Agent 2:Vue 前端组件专家
|
||||
- **职责**:Task 4 + Task 5
|
||||
- **技能要求**:熟悉 Vue 3、组件重构、Tailwind CSS、依赖清理。
|
||||
- **预计耗时**:2~4 小时
|
||||
- **产出**:`TitleBar` / `Layout` 重建 PR + 依赖清理补丁。
|
||||
|
||||
### Agent 3:跨平台测试与集成专家
|
||||
- **职责**:Task 6(回归测试)+ 边缘情况修复
|
||||
- **技能要求**:能在 macOS / Windows / Linux 环境下运行 Electron 应用,熟悉 frameless 窗口陷阱。
|
||||
- **预计耗时**:2~4 小时(取决于平台覆盖度)
|
||||
- **产出**:测试报告 + 对 Agent 1/2 PR 的修复补丁。
|
||||
|
||||
---
|
||||
|
||||
## 5. 风险与注意事项
|
||||
|
||||
1. **macOS traffic light 位置**:`hiddenInset` 默认内边距可能因 Electron 版本略有差异,建议通过 `trafficLightPosition` 微调至 `{x: 16, y: 16}`(与 ClawX 一致)。
|
||||
2. **Linux 原生边框与主题**:若 zn-ai 深色模式下 Linux 原生标题栏颜色不匹配,可后续通过 GTK 主题或 `darkTheme` 选项优化,但不在本次核心迁移范围内。
|
||||
3. **Windows 最大化白边**:`frame: false` 的窗口在 Windows 最大化时可能出现 1px 白边。ClawX 未做特殊处理,可后续按需优化。
|
||||
4. **关闭行为一致性**:zn-ai 主窗口关闭逻辑原会根据 `MINIMIZE_TO_TRAY` 配置决定 `hide()` 或 `close()`。改造后 `window:close` 直接调用 `mainWindow.close()`,若需保留托盘最小化逻辑,可在 `window:close` handler 中判断 `MINIMIZE_TO_TRAY` 并调用 `mainWindow.hide()` 阻止默认关闭。
|
||||
5. **旧常量清理**:若 `IPC_EVENTS` 中 `WINDOW_MINIMIZE` / `WINDOW_MAXIMIZE` / `WINDOW_CLOSE` / `IS_WINDOW_MAXIMIZED` 被其他未发现的模块引用,删除后会导致编译失败。建议在删除前用全局搜索再次确认。
|
||||
|
||||
---
|
||||
|
||||
## 6. 参考资料
|
||||
|
||||
- ClawX 主窗口创建:`ClawX/electron/main/index.ts`
|
||||
- ClawX TitleBar 组件:`ClawX/src/components/layout/TitleBar.tsx`
|
||||
- ClawX 窗口 IPC:`ClawX/electron/main/ipc-handlers.ts`(`registerWindowHandlers`)
|
||||
- zn-ai 现有窗口服务:`zn-ai/electron/service/window-service/index.ts`
|
||||
- zn-ai 现有 HeaderBar:`zn-ai/src/components/HeaderBar/index.vue`
|
||||
23
electron/ipc/window-handlers.ts
Normal file
23
electron/ipc/window-handlers.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
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();
|
||||
});
|
||||
}
|
||||
@@ -8,11 +8,13 @@ const api: WindowApi = {
|
||||
open: (url: string) => ipcRenderer.invoke('external-open', url)
|
||||
},
|
||||
|
||||
closeWindow: () => ipcRenderer.send(IPC_EVENTS.WINDOW_CLOSE),
|
||||
minimizeWindow: () => ipcRenderer.send(IPC_EVENTS.WINDOW_MINIMIZE),
|
||||
maximizeWindow: () => ipcRenderer.send(IPC_EVENTS.WINDOW_MAXIMIZE),
|
||||
onWindowMaximized: (callback: (isMaximized: boolean) => void) => ipcRenderer.on(IPC_EVENTS.WINDOW_MAXIMIZE + 'back', (_, isMaximized) => callback(isMaximized)),
|
||||
isWindowMaximized: () => ipcRenderer.invoke(IPC_EVENTS.IS_WINDOW_MAXIMIZED),
|
||||
platform: process.platform,
|
||||
|
||||
windowMinimize: () => ipcRenderer.invoke('window:minimize'),
|
||||
windowMaximize: () => ipcRenderer.invoke('window:maximize'),
|
||||
windowClose: () => ipcRenderer.invoke('window:close'),
|
||||
windowIsMaximized: () => ipcRenderer.invoke('window:isMaximized'),
|
||||
|
||||
viewIsReady: () => ipcRenderer.send(IPC_EVENTS.RENDERER_IS_READY),
|
||||
|
||||
app: {
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { WindowNames } from '@lib/types'
|
||||
|
||||
import { CONFIG_KEYS, IPC_EVENTS, WINDOW_NAMES } from '@lib/constants'
|
||||
import { BrowserWindow, BrowserWindowConstructorOptions, ipcMain, IpcMainInvokeEvent, type IpcMainEvent } from 'electron'
|
||||
import { debounce } from '@lib/utils'
|
||||
import { createLogo } from '@electron/utils'
|
||||
|
||||
import logManager from '@electron/service/logger'
|
||||
@@ -26,10 +25,14 @@ interface SizeOptions {
|
||||
minHeight?: number; // 窗口最小高度,可选
|
||||
}
|
||||
|
||||
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 } : undefined,
|
||||
show: false,
|
||||
title: 'NIANXX',
|
||||
darkTheme: themeManager.isDark,
|
||||
@@ -71,29 +74,6 @@ class WindowService {
|
||||
}
|
||||
|
||||
private _setupIpcEvents() {
|
||||
const handleCloseWindow = (e: IpcMainEvent) => {
|
||||
const target = BrowserWindow.fromWebContents(e.sender);
|
||||
const winName = this.getName(target);
|
||||
|
||||
this.close(target, this._isReallyClose(winName));
|
||||
}
|
||||
|
||||
const handleMinimizeWindow = (e: IpcMainEvent) => {
|
||||
BrowserWindow.fromWebContents(e.sender)?.minimize();
|
||||
}
|
||||
|
||||
const handleMaximizeWindow = (e: IpcMainEvent) => {
|
||||
this.toggleMax(BrowserWindow.fromWebContents(e.sender));
|
||||
}
|
||||
|
||||
const handleIsWindowMaximized = (e: IpcMainInvokeEvent) => {
|
||||
return BrowserWindow.fromWebContents(e.sender)?.isMaximized() ?? false;
|
||||
}
|
||||
|
||||
ipcMain.on(IPC_EVENTS.WINDOW_CLOSE, handleCloseWindow);
|
||||
ipcMain.on(IPC_EVENTS.WINDOW_MINIMIZE, handleMinimizeWindow);
|
||||
ipcMain.on(IPC_EVENTS.WINDOW_MAXIMIZE, handleMaximizeWindow);
|
||||
ipcMain.handle(IPC_EVENTS.IS_WINDOW_MAXIMIZED, handleIsWindowMaximized);
|
||||
ipcMain.handle(IPC_EVENTS.APP_LOAD_PAGE, (e: IpcMainInvokeEvent, page: string) => {
|
||||
const win = BrowserWindow.fromWebContents(e.sender);
|
||||
if (win) this._loadPage(win, page);
|
||||
@@ -139,17 +119,13 @@ class WindowService {
|
||||
}
|
||||
|
||||
private _setupWinLifecycle(window: BrowserWindow, name: WindowNames) {
|
||||
const updateWinStatus = debounce(() => !window?.isDestroyed()
|
||||
&& window?.webContents?.send(IPC_EVENTS.WINDOW_MAXIMIZE + 'back', window?.isMaximized()), 80);
|
||||
window.once('closed', () => {
|
||||
this._winStates[name].onClosed.forEach(callback => callback(window));
|
||||
window?.destroy();
|
||||
window?.removeListener('resize', updateWinStatus);
|
||||
this._winStates[name].instance = void 0;
|
||||
this._winStates[name].isHidden = false;
|
||||
logManager.info(`Window closed: ${name}`);
|
||||
});
|
||||
window.on('resize', updateWinStatus)
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { logManager } from '@electron/service/logger'
|
||||
import { configManager } from '@electron/service/config-service'
|
||||
import { trayManager } from '@electron/service/tray-service'
|
||||
import { TabManager } from '@service/tab-manager'
|
||||
import { registerWindowHandlers } from '@electron/ipc/window-handlers'
|
||||
|
||||
const handleTray = (minimizeToTray: boolean) => {
|
||||
if (minimizeToTray) {
|
||||
@@ -109,6 +110,7 @@ export function setupMainWindow() {
|
||||
|
||||
handleTray(minimizeToTray);
|
||||
registerMenus(mainWindow);
|
||||
registerWindowHandlers(mainWindow);
|
||||
|
||||
const tabManager = new TabManager(mainWindow)
|
||||
tabManager.enable()
|
||||
|
||||
23
global.d.ts
vendored
23
global.d.ts
vendored
@@ -127,12 +127,12 @@ declare global {
|
||||
external: {
|
||||
open: (url: string) => Promise<void>
|
||||
},
|
||||
minimizeWindow: () => void,
|
||||
maximizeWindow: () => void,
|
||||
closeWindow: () => void,
|
||||
onWindowMaximized: (callback: (isMaximized: boolean) => void) => void,
|
||||
isWindowMaximized: () => Promise<boolean>,
|
||||
viewIsReady: () => void
|
||||
platform: string;
|
||||
windowMinimize: () => void;
|
||||
windowMaximize: () => void;
|
||||
windowClose: () => void;
|
||||
windowIsMaximized: () => Promise<boolean>;
|
||||
viewIsReady: () => void;
|
||||
app: {
|
||||
setFrameless: (route?: string) => Promise<void>,
|
||||
loadPage: (page: string) => Promise<void>
|
||||
@@ -227,15 +227,4 @@ declare module "@assets/images/*";
|
||||
declare module "@constant/*";
|
||||
declare module "@remixicon/vue";
|
||||
declare module "vue-router";
|
||||
declare module '@iconify/vue' {
|
||||
import { DefineComponent } from 'vue'
|
||||
export const Icon: DefineComponent<{
|
||||
icon: string
|
||||
width?: string | number
|
||||
height?: string | number
|
||||
color?: string
|
||||
flip?: string
|
||||
rotate?: number
|
||||
}>
|
||||
}
|
||||
|
||||
|
||||
42
package-lock.json
generated
42
package-lock.json
generated
@@ -13,8 +13,7 @@
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.41.0",
|
||||
"@iconify-json/material-symbols": "^1.2.50",
|
||||
"@iconify/vue": "^5.0.0",
|
||||
"@lucide/vue": "^1.8.0",
|
||||
"@remixicon/vue": "^4.7.0",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
@@ -1241,36 +1240,6 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@iconify-json/material-symbols": {
|
||||
"version": "1.2.63",
|
||||
"resolved": "https://registry.npmmirror.com/@iconify-json/material-symbols/-/material-symbols-1.2.63.tgz",
|
||||
"integrity": "sha512-R4PS/l8K6j+dk2P2MoYFLJgfbZ4YDo6XjCOpX6b1tvX+BhiSpSOjc1b6cnb/mvWe+JWBKlt4pcvPNiAijFLPnA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@iconify/types": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@iconify/types": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/@iconify/types/-/types-2.0.0.tgz",
|
||||
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@iconify/vue": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/@iconify/vue/-/vue-5.0.0.tgz",
|
||||
"integrity": "sha512-C+KuEWIF5nSBrobFJhT//JS87OZ++QDORB6f2q2Wm6fl2mueSTpFBeBsveK0KW9hWiZ4mNiPjsh6Zs4jjdROSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@iconify/types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/cyberalien"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": ">=3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
|
||||
@@ -1896,6 +1865,15 @@
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lucide/vue": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmmirror.com/@lucide/vue/-/vue-1.8.0.tgz",
|
||||
"integrity": "sha512-Rgy2rxfOx9yP6fWneE3QO6xwUbF2o7f9+MRbzGLRakee4tzUeVWHdX23uRH4ymwEzoq2+8vqRI9yGsxeZhYlWw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"vue": ">=3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@malept/cross-spawn-promise": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz",
|
||||
|
||||
@@ -68,8 +68,7 @@
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.41.0",
|
||||
"@iconify-json/material-symbols": "^1.2.50",
|
||||
"@iconify/vue": "^5.0.0",
|
||||
"@lucide/vue": "^1.8.0",
|
||||
"@remixicon/vue": "^4.7.0",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
|
||||
40
pnpm-lock.yaml
generated
40
pnpm-lock.yaml
generated
@@ -17,12 +17,9 @@ importers:
|
||||
'@codemirror/view':
|
||||
specifier: ^6.41.0
|
||||
version: 6.41.0
|
||||
'@iconify-json/material-symbols':
|
||||
specifier: ^1.2.50
|
||||
version: 1.2.65
|
||||
'@iconify/vue':
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0(vue@3.5.32(typescript@5.9.3))
|
||||
'@lucide/vue':
|
||||
specifier: ^1.8.0
|
||||
version: 1.8.0(vue@3.5.32(typescript@5.9.3))
|
||||
'@remixicon/vue':
|
||||
specifier: ^4.7.0
|
||||
version: 4.9.0(vue@3.5.32(typescript@5.9.3))
|
||||
@@ -530,17 +527,6 @@ packages:
|
||||
resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==}
|
||||
deprecated: Use @eslint/object-schema instead
|
||||
|
||||
'@iconify-json/material-symbols@1.2.65':
|
||||
resolution: {integrity: sha512-0kGO7z+yuWjn4de2gAz1hrOpDHN1rvnb1Lr3YnkmZr/pJ9bfLSv2bRXQo/nKx6oODXKcoYuFLcLvg2xPxMq5Pg==}
|
||||
|
||||
'@iconify/types@2.0.0':
|
||||
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
|
||||
|
||||
'@iconify/vue@5.0.0':
|
||||
resolution: {integrity: sha512-C+KuEWIF5nSBrobFJhT//JS87OZ++QDORB6f2q2Wm6fl2mueSTpFBeBsveK0KW9hWiZ4mNiPjsh6Zs4jjdROSg==}
|
||||
peerDependencies:
|
||||
vue: '>=3'
|
||||
|
||||
'@img/sharp-darwin-arm64@0.33.5':
|
||||
resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
@@ -713,6 +699,11 @@ packages:
|
||||
'@lezer/lr@1.4.8':
|
||||
resolution: {integrity: sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==}
|
||||
|
||||
'@lucide/vue@1.8.0':
|
||||
resolution: {integrity: sha512-Rgy2rxfOx9yP6fWneE3QO6xwUbF2o7f9+MRbzGLRakee4tzUeVWHdX23uRH4ymwEzoq2+8vqRI9yGsxeZhYlWw==}
|
||||
peerDependencies:
|
||||
vue: '>=3.0.1'
|
||||
|
||||
'@malept/cross-spawn-promise@2.0.0':
|
||||
resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==}
|
||||
engines: {node: '>= 12.13.0'}
|
||||
@@ -3822,17 +3813,6 @@ snapshots:
|
||||
|
||||
'@humanwhocodes/object-schema@2.0.3': {}
|
||||
|
||||
'@iconify-json/material-symbols@1.2.65':
|
||||
dependencies:
|
||||
'@iconify/types': 2.0.0
|
||||
|
||||
'@iconify/types@2.0.0': {}
|
||||
|
||||
'@iconify/vue@5.0.0(vue@3.5.32(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@iconify/types': 2.0.0
|
||||
vue: 3.5.32(typescript@5.9.3)
|
||||
|
||||
'@img/sharp-darwin-arm64@0.33.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-arm64': 1.0.4
|
||||
@@ -3979,6 +3959,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
|
||||
'@lucide/vue@1.8.0(vue@3.5.32(typescript@5.9.3))':
|
||||
dependencies:
|
||||
vue: 3.5.32(typescript@5.9.3)
|
||||
|
||||
'@malept/cross-spawn-promise@2.0.0':
|
||||
dependencies:
|
||||
cross-spawn: 7.0.6
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<template>
|
||||
<div class="drag-region">
|
||||
<slot>
|
||||
<span class="_placeholder">_hidden</span>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
._placeholder {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,76 +0,0 @@
|
||||
<template>
|
||||
<header class="flex items-start justify-between h-[40px]">
|
||||
<div class="title-bar-main flex-auto">
|
||||
<slot>{{ title ?? '' }}</slot>
|
||||
</div>
|
||||
|
||||
<div class="title-bar-controls w-[168px] flex items-center justify-end text-tx-secondary">
|
||||
<native-tooltip :content="t('window.minimize')">
|
||||
<button v-show="isMinimizable"
|
||||
class="flex items-center justify-center cursor-pointer w-[40px] h-[40px] hover:bg-[#999] hover:text-[#fff]"
|
||||
@click="minimizeWindow">
|
||||
<iconify-icon icon="material-symbols:check-indeterminate-small" :color="color" :width="btnSize"
|
||||
:height="btnSize" />
|
||||
</button>
|
||||
</native-tooltip>
|
||||
<native-tooltip :content="isMaximized ? t('window.restore') : t('window.maximize')">
|
||||
<button v-show="isMaximizable"
|
||||
class="flex items-center justify-center cursor-pointer w-[40px] h-[40px] hover:bg-[#999] hover:text-[#fff]"
|
||||
@click="maximizeWindow">
|
||||
<iconify-icon icon="material-symbols:chrome-maximize-outline-sharp" :color="color" :width="btnSize"
|
||||
:height="btnSize" v-show="!isMaximized" />
|
||||
<iconify-icon icon="material-symbols:chrome-restore-outline-sharp" :color="color" :width="btnSize"
|
||||
:height="btnSize" v-show="isMaximized" />
|
||||
</button>
|
||||
</native-tooltip>
|
||||
<native-tooltip :content="t('window.close')">
|
||||
<button v-show="isClosable"
|
||||
class="flex items-center justify-center cursor-pointer w-[40px] h-[40px] hover:bg-[#ff0000] hover:text-[#fff]"
|
||||
@click="handleClose">
|
||||
<iconify-icon icon="material-symbols:close" :color="color" :width="btnSize" :height="btnSize" />
|
||||
</button>
|
||||
</native-tooltip>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import { useWinManager } from '@hooks/useWinManager'
|
||||
|
||||
import NativeTooltip from '@components/NativeTooltip/index.vue'
|
||||
|
||||
interface HeaderBarProps {
|
||||
title?: string;
|
||||
isMaximizable?: boolean;
|
||||
isMinimizable?: boolean;
|
||||
isClosable?: boolean;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
defineOptions({ name: 'HeaderBar', color: '#525866' })
|
||||
|
||||
withDefaults(defineProps<HeaderBarProps>(), {
|
||||
isMaximizable: true,
|
||||
isMinimizable: true,
|
||||
isClosable: true,
|
||||
})
|
||||
const emit = defineEmits(['close']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const btnSize = 16;
|
||||
|
||||
const {
|
||||
isMaximized,
|
||||
closeWindow,
|
||||
minimizeWindow,
|
||||
maximizeWindow
|
||||
} = useWinManager();
|
||||
|
||||
function handleClose() {
|
||||
emit('close');
|
||||
closeWindow();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
60
src/components/Layout/TitleBar/index.vue
Normal file
60
src/components/Layout/TitleBar/index.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<!-- macOS: just drag region -->
|
||||
<div v-if="platform === 'darwin'" class="drag-region h-10 shrink-0 border-b border-b-gray-300" style="background: transparent;" />
|
||||
|
||||
<!-- Linux: no custom title bar -->
|
||||
<template v-else-if="platform !== 'win32'" />
|
||||
|
||||
<!-- Windows: custom controls -->
|
||||
<div v-else class="drag-region flex h-10 shrink-0 items-center justify-end border-b" style="background: transparent;">
|
||||
<div class="no-drag flex h-full">
|
||||
<button
|
||||
@click="handleMinimize"
|
||||
class="flex h-full w-11 items-center justify-center text-[#525866] hover:bg-[#999] hover:text-white transition-colors"
|
||||
title="Minimize"
|
||||
>
|
||||
<Minus class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
@click="handleMaximize"
|
||||
class="flex h-full w-11 items-center justify-center text-[#525866] hover:bg-[#999] hover:text-white transition-colors"
|
||||
:title="maximized ? 'Restore' : 'Maximize'"
|
||||
>
|
||||
<Copy v-if="maximized" class="h-3.5 w-3.5" />
|
||||
<Square v-else class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
@click="handleClose"
|
||||
class="flex h-full w-11 items-center justify-center text-[#525866] hover:bg-[#ff0000] hover:text-white transition-colors"
|
||||
title="Close"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Minus, Square, Copy, X } from '@lucide/vue'
|
||||
|
||||
const platform = (window as any).api?.platform ?? ''
|
||||
const maximized = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
maximized.value = await (window as any).api.windowIsMaximized()
|
||||
})
|
||||
|
||||
const handleMinimize = () => {
|
||||
(window as any).api.windowMinimize()
|
||||
}
|
||||
|
||||
const handleMaximize = async () => {
|
||||
await (window as any).api.windowMaximize()
|
||||
maximized.value = await (window as any).api.windowIsMaximized()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
(window as any).api.windowClose()
|
||||
}
|
||||
</script>
|
||||
@@ -1,22 +1,27 @@
|
||||
<template>
|
||||
<div class="bg-color h-screen flex flex-col">
|
||||
<header-bar>
|
||||
<drag-region class="w-full" />
|
||||
</header-bar>
|
||||
<title-bar v-if="platform !== 'linux'" />
|
||||
|
||||
<main class="box-border w-full h-[calc(100vh-40px)] flex pt-[8px] pb-[8px] pl-[8px] ">
|
||||
<main
|
||||
class="box-border w-full flex pt-2 pb-2 pl-2"
|
||||
:style="{ height: platform === 'linux' ? '100vh' : 'calc(100vh - 40px)' }"
|
||||
>
|
||||
<div class="flex-1 flex">
|
||||
<slot />
|
||||
</div>
|
||||
<SideMenus />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="Layout">
|
||||
import { computed } from 'vue'
|
||||
import SideMenus from '@src/components/SideMenus/index.vue'
|
||||
import TitleBar from '@components/layout/TitleBar/index.vue'
|
||||
|
||||
const platform = computed(() => (window as any).api?.platform ?? '')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bg-color {
|
||||
background: linear-gradient(180deg, #EFF6FF 0%, #F5F7FA 40%);
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
export function useWinManager() {
|
||||
const isMaximized = ref(false)
|
||||
|
||||
function closeWindow() {
|
||||
window.api.closeWindow();
|
||||
}
|
||||
|
||||
function minimizeWindow() {
|
||||
window.api.minimizeWindow();
|
||||
}
|
||||
|
||||
function maximizeWindow() {
|
||||
window.api.maximizeWindow();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
window.api.viewIsReady();
|
||||
isMaximized.value = await window.api.isWindowMaximized();
|
||||
window.api.onWindowMaximized((_isMaximized: boolean) => isMaximized.value = _isMaximized);
|
||||
})
|
||||
|
||||
return {
|
||||
isMaximized,
|
||||
closeWindow,
|
||||
minimizeWindow,
|
||||
maximizeWindow
|
||||
}
|
||||
};
|
||||
|
||||
export default useWinManager;
|
||||
@@ -2,10 +2,6 @@
|
||||
|
||||
export enum IPC_EVENTS {
|
||||
EXTERNAL_OPEN = 'external-open',
|
||||
WINDOW_MINIMIZE = 'window-minimize',
|
||||
WINDOW_MAXIMIZE = 'window-maximize',
|
||||
WINDOW_CLOSE = 'window-close',
|
||||
IS_WINDOW_MAXIMIZED = 'is-window-maximized',
|
||||
APP_SET_FRAMELESS = 'app:set-frameless',
|
||||
APP_LOAD_PAGE = 'app:load-page',
|
||||
TAB_CREATE = 'tab:create',
|
||||
|
||||
@@ -17,13 +17,9 @@ import "./styles/index.css";
|
||||
import 'element-plus/dist/index.css'
|
||||
|
||||
// 引入全局组件
|
||||
import HeaderBar from '@components/HeaderBar/index.vue'
|
||||
import DragRegion from '@components/DragRegion/index.vue'
|
||||
import Layout from '@components/Layout/index.vue'
|
||||
|
||||
const components: Plugin = (app) => {
|
||||
app.component('HeaderBar', HeaderBar);
|
||||
app.component('DragRegion', DragRegion);
|
||||
app.component('Layout', Layout);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,30 +1,26 @@
|
||||
<template>
|
||||
<div class="h-screen login-bg flex flex-col">
|
||||
<header-bar color="#fff">
|
||||
<drag-region class="w-full" />
|
||||
</header-bar>
|
||||
|
||||
<main class="box-border p-[8px] flex-auto flex ">
|
||||
<div class="w-[836px] box-border bg-white rounded-2xl p-[32px] flex flex-col">
|
||||
<main class="box-border pl-2 pr-2 pb-2 pt-11 flex-auto flex ">
|
||||
<div class="w-209 box-border bg-white rounded-2xl p-8 flex flex-col">
|
||||
<div class="flex items-center">
|
||||
<img class="w-[48px] h-[48px]" src="@assets/images/login/blue_logo.png" />
|
||||
<img class="w-12 h-12" src="@assets/images/login/blue_logo.png" />
|
||||
|
||||
<!-- <span class="ml-auto text-[14px] text-gray-600">没有账号?</span>
|
||||
<button
|
||||
class="bg-sky-50 rounded-[8px] text-[14px] text-sky-600 px-[12px] py-[6px] focus-visible:outline-none cursor-pointer">注册</button> -->
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center justify-center mb-[24px] box-border pt-[40px]">
|
||||
<img class="w-[80px] h-[80px] mb-[12px]" src="@assets/images/login/user_icon.png" />
|
||||
<div class="text-[24px] font-500 text-gray-800 line-height-[32px] mb-[4px]">{{ t('login.title') }}</div>
|
||||
<div class="flex flex-col items-center justify-center mb-6 box-border pt-10">
|
||||
<img class="w-20 h-20 mb-3" src="@assets/images/login/user_icon.png" />
|
||||
<div class="text-[24px] font-500 text-gray-800 line-height-[32px] mb-1">{{ t('login.title') }}</div>
|
||||
<div class="text-[16px] text-gray-500 line-height-[24px]">{{ t('login.subtitle') }}</div>
|
||||
</div>
|
||||
|
||||
<el-form class="w-[392px] ml-auto mr-auto" ref="formRef" :rules="rules" :model="form" label-position="top"
|
||||
<el-form class="w-98 ml-auto mr-auto" ref="formRef" :rules="rules" :model="form" label-position="top"
|
||||
@keyup.enter="onSubmit">
|
||||
<el-form-item prop="username">
|
||||
<div class="text-[14px] text-gray-600">{{ t('login.username') }}</div>
|
||||
<el-input class="h-[40px]" v-model.trim="form.username" :placeholder="t('login.usernamePlaceholder')" clearable autocomplete="off">
|
||||
<el-input class="h-10" v-model.trim="form.username" :placeholder="t('login.usernamePlaceholder')" clearable autocomplete="off">
|
||||
<template #prefix>
|
||||
<RiUser3Fill size="20px" color="#99A0AE" />
|
||||
</template>
|
||||
@@ -32,7 +28,7 @@
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<div class="text-[14px] text-gray-600">{{ t('login.password') }}</div>
|
||||
<el-input class="h-[40px]" v-model.trim="form.password" type="password" :placeholder="t('login.passwordPlaceholder')" clearable
|
||||
<el-input class="h-10" v-model.trim="form.password" type="password" :placeholder="t('login.passwordPlaceholder')" clearable
|
||||
autocomplete="off">
|
||||
<template #prefix>
|
||||
<RiKey2Fill size="20px" color="#99A0AE" />
|
||||
@@ -44,7 +40,7 @@
|
||||
<span class="text-[14px] text-gray-600">{{ t('login.code') }}</span>
|
||||
<el-input v-model.trim="form.code" :placeholder="t('login.codePlaceholder')" autocomplete="off">
|
||||
<template #suffix>
|
||||
<img class="w-[80px] h-[38px] cursor-pointer" :src="imgSrc" @click="getVerifyCode" />
|
||||
<img class="w-20 h-9.5 cursor-pointer" :src="imgSrc" @click="getVerifyCode" />
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
@@ -60,7 +56,7 @@
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<button type="button"
|
||||
class="w-full py-2 bg-blue-600 cursor-pointer text-white rounded-[8px] hover:bg-blue-700 disabled:bg-blue-300"
|
||||
class="w-full py-2 bg-blue-600 cursor-pointer text-white rounded-lg hover:bg-blue-700 disabled:bg-blue-300"
|
||||
:loading="loading" @click="onSubmit">
|
||||
{{ t('login.loginButton') }}
|
||||
</button>
|
||||
@@ -81,7 +77,7 @@
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
<img class="w-[540px]" src="@assets/images/login/logo.png" />
|
||||
<img class="w-135" src="@assets/images/login/logo.png" />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user