cc 2 周之前
父節點
當前提交
884f59a7a4

+ 12 - 2
ai-electron/frontend/src/ai/api/codexApi.ts

@@ -53,6 +53,16 @@ async function invokeChannel<T>(channel: string, params?: unknown): Promise<T> {
   return result.data;
 }
 
+/**
+ * ee-core 的 ipcMain.handle 原样返回 controller 返回值,不包 {success, data}。
+ * 调用 osCtl 这类既有 controller 必须走这里,否则裸字符串/布尔值会被当成失败。
+ */
+async function invokeRawChannel<T>(channel: string, params?: unknown): Promise<T> {
+  const ipcRenderer = resolveIpcRenderer();
+  if (!ipcRenderer) throw new Error('当前环境不可用:请在桌面客户端内打开');
+  return (await ipcRenderer.invoke(channel, params)) as T;
+}
+
 /** 调用失败(桥不可用 / success:false / invoke reject)统一抛 Error,调用方 showMessage(e.message) */
 export function codexInvoke<T>(method: string, params?: unknown): Promise<T> {
   return invokeChannel<T>(`${CHANNEL_PREFIX}${method}`, params);
@@ -270,9 +280,9 @@ export function codexSkillOpenFolder(path?: string): Promise<{ path: string }> {
   return codexInvoke<{ path: string }>('skillOpenFolder', { path });
 }
 
-/** 复用 ee-core 既有的目录选择对话框 */
+/** 复用 ee-core 既有的目录选择对话框,取消时主进程返回空字符串 */
 export function selectDirectory(): Promise<string> {
-  return invokeChannel<string>(`${OS_CHANNEL_PREFIX}selectDirectory`);
+  return invokeRawChannel<string>(`${OS_CHANNEL_PREFIX}selectDirectory`);
 }
 
 // ─── 会话(契约已就位,对话 UI 由业务页自行实现) ─────────────────────────────

+ 8 - 0
ai-electron/frontend/src/core/components/Application/index.ts

@@ -1,8 +1,10 @@
 import { withInstall } from '@/core/utils';
 
 import appLogo from './src/AppLogo.vue';
+import appHeader from './src/AppHeader.vue';
 import appProvider from './src/AppProvider.vue';
 import appSearch from './src/search/AppSearch.vue';
+import windowControls from './src/WindowControls.vue';
 import appLocalePicker from './src/AppLocalePicker.vue';
 import appDarkModeToggle from './src/AppDarkModeToggle.vue';
 
@@ -13,3 +15,9 @@ export const AppProvider = withInstall(appProvider);
 export const AppSearch = withInstall(appSearch);
 export const AppLocalePicker = withInstall(appLocalePicker);
 export const AppDarkModeToggle = withInstall(appDarkModeToggle);
+
+// 无边框窗口的标题栏:与主界面 LayoutHeader 同款外观(logo + 操作区 + 窗口控制)
+export const AppHeader = withInstall(appHeader);
+
+// 最小化 / 最大化-还原 / 关闭到托盘,非桌面端自动不渲染
+export const WindowControls = withInstall(windowControls);

+ 90 - 0
ai-electron/frontend/src/core/components/Application/src/AppHeader.vue

@@ -0,0 +1,90 @@
+<template>
+  <header :class="prefixCls">
+    <div :class="`${prefixCls}-left`">
+      <AppLogo :showTitle="true" :class="`${prefixCls}-logo`" />
+      <slot></slot>
+    </div>
+    <div :class="`${prefixCls}-action`">
+      <slot name="actions"></slot>
+      <WindowControls />
+    </div>
+  </header>
+</template>
+
+<script lang="ts" setup>
+  import { useDesign } from '@/core/hooks/web/useDesign';
+  import AppLogo from './AppLogo.vue';
+  import WindowControls from './WindowControls.vue';
+
+  const { prefixCls } = useDesign('app-header');
+</script>
+
+<style lang="less">
+  @prefix-cls: ~'jeesite-app-header';
+
+  .@{prefix-cls} {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    flex: 0 0 auto;
+    height: @header-height;
+    background-color: @white;
+    border-bottom: 1px solid @header-light-bottom-border-color;
+    -webkit-app-region: drag;
+
+    &-left {
+      display: flex;
+      align-items: center;
+      min-width: 0;
+    }
+
+    &-logo {
+      height: @header-height;
+      padding: 0 10px;
+      font-size: 14px;
+      font-weight: bold;
+      color: @text-color-base;
+
+      img {
+        width: @logo-width;
+        height: @logo-width;
+        margin-right: 2px;
+      }
+
+      .jeesite-app-logo__title {
+        color: #555;
+        cursor: default;
+        user-select: none;
+      }
+    }
+
+    &-action {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      flex-shrink: 0;
+      padding-right: 8px;
+      color: @text-color-base;
+      -webkit-app-region: no-drag;
+    }
+  }
+
+  html[data-theme='dark'] {
+    .@{prefix-cls} {
+      background-color: @header-dark-bg-color;
+      border-bottom-color: @border-color-base;
+
+      &-logo {
+        color: @white;
+
+        .jeesite-app-logo__title {
+          color: @white;
+        }
+      }
+
+      &-action {
+        color: @white;
+      }
+    }
+  }
+</style>

+ 87 - 0
ai-electron/frontend/src/core/components/Application/src/WindowControls.vue

@@ -0,0 +1,87 @@
+<template>
+  <div v-if="available" :class="prefixCls">
+    <Tooltip title="最小化" placement="bottom" :mouseEnterDelay="0.4">
+      <button type="button" class="window-control-btn" aria-label="最小化" @click="minimize">
+        <MinusOutlined />
+      </button>
+    </Tooltip>
+
+    <Tooltip :title="maximized ? '还原' : '最大化'" placement="bottom" :mouseEnterDelay="0.4">
+      <button
+        type="button"
+        class="window-control-btn"
+        :aria-label="maximized ? '还原窗口' : '最大化窗口'"
+        @click="toggleMaximize"
+      >
+        <SwitcherOutlined v-if="maximized" />
+        <BorderOutlined v-else />
+      </button>
+    </Tooltip>
+
+    <Tooltip title="关闭(最小化到托盘)" placement="bottom" :mouseEnterDelay="0.4">
+      <button
+        type="button"
+        class="window-control-btn window-control-btn--close"
+        aria-label="关闭窗口"
+        @click="closeToTray"
+      >
+        <CloseOutlined />
+      </button>
+    </Tooltip>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { BorderOutlined, CloseOutlined, MinusOutlined, SwitcherOutlined } from '@ant-design/icons-vue';
+  import { Tooltip } from 'ant-design-vue';
+  import { useDesign } from '@/core/hooks/web/useDesign';
+  import { useElectronWindow } from '@/core/hooks/desktop/useElectronWindow';
+
+  const { prefixCls } = useDesign('window-controls');
+  const { available, maximized, minimize, toggleMaximize, closeToTray } = useElectronWindow();
+</script>
+
+<style lang="less">
+  @prefix-cls: ~'jeesite-window-controls';
+
+  .@{prefix-cls} {
+    display: flex;
+    align-items: center;
+    gap: 2px;
+    flex: 0 0 auto;
+    -webkit-app-region: no-drag;
+
+    .window-control-btn {
+      display: inline-flex;
+      align-items: center;
+      justify-content: center;
+      width: 32px;
+      height: 32px;
+      padding: 0;
+      border: 0;
+      border-radius: 8px;
+      background: transparent;
+      color: inherit;
+      font-size: 14px;
+      cursor: pointer;
+      transition: background-color 0.2s ease;
+
+      &:hover {
+        background-color: @header-light-bg-hover-color;
+      }
+
+      &--close:hover {
+        background-color: #e5484d;
+        color: @white;
+      }
+    }
+  }
+
+  html[data-theme='dark'] {
+    .@{prefix-cls} {
+      .window-control-btn:hover {
+        background-color: @header-dark-bg-hover-color;
+      }
+    }
+  }
+</style>

+ 87 - 0
ai-electron/frontend/src/core/hooks/desktop/useElectronWindow.ts

@@ -0,0 +1,87 @@
+import { onBeforeUnmount, onMounted, ref } from 'vue';
+
+type IpcRendererLike = {
+  invoke: (channel: string, ...args: unknown[]) => Promise<unknown>;
+};
+
+/** 主进程实现见 ai-electron/electron/controller/osCtl.js(返回值不被 ee-core 包装) */
+const CHANNEL_PREFIX = 'controller/osCtl/';
+
+/**
+ * 两种桥都认:脚手架现状是 nodeIntegration:true + contextIsolation:false(渲染进程里 require 可用),
+ * 若后续启用 preload/bridge.ts 则走 window.electron。浏览器里两者都没有,返回 null。
+ */
+function resolveIpcRenderer(): IpcRendererLike | null {
+  const bridge = window.electron?.ipcRenderer as IpcRendererLike | undefined;
+  if (bridge?.invoke) return bridge;
+  const nodeRequire = (window as unknown as { require?: (id: string) => { ipcRenderer?: IpcRendererLike } }).require;
+  try {
+    const renderer = nodeRequire?.('electron')?.ipcRenderer;
+    return renderer?.invoke ? renderer : null;
+  } catch {
+    return null;
+  }
+}
+
+/**
+ * 无边框窗口(frame: false)的控制能力。
+ *
+ * <p>关闭走的是「关闭到托盘」:主进程 tray.js 里 setCloseAndQuit(false) 拦截了 close 事件,
+ * 窗口只 hide、进程不退,退出要从托盘菜单走。
+ */
+export function useElectronWindow() {
+  const ipcRenderer = resolveIpcRenderer();
+  const available = !!ipcRenderer;
+  const maximized = ref(false);
+  let syncTimer: ReturnType<typeof setTimeout> | null = null;
+
+  async function invoke(
+    method: 'minimizeWindow' | 'maximizeWindow' | 'unmaximizeWindow' | 'closeWindow',
+  ): Promise<void> {
+    try {
+      await ipcRenderer?.invoke(`${CHANNEL_PREFIX}${method}`);
+    } catch (error) {
+      console.warn('[window] invoke failed:', method, error);
+    }
+  }
+
+  async function syncMaximized(): Promise<void> {
+    try {
+      maximized.value = (await ipcRenderer?.invoke(`${CHANNEL_PREFIX}isMaximized`)) === true;
+    } catch (error) {
+      console.warn('[window] query maximize state failed:', error);
+    }
+  }
+
+  function minimize(): void {
+    void invoke('minimizeWindow');
+  }
+
+  async function toggleMaximize(): Promise<void> {
+    await invoke(maximized.value ? 'unmaximizeWindow' : 'maximizeWindow');
+    await syncMaximized();
+  }
+
+  function closeToTray(): void {
+    void invoke('closeWindow');
+  }
+
+  /** 托盘/快捷键/双击拖拽区等外部途径改变窗口状态时,只能靠 resize 反查是否最大化 */
+  function handleResize(): void {
+    if (syncTimer) clearTimeout(syncTimer);
+    syncTimer = setTimeout(syncMaximized, 120);
+  }
+
+  onMounted(() => {
+    if (!available) return;
+    void syncMaximized();
+    window.addEventListener('resize', handleResize);
+  });
+
+  onBeforeUnmount(() => {
+    if (syncTimer) clearTimeout(syncTimer);
+    window.removeEventListener('resize', handleResize);
+  });
+
+  return { available, maximized, minimize, toggleMaximize, closeToTray };
+}

+ 20 - 0
ai-electron/frontend/src/core/layouts/default/header/index.less

@@ -257,6 +257,14 @@
       cursor: default;
     }
 
