|
|
@@ -0,0 +1,232 @@
|
|
|
+import path from 'path';
|
|
|
+import { BrowserWindow, screen } from 'electron';
|
|
|
+import { getBaseDir, isDev } from 'ee-core/ps';
|
|
|
+import { logger } from 'ee-core/log';
|
|
|
+import { getMainWindow } from 'ee-core/electron';
|
|
|
+import { getSmartWindowOptions, smartWindowSize } from '../../config/windowOptions';
|
|
|
+import { localSettingsService } from '../setting/localSettings';
|
|
|
+
|
|
|
+/** 窗口 bounds 在本机设置里的键名 */
|
|
|
+const BOUNDS_KEY = 'smartWindowBounds';
|
|
|
+/** 智能窗口只承载工作台,非该路径一律改写 */
|
|
|
+const WORKBENCH_PATH = '/workbench';
|
|
|
+/** 主进程 → 主窗口的窗口开关状态通道 */
|
|
|
+export const SMART_WINDOW_STATE_CHANNEL = 'smart-window:state';
|
|
|
+
|
|
|
+export interface OpenSmartWindowOptions {
|
|
|
+ /** 完整路由(含 query),如 /workbench?newChat=1 */
|
|
|
+ fullPath?: string;
|
|
|
+ /** 窗口已存在时是否整窗重载(换案后需要重新读案件上下文) */
|
|
|
+ reloadIfOpen?: boolean;
|
|
|
+ /** 窗口已存在且目标带 query 时,是否导航到该地址(?newChat=1 / ?view=agent 这类明确意图) */
|
|
|
+ navigateIfOpen?: boolean;
|
|
|
+}
|
|
|
+
|
|
|
+interface SavedBounds {
|
|
|
+ x: number;
|
|
|
+ y: number;
|
|
|
+ width: number;
|
|
|
+ height: number;
|
|
|
+}
|
|
|
+
|
|
|
+/** 单例:应用内最多一个智能工作台窗口 */
|
|
|
+let smartWindow: BrowserWindow | null = null;
|
|
|
+
|
|
|
+export function getSmartWindow(): BrowserWindow | null {
|
|
|
+ return smartWindow && !smartWindow.isDestroyed() ? smartWindow : null;
|
|
|
+}
|
|
|
+
|
|
|
+export function isSmartWindowOpen(): boolean {
|
|
|
+ return !!getSmartWindow();
|
|
|
+}
|
|
|
+
|
|
|
+/** 判断某个窗口是不是智能工作台窗口(IPC 解析窗口角色用) */
|
|
|
+export function isSmartWindow(win: BrowserWindow | null | undefined): boolean {
|
|
|
+ const current = getSmartWindow();
|
|
|
+ return !!win && !!current && win.id === current.id;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 打开/聚焦智能工作台窗口。
|
|
|
+ *
|
|
|
+ * 已存在时只做 restore/show/focus(reloadIfOpen 时再 reload),保证单例。
|
|
|
+ */
|
|
|
+export function openSmartWindow(options: OpenSmartWindowOptions = {}): BrowserWindow {
|
|
|
+ const fullPath = normalizeFullPath(options.fullPath);
|
|
|
+ const existing = getSmartWindow();
|
|
|
+
|
|
|
+ if (existing) {
|
|
|
+ focusWindow(existing);
|
|
|
+
|
|
|
+ if (options.reloadIfOpen) {
|
|
|
+ // 刚由本次导航创建、还在加载时不用再刷一次;已加载完成的才需要按新案件重建
|
|
|
+ if (!existing.webContents.isLoading()) {
|
|
|
+ existing.webContents.reload();
|
|
|
+ }
|
|
|
+ } else if (options.navigateIfOpen && fullPath.includes('?')) {
|
|
|
+ // 显式带 query(?newChat=1 / ?view=agent)说明是明确的导航意图;
|
|
|
+ // 普通打开/聚焦不动它当前所在的视图
|
|
|
+ loadRenderer(existing, fullPath);
|
|
|
+ }
|
|
|
+ return existing;
|
|
|
+ }
|
|
|
+
|
|
|
+ const win = new BrowserWindow({
|
|
|
+ ...getSmartWindowOptions(),
|
|
|
+ ...restoreBounds(),
|
|
|
+ });
|
|
|
+ smartWindow = win;
|
|
|
+
|
|
|
+ win.once('ready-to-show', () => {
|
|
|
+ if (!win.isDestroyed()) {
|
|
|
+ win.show();
|
|
|
+ win.focus();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ win.on('close', () => saveBounds(win));
|
|
|
+ win.on('closed', () => {
|
|
|
+ if (smartWindow === win) smartWindow = null;
|
|
|
+ notifyState(false);
|
|
|
+ });
|
|
|
+
|
|
|
+ // 渲染进程崩溃后留一个空壳没有意义:销毁它,下次点「智能」重新创建
|
|
|
+ win.webContents.on('render-process-gone', (_event, details) => {
|
|
|
+ logger.error('[smartWindow] render-process-gone:', details?.reason);
|
|
|
+ if (!win.isDestroyed()) {
|
|
|
+ saveBounds(win);
|
|
|
+ win.destroy();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ loadRenderer(win, fullPath);
|
|
|
+ notifyState(true);
|
|
|
+ return win;
|
|
|
+}
|
|
|
+
|
|
|
+export function closeSmartWindow(): void {
|
|
|
+ const win = getSmartWindow();
|
|
|
+ if (!win) return;
|
|
|
+ win.close();
|
|
|
+}
|
|
|
+
|
|
|
+/** 把已打开的智能窗口拉到前台;没打开返回 false */
|
|
|
+export function focusSmartWindow(): boolean {
|
|
|
+ const win = getSmartWindow();
|
|
|
+ if (!win) return false;
|
|
|
+ focusWindow(win);
|
|
|
+ return true;
|
|
|
+}
|
|
|
+
|
|
|
+/** 把主窗口拉到前台(智能窗口遇到登录/授权失效时,由主窗口负责提示) */
|
|
|
+export function focusMainWindow(): void {
|
|
|
+ try {
|
|
|
+ focusWindow(getMainWindow());
|
|
|
+ } catch (e) {
|
|
|
+ logger.warn('[smartWindow] focusMainWindow failed:', e instanceof Error ? e.message : String(e));
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function focusWindow(win: BrowserWindow): void {
|
|
|
+ if (win.isMinimized()) win.restore();
|
|
|
+ win.show();
|
|
|
+ win.focus();
|
|
|
+}
|
|
|
+
|
|
|
+function normalizeFullPath(fullPath?: string): string {
|
|
|
+ const raw = typeof fullPath === 'string' ? fullPath.trim() : '';
|
|
|
+ if (!raw) return WORKBENCH_PATH;
|
|
|
+ // 只接受工作台地址,避免把业务页塞进智能窗口(如 /workbenchfoo 不算)
|
|
|
+ return /^\/workbench(\/|\?|$)/.test(raw) ? raw : WORKBENCH_PATH;
|
|
|
+}
|
|
|
+
|
|
|
+function loadRenderer(win: BrowserWindow, fullPath: string): void {
|
|
|
+ const devServerUrl = resolveDevServerUrl();
|
|
|
+ if (devServerUrl) {
|
|
|
+ win.loadURL(`${devServerUrl}${fullPath}`).catch((err) => {
|
|
|
+ logger.error('[smartWindow] loadURL failed:', err);
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 生产是 file:// + hash 路由:hash 带上完整路径与 query
|
|
|
+ const indexPath = path.join(getBaseDir(), 'public', 'dist', 'index.html');
|
|
|
+ win.loadFile(indexPath, { hash: fullPath }).catch((err) => {
|
|
|
+ logger.error('[smartWindow] loadFile failed:', err);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 开发环境的前端地址:与 ee-core 加载主窗口同口径,端口来自 cmd/bin.js 的 dev.frontend.port。
|
|
|
+ * 生产环境(或读不到配置)返回 null,走 loadFile。
|
|
|
+ */
|
|
|
+function resolveDevServerUrl(): string | null {
|
|
|
+ if (!isDev()) return null;
|
|
|
+
|
|
|
+ try {
|
|
|
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
|
+ const binConfig = require(path.join(getBaseDir(), 'cmd', 'bin.js'));
|
|
|
+ const frontend = binConfig?.dev?.frontend || {};
|
|
|
+ const protocol = typeof frontend.protocol === 'string' ? frontend.protocol : 'http://';
|
|
|
+ if (protocol === 'file://') return null;
|
|
|
+ const hostname = typeof frontend.hostname === 'string' ? frontend.hostname : 'localhost';
|
|
|
+ const port = Number(frontend.port) || 3100;
|
|
|
+ return `${protocol}${hostname}:${port}`;
|
|
|
+ } catch (e) {
|
|
|
+ logger.warn('[smartWindow] 读取 cmd/bin.js 失败,回退 http://localhost:3100');
|
|
|
+ return 'http://localhost:3100';
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function saveBounds(win: BrowserWindow): void {
|
|
|
+ try {
|
|
|
+ const { x, y, width, height } = win.getBounds();
|
|
|
+ localSettingsService.set(BOUNDS_KEY, JSON.stringify({ x, y, width, height }));
|
|
|
+ } catch (e) {
|
|
|
+ logger.warn('[smartWindow] 保存窗口位置失败:', e instanceof Error ? e.message : String(e));
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/** 恢复上次的位置与大小;越界或尺寸不合法时交给 Electron 默认放置 */
|
|
|
+function restoreBounds(): Partial<SavedBounds> {
|
|
|
+ try {
|
|
|
+ const raw = localSettingsService.get(BOUNDS_KEY);
|
|
|
+ if (!raw) return {};
|
|
|
+
|
|
|
+ const parsed = JSON.parse(raw) as Partial<SavedBounds>;
|
|
|
+ const { x, y, width, height } = parsed || {};
|
|
|
+ if (![x, y, width, height].every((value) => typeof value === 'number' && Number.isFinite(value))) {
|
|
|
+ return {};
|
|
|
+ }
|
|
|
+ if ((width as number) < smartWindowSize.minWidth || (height as number) < smartWindowSize.minHeight) {
|
|
|
+ return {};
|
|
|
+ }
|
|
|
+
|
|
|
+ // 显示器拔掉/分辨率变化后,旧坐标可能落在屏幕外
|
|
|
+ const visible = screen.getAllDisplays().some((display) => {
|
|
|
+ const area = display.workArea;
|
|
|
+ return (
|
|
|
+ (x as number) < area.x + area.width &&
|
|
|
+ (x as number) + (width as number) > area.x &&
|
|
|
+ (y as number) < area.y + area.height &&
|
|
|
+ (y as number) + (height as number) > area.y
|
|
|
+ );
|
|
|
+ });
|
|
|
+
|
|
|
+ return visible ? { x, y, width, height } : {};
|
|
|
+ } catch (e) {
|
|
|
+ logger.warn('[smartWindow] 恢复窗口位置失败:', e instanceof Error ? e.message : String(e));
|
|
|
+ return {};
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function notifyState(open: boolean): void {
|
|
|
+ try {
|
|
|
+ const main = getMainWindow();
|
|
|
+ if (!main.isDestroyed() && !main.webContents.isDestroyed()) {
|
|
|
+ main.webContents.send(SMART_WINDOW_STATE_CHANNEL, { open });
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ // 主窗口尚未创建或应用正在退出:忽略
|
|
|
+ }
|
|
|
+}
|