+    // 窗口控制按钮自己管 hover,取消 action 容器统一套上的圆角块
+    > .window-controls-item {
+      height: auto;
+      padding: 0;
+      border-radius: 0;
+      cursor: default;
+    }
+
     span[role='img'] {
       padding: 0 8px;
     }
@@ -471,6 +479,10 @@
           background-color: @header-light-bg-hover-color;
         }
       }
+
+      > .window-controls-item:hover {
+        background-color: transparent;
+      }
     }
   }
 
@@ -548,6 +560,10 @@
           box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
         }
       }
+
+      > .window-controls-item:hover {
+        background-color: transparent;
+      }
     }
   }
 }
@@ -562,6 +578,10 @@ html[data-theme='dark'] {
             background-color: #262626;
           }
         }
+
+        > .window-controls-item:hover {
+          background-color: transparent;
+        }
       }
     }
   }

+ 3 - 1
ai-electron/frontend/src/core/layouts/default/header/index.vue

@@ -54,6 +54,7 @@
       <LogoutButton class="logout-item" />
       <UserDropdown class="user-dropdown-item" />
       <span :class="`${prefixCls}-search-divider`"></span>
+      <WindowControls class="window-controls-item" />
     </div>
   </ALayoutHeader>
 </template>
@@ -64,7 +65,7 @@
   import { propTypes } from '@/core/utils/propTypes';
   import { Button, Layout, Tooltip } from 'ant-design-vue';
   import { AppLogo } from '@/core/components/Application';
-  import { AppSearch } from '@/core/components/Application';
+  import { AppSearch, WindowControls } from '@/core/components/Application';
   import { Icon } from '@/core/components/Icon';
   import { PageEnum } from '@/core/enums/pageEnum';
   import { MenuModeEnum, MenuSplitTyeEnum } from '@/core/enums/menuEnum';
@@ -98,6 +99,7 @@
       LayoutMenu,
       FullScreen,
       AppSearch,
+      WindowControls,
       ErrorAction,
       SettingDrawer,
       LogoutButton,

+ 2 - 0
ai-electron/frontend/src/plat/views/authorization/index.vue

@@ -1,5 +1,6 @@
 <template>
   <div :class="prefixCls" class="authorization-page">
+    <AppHeader />
     <div class="authorization-content">
       <section class="left-pane">
         <div class="left-pane__content">
@@ -256,6 +257,7 @@
     UploadOutlined,
   } from '@ant-design/icons-vue';
   import { PageEnum } from '@/core/enums/pageEnum';
+  import { AppHeader } from '@/core/components/Application';
   import { authorizationInfoApi, checkAuthApi, machineCodeApi, offlineAuthorizationApi } from '@/core/api/sys/login';
   import { useCopyToClipboard } from '@/core/hooks/web/useCopyToClipboard';
   import { useDesign } from '@/core/hooks/web/useDesign';

+ 8 - 82
ai-electron/frontend/src/plat/views/case/case.vue

@@ -1,12 +1,10 @@
 <template>
   <div :class="prefixCls" class="relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-light-400">
-    <div class="case-header-bar flex items-center justify-between">
-      <AppLogo :showTitle="true" />
-      <div class="flex items-center gap-3">
-        <span v-if="accountDisplayName" class="text-sm text-gray-600">{{ accountDisplayName }}</span>
-        <Button type="text" size="small" @click="handleLogout">退出登录</Button>
-      </div>
-    </div>
+    <AppHeader>
+      <template #actions>
+        <UserDropdown />
+      </template>
+    </AppHeader>
     <div class="container relative mx-auto flex h-full min-h-0 flex-1 flex-col py-2">
       <div class="flex h-full min-h-0 flex-col">
         <div class="flex items-start justify-between gap-4">
@@ -139,40 +137,16 @@
   import { useDesign } from '@/core/hooks/web/useDesign';
   import { useMessage } from '@/core/hooks/web/useMessage';
   import { useUserStore } from '@/core/store/modules/case';
-  import { useAccountStore } from '@/core/store/modules/account';
-  import { useRouter } from 'vue-router';
-  import { PageEnum } from '@/core/enums/pageEnum';
   import { useModal } from '@/core/components/Modal';
-  import { AppLogo, AppSearch } from '@/core/components/Application';
+  import { AppHeader } from '@/core/components/Application';
+  import { UserDropdown } from '@/core/layouts/default/header/components';
   import { list, deleteById } from '@/plat/api/case/caseApi';
   import type { CaseInfo } from '@/plat/api/case/caseApi';
   import CaseForm from './CaseForm.vue';
 
-  const { showMessage, notification, createConfirm } = useMessage();
+  const { showMessage, notification } = useMessage();
   const { prefixCls } = useDesign('case-list');
   const userStore = useUserStore();
-  const accountStore = useAccountStore();
-  const router = useRouter();
-  const accountDisplayName = computed(() => accountStore.getDisplayName);
-
-  /**
-   * 退出登录。
-   *
-   * <p>案件列表页没有 Layout Header,所以入口放在本页顶部。
-   * 注意与「退出当前案件」(Header 里的 LogoutButton)语义不同:这里会注销 token。
-   */
-  function handleLogout() {
-    createConfirm({
-      iconType: 'warning',
-      title: '提示',
-      content: '确认退出登录?退出后需要重新输入账号密码。',
-      onOk: async () => {
-        userStore.resetState();
-        await accountStore.logout();
-        await router.replace({ path: PageEnum.BASE_AUTHORIZATION });
-      },
-    });
-  }
 
   const caseList = ref<CaseInfo[]>([]);
   const searchKeyword = ref('');
@@ -323,54 +297,6 @@
     display: flex;
     flex-direction: column;
 
-    .case-header-bar {
-      height: 48px;
-      padding: 0 12px 0 0;
-      background: #fff;
-      -webkit-app-region: drag;
-
-      .jeesite-app-logo {
-        -webkit-app-region: no-drag;
-
-        &__title {
-          display: inline-flex;
-          align-items: baseline;
-          gap: 6px;
-          font-size: 18px;
-          font-weight: bold;
-          color: #555;
-          font-family: Arial, 'Microsoft YaHei', sans-serif;
-          cursor: default;
-          user-select: none;
-          transition: all 0.5s;
-        }
-      }
-
-      .jeesite-app-logo img {
-        width: 32px;
-        height: 32px;
-        margin-right: 2px;
-      }
-
-      .jeesite-header-action-button {
-        display: inline-flex;
-        align-items: center;
-        justify-content: center;
-        width: 36px;
-        height: 36px;
-        border-radius: 10px;
-        color: #333;
-        font-size: 18px;
-        cursor: pointer;
-        transition: background-color 0.2s ease;
-        -webkit-app-region: no-drag;
-
-        &:hover {
-          background-color: #f6f6f6;
-        }
-      }
-    }
-
     .case-card {
       border-radius: 12px;
       transition: all 0.3s ease;

+ 3 - 3
ai-electron/frontend/src/plat/views/user/UserManageModal.vue

@@ -33,11 +33,11 @@
         </template>
         <template v-else-if="column.key === 'action'">
           <a class="user-manage__link" @click="openEdit(record)">编辑</a>
-          <a-divider type="vertical" />
+          <Divider type="vertical" />
           <a class="user-manage__link" @click="openResetPwd(record)">重置密码</a>
-          <a-divider type="vertical" />
+          <Divider type="vertical" />
           <a class="user-manage__link" @click="handleKickout(record)">强制下线</a>
-          <a-divider type="vertical" />
+          <Divider type="vertical" />
           <a class="user-manage__link user-manage__link--danger" @click="handleDelete(record)">删除</a>
         </template>
       </template>