cc 1 settimana fa
parent
commit
c27aff8e2a

+ 2 - 1
ai-electron/frontend/src/ai/components/SessionList.vue

@@ -148,7 +148,8 @@
                     title: '删除会话',
                     content: `确认删除「${title}」?该会话的消息记录将一并删除,且不可恢复。`,
                     okText: '删除',
-                    okType: 'danger',
+                    okType: 'primary',
+                    okButtonProps: { danger: true },
                     cancelText: '取消',
                     onOk: () => emit('remove'),
                   });

+ 2 - 1
ai-electron/frontend/src/ai/views/aiModel/index.vue

@@ -234,7 +234,8 @@
         isEmbedding ? '向量' : '对话'
       }模型。`,
       okText: '删除',
-      okType: 'danger',
+      okType: 'primary',
+                    okButtonProps: { danger: true },
       cancelText: '取消',
       onOk: async () => {
         await modelApi.deleteModel(record.id);

+ 4 - 2
ai-electron/frontend/src/ai/views/aiPlugin/index.vue

@@ -777,7 +777,8 @@
       title: '删除 MCP 服务',
       content: `确认删除「${record.id}」?该配置只影响本机 Codex。`,
       okText: '删除',
-      okType: 'danger',
+      okType: 'primary',
+                    okButtonProps: { danger: true },
       cancelText: '取消',
       onOk: async () => {
         try {
@@ -856,7 +857,8 @@
       title: '卸载 Skill',
       content: `确认删除「${record.name}」?将移除 ${record.path} 整个目录。`,
       okText: '卸载',
-      okType: 'danger',
+      okType: 'primary',
+                    okButtonProps: { danger: true },
       cancelText: '取消',
       onOk: async () => {
         try {

+ 2 - 1
ai-electron/frontend/src/codex/components/codexSessionSidebar.vue

@@ -163,7 +163,8 @@
       title: '删除会话',
       content: `确认删除「${session.title}」?将把它从本机列表移除,Codex 侧已落盘的历史不受影响。`,
       okText: '删除',
-      okType: 'danger',
+      okType: 'primary',
+                    okButtonProps: { danger: true },
       cancelText: '取消',
       onOk: () => {
         store.removeSession(session.threadId);

+ 2 - 0
ai-electron/frontend/src/codex/components/codexTemplatePanel.vue

@@ -217,6 +217,8 @@
           content: `冲突模板:${conflicts
             .map((item) => String(item.title))
             .join('、')}。选择「覆盖」用文件里的内容替换,选择「跳过」保留现有模板。`,
+          okType: 'primary',
+          okButtonProps: { danger: true },
           okText: '覆盖',
           cancelText: '跳过',
           onOk: () => applyImport(incoming, true),

+ 2 - 1
ai-electron/frontend/src/codex/views/workbench/WbModelPage.vue

@@ -177,7 +177,8 @@
         isEmbedding ? '向量' : '对话'
       }模型。`,
       okText: '删除',
-      okType: 'danger',
+      okType: 'primary',
+                    okButtonProps: { danger: true },
       cancelText: '取消',
       onOk: async () => {
         await modelApi.deleteModel(record.id);

+ 4 - 2
ai-electron/frontend/src/codex/views/workbench/WbPluginPage.vue

@@ -301,7 +301,8 @@
       title: '删除 MCP 服务',
       content: `确认删除「${record.id}」?`,
       okText: '删除',
-      okType: 'danger',
+      okType: 'primary',
+                    okButtonProps: { danger: true },
       cancelText: '取消',
       onOk: async () => {
         mcpServers.value = await codexApi.codexMcpRemove(record.id);
@@ -326,7 +327,8 @@
       title: '删除技能',
       content: `确认删除技能「${skill.name}」?`,
       okText: '删除',
-      okType: 'danger',
+      okType: 'primary',
+                    okButtonProps: { danger: true },
       cancelText: '取消',
       onOk: async () => {
         skills.value = await codexApi.codexSkillRemove({ path: skill.path, name: skill.name });

+ 2 - 1
ai-electron/frontend/src/codex/views/workbench/WbSessionList.vue

@@ -166,7 +166,8 @@
       title: '删除会话',
       content: `确认删除「${session.title}」?将把它从本机列表移除,Codex 侧已落盘的历史不受影响。`,
       okText: '删除',
-      okType: 'danger',
+      okType: 'primary',
+                    okButtonProps: { danger: true },
       cancelText: '取消',
       onOk: () => {
         store.removeSession(session.threadId);

+ 56 - 116
ai-electron/frontend/src/codex/views/workbench/Workbench.vue

@@ -1,40 +1,33 @@
 <template>
   <div class="wb-page">
-    <!-- 工作台顶栏 -->
-    <header class="wb-top">
-      <div class="wb-logo"><span class="wb-logo__mark">清</span>清鉴 · 智能工作台</div>
-
-      <span
-        class="wb-chip"
-        title="切换案件请退出当前案件后,从案件管理重新进入"
-      >
-        <Icon icon="mdi:briefcase-outline" :size="13" />
-        案件:<b>{{ caseName }}</b>
-        <button class="wb-chip__exit" type="button" @click="onExitCase">退出</button>
-      </span>
-
-      <span v-if="fileCount > 0" class="wb-chip wb-chip--muted">数据就绪 · {{ fileCount }} 个文件</span>
-
-      <span class="wb-spacer"></span>
-
-      <span v-if="routerInfo" class="wb-chip" title="端点只会 Chat 协议,经内置路由层桥接">
-        <Icon icon="mdi:swap-horizontal" :size="13" />
-        路由层
-      </span>
-      <span v-if="modelLabel" class="wb-chip wb-chip--model">
-        <Icon icon="mdi:robot-outline" :size="13" />
-        {{ modelLabel }}
-      </span>
-
-      <AppWorkbenchToggle :current="'smart'" @change="onModeChange" />
-
-      <Tooltip title="诊断日志" placement="bottom">
-        <button class="wb-iconbtn" type="button" @click="diagnosticsOpen = true">
-          <Icon icon="mdi:bug-outline" :size="15" />
-        </button>
-      </Tooltip>
-      <span class="wb-avatar">C</span>
-    </header>
+    <!-- 工作台顶栏:与布局壳共用 AppTopBar(结构/模式切换/头像/窗口控制都在里面) -->
+    <AppTopBar
+      :case-name="caseName"
+      :logo-title="'清鉴 · 智能工作台'"
+      show-case-exit
+      @exit-case="onExitCase"
+    >
+      <!-- 工作台特有状态 chip:数据就绪 / 路由层 / 当前模型 -->
+      <template #info>
+        <span v-if="fileCount > 0" class="wb-chip wb-chip--muted">数据就绪 · {{ fileCount }} 个文件</span>
+        <span v-if="routerInfo" class="wb-chip" title="端点只会 Chat 协议,经内置路由层桥接">
+          <Icon icon="mdi:swap-horizontal" :size="13" />
+          路由层
+        </span>
+        <span v-if="modelLabel" class="wb-chip wb-chip--model">
+          <Icon icon="mdi:robot-outline" :size="13" />
+          {{ modelLabel }}
+        </span>
+      </template>
+
+      <template #actions>
+        <Tooltip title="诊断日志" placement="bottom">
+          <button class="wb-iconbtn" type="button" @click="diagnosticsOpen = true">
+            <Icon icon="mdi:bug-outline" :size="15" />
+          </button>
+        </Tooltip>
+      </template>
+    </AppTopBar>
 
     <div class="wb-body">
       <!-- 左栏:视图菜单 + 会话 -->
@@ -179,9 +172,10 @@
   // 否则工作台路由上拿不到任何 token,样式整体塌掉
   import '@/codex/codexIndex';
   import Icon from '@/core/components/Icon/src/Icon.vue';
-  import { AppWorkbenchToggle } from '@/core/components/Application';
+  import { AppTopBar } from '@/core/components/Application';
   import { PageEnum } from '@/core/enums/pageEnum';
   import { useMessage } from '@/core/hooks/web/useMessage';
+  import { exitCaseConfirmOptions } from '@/core/logics/exitCaseConfirm';
   import { useUserStore } from '@/core/store/modules/case';
 import { useAppStoreWithOut } from '@/core/store/modules/app';
   import WbModelPage from './WbModelPage.vue';
@@ -200,7 +194,7 @@ import { useAppStoreWithOut } from '@/core/store/modules/app';
   const router = useRouter();
   const userStore = useUserStore();
   const appStore = useAppStoreWithOut();
-  const { showMessage } = useMessage();
+  const { createConfirm } = useMessage();
 
   type WorkbenchView = 'chat' | 'model' | 'plugin';
   const view = ref<WorkbenchView>('chat');
@@ -270,22 +264,20 @@ import { useAppStoreWithOut } from '@/core/store/modules/app';
     store.loadRuntime();
   });
 
-  /** 模式切回传统:回首页(业务页面在传统模式里照常使用) */
-  function onModeChange(mode: 'traditional' | 'smart') {
-    if (mode === 'traditional') {
-      appStore.setWorkbenchMode('traditional');
-      router.replace(PageEnum.BASE_HOME);
-    }
-  }
-
   /**
-   * 退出案件:回传统模式的案件管理页重新选择。
-   * 案件切换不允许在工作台内直接完成(业务口径)。
+   * 退出案件:回传统模式的案件管理页重新选择(工作台不允许直接切案件)。
+   * 破坏性动作(当前会话上下文、工作台状态都会离开)先弹确认框。
    */
   function onExitCase() {
-    showMessage('已返回案件管理,重新打开案件后再进入工作台', 'info');
-    appStore.setWorkbenchMode('traditional');
-    router.replace(PageEnum.BASE_CASE);
+    // 用项目的 createConfirm(居中 + 警告图标),与其它页面的确认框外观统一;
+    // content 用 VNode 而非字符串:createConfirm 对字符串走 innerHTML,案件名不该进 HTML
+    createConfirm(
+      exitCaseConfirmOptions(async () => {
+        // 退出后回传统模式:重新打开案件时落在传统布局,需要工作台再切回来
+        appStore.setWorkbenchMode('traditional');
+        await userStore.logout(true);
+      }, caseName.value),
+    );
   }
 </script>
 
@@ -299,39 +291,6 @@ import { useAppStoreWithOut } from '@/core/store/modules/app';
     background: var(--codex-bg-page, #f0f2f5);
   }
 
-  .wb-top {
-    display: flex;
-    align-items: center;
-    gap: 12px;
-    flex: 0 0 auto;
-    height: 50px;
-    padding: 0 16px;
-    background: var(--codex-bg);
-    border-bottom: 1px solid var(--codex-border);
-  }
-
-  .wb-logo {
-    display: flex;
-    align-items: center;
-    gap: 8px;
-    font-size: 14px;
-    font-weight: 700;
-    color: var(--codex-text);
-    white-space: nowrap;
-  }
-
-  .wb-logo__mark {
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    width: 24px;
-    height: 24px;
-    font-size: 13px;
-    color: #fff;
-    background: var(--codex-primary);
-    border-radius: 6px;
-  }
-
   .wb-chip {
     display: inline-flex;
     align-items: center;
@@ -360,57 +319,38 @@ import { useAppStoreWithOut } from '@/core/store/modules/app';
     }
   }
 
-  .wb-chip__exit {
+  /* 诊断入口:尺寸与 hover 对齐传统顶栏的图标按钮(36px 方块 + 悬停底色) */
+  .wb-iconbtn {
     display: inline-flex;
     align-items: center;
+    justify-content: center;
+    width: 36px;
+    min-width: 36px;
+    height: 36px;
     padding: 0;
-    font-size: 11.5px;
-    color: var(--codex-text-3);
+    font-size: 18px;
+    color: var(--codex-text);
     background: transparent;
     border: none;
+    border-radius: 10px;
     cursor: pointer;
+    transition: background-color 0.2s ease;
 
     &:hover {
-      color: var(--codex-error);
+      color: var(--codex-primary);
+      background: var(--codex-bg-hover);
     }
   }
 
-  .wb-spacer {
-    flex: 1 1 auto;
-  }
-
-  .wb-iconbtn {
-    display: inline-flex;
-    align-items: center;
-    justify-content: center;
-    width: 28px;
-    height: 28px;
+  /* 顶栏头像下拉(UserDropdown)跟随工作台文字色 */
+  .wb-top .user-dropdown-trigger {
     color: var(--codex-text-2);
-    background: transparent;
-    border: none;
-    border-radius: 7px;
-    cursor: pointer;
 
     &:hover {
       color: var(--codex-primary);
-      background: var(--codex-bg-hover);
     }
   }
 
-  .wb-avatar {
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    flex: 0 0 auto;
-    width: 26px;
-    height: 26px;
-    font-size: 12px;
-    font-weight: 700;
-    color: #fff;
-    background: var(--codex-primary);
-    border-radius: 50%;
-  }
-
   .wb-body {
     display: flex;
     flex: 1;

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

@@ -8,6 +8,7 @@ import windowControls from './src/WindowControls.vue';
 import appLocalePicker from './src/AppLocalePicker.vue';
 import appDarkModeToggle from './src/AppDarkModeToggle.vue';
 import appWorkbenchToggle from './src/AppWorkbenchToggle.vue';
+import appTopBar from './src/AppTopBar.vue';
 
 export { useAppProviderContext } from './src/useAppContext';
 
@@ -17,6 +18,7 @@ export const AppSearch = withInstall(appSearch);
 export const AppLocalePicker = withInstall(appLocalePicker);
 export const AppDarkModeToggle = withInstall(appDarkModeToggle);
 export const AppWorkbenchToggle = withInstall(appWorkbenchToggle);
+export const AppTopBar = withInstall(appTopBar);
 
 // 无边框窗口的标题栏:与主界面 LayoutHeader 同款外观(logo + 操作区 + 窗口控制)
 export const AppHeader = withInstall(appHeader);

+ 6 - 0
ai-electron/frontend/src/core/components/Application/src/AppLogo.vue

@@ -38,6 +38,10 @@
      * The title is also displayed when the menu is collapsed
      */
     alwaysShowTitle: { type: Boolean },
+    /**
+     * 自定义标题(如工作台的「清鉴 · 智能工作台」);不传则用全局应用标题
+     */
+    title: { type: String, default: '' },
   });
 
   const { prefixCls } = useDesign('app-logo');
@@ -54,6 +58,8 @@
   });
 
   const getLogoTitle = computed(() => {
+    const custom = String(props.title || '').trim();
+    if (custom) return custom;
     const title = String(unref(getTitle) || '').trim();
     if (!title) return '清鉴';
     return title.split('-')[0]?.trim() || title;

+ 255 - 0
ai-electron/frontend/src/core/components/Application/src/AppTopBar.vue

@@ -0,0 +1,255 @@
+<template>
+  <header class="app-topbar" :class="[`app-topbar--${theme}`]">
+    <!-- 左区:品牌 + 导航(传统壳放顶部菜单) -->
+    <div class="app-topbar__left">
+      <slot name="logo">
+        <!-- 默认即应用 logo:两个壳共用同一尺寸规则,保证外观与高度一致 -->
+        <AppLogo
+          v-if="showLogo"
+          class="app-topbar__logo"
+          :theme="theme"
+          :title="logoTitle"
+          :style="logoMinWidth ? { minWidth: `${logoMinWidth}px` } : undefined"
+        />
+      </slot>
+      <slot name="nav"></slot>
+    </div>
+
+    <!-- 案件区:默认「案件:xxx」胶囊;传统壳可用插槽换成自己的大字标题 -->
+    <slot name="case">
+      <span v-if="caseName" class="app-topbar__case" :title="caseName">
+        <span class="app-topbar__case-label">案件:</span>
+        <b>{{ caseName }}</b>
+        <button
+          v-if="showCaseExit"
+          class="app-topbar__case-exit"
+          type="button"
+          title="退出案件后从案件管理重新进入"
+          @click="emit('exit-case')"
+        >
+          退出
+        </button>
+      </span>
+    </slot>
+
+    <span class="app-topbar__spacer"></span>
+
+    <!-- 中间信息区(工作台:数据就绪 / 路由层 / 模型 等 chip) -->
+    <slot name="info"></slot>
+
+    <!-- 模式切换:两个壳共用同一个开关与跳转口径 -->
+    <AppWorkbenchToggle v-if="showModeSwitch" @change="setMode" />
+
+    <!-- 右侧动作区:传统壳放搜索 / 模板库 / 设置 / 退出;工作台放诊断 -->
+    <div class="app-topbar__actions jeesite-layout-header-action">
+      <slot name="actions"></slot>
+
+      <!-- 用户头像下拉(修改密码 / 用户管理 / 退出登录) -->
+      <UserDropdown />
+
+      <span class="app-topbar__divider"></span>
+      <!-- 无边框窗口控制:非桌面端自动不渲染 -->
+      <WindowControls class="window-controls-item" />
+    </div>
+  </header>
+</template>
+
+<script lang="ts" setup name="AppTopBar">
+  /**
+   * 顶部 header 公共组件:传统布局壳与智能工作台共用同一个顶栏。
+   *
+   * 统一持有:品牌/导航区、案件胶囊、模式切换、用户头像下拉、窗口控制,
+   * 以及无边框窗口的拖拽区(-webkit-app-region)与可交互元件的 no-drag 例外。
+   * 两个壳的差异通过插槽注入:
+   *  - #logo / #nav:传统壳放应用 logo 与顶部菜单;
+   *  - #case:传统壳保留自己的居中大字案件名(不传则用默认胶囊);
+   *  - #info:工作台放「数据就绪 / 路由层 / 模型」等状态 chip;
+   *  - #actions:各自的右侧按钮(搜索/模板库/设置 或 诊断)。
+   *
+   * 模式切换的跳转口径也收在这里(与路由守卫一致):目标模式与当前页不匹配时才跳转。
+   */
+  import AppLogo from '@/core/components/Application/src/AppLogo.vue';
+  import UserDropdown from '@/core/layouts/default/header/components/user-dropdown/index.vue';
+  import { AppWorkbenchToggle, WindowControls } from '@/core/components/Application';
+  import { useWorkbenchModeSwitch } from '@/core/logics/workbenchMode';
+
+  const props = withDefaults(
+    defineProps<{
+      /** 顶栏主题(跟随布局设置,默认亮色) */
+      theme?: 'light' | 'dark';
+      /** 是否显示模式切换开关 */
+      showModeSwitch?: boolean;
+      /** 当前案件名(空则不渲染案件区) */
+      caseName?: string | null;
+      /** 案件胶囊里是否显示「退出」(工作台用;退出后走案件管理重进) */
+      showCaseExit?: boolean;
+      /** 是否显示默认(应用)logo;使用方用 #logo 插槽时该开关无效 */
+      showLogo?: boolean;
+      /** logo 最小宽度(MIX 侧栏模式给左侧留出菜单宽度,传统壳会传) */
+      logoMinWidth?: number | null;
+      /** logo 文案(不传用全局应用标题;工作台传「清鉴 · 智能工作台」) */
+      logoTitle?: string;
+    }>(),
+    {
+      theme: 'light',
+      showModeSwitch: true,
+      caseName: null,
+      showCaseExit: false,
+      showLogo: true,
+      logoMinWidth: null,
+      logoTitle: '',
+    },
+  );
+
+  const emit = defineEmits<{
+    (e: 'exit-case'): void;
+  }>();
+
+  const { setMode } = useWorkbenchModeSwitch();
+</script>
+
+<style lang="less">
+  .app-topbar {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    flex: 0 0 auto;
+    height: @header-height;
+    padding: 0 8px 0 0;
+    /* 不设 line-height:40px+ 的行高会被胶囊/按钮继承,把它们撑出顶栏(旧布局壳是逐元素各写各的) */
+    line-height: normal;
+    background: @white;
+    border-bottom: 1px solid @header-light-bottom-border-color;
+    /* 无边框窗口:顶栏整条可拖动窗口 */
+    -webkit-app-region: drag;
+
+    &--dark {
+      background: @header-dark-bg-color;
+      border-bottom-color: @border-color-base;
+    }
+
+    &__left {
+      display: flex;
+      align-items: center;
+      height: 100%;
+      min-width: 0;
+      flex: 0 1 auto;
+    }
+
+    /* 应用 logo:高度撑满顶栏;两个壳共用这一份尺寸,避免各写一套漂移 */
+    &__logo {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      /* 用 100% 而非固定 48px:顶栏有 1px 下边框,固定值会溢出并被裁掉 */
+      height: 100%;
+      min-width: 192px;
+      padding: 0 10px;
+      font-size: 14px;
+      font-weight: bold;
+
+      /* 图片尺寸必须在这里定死:布局壳的对应规则只在传统壳样式表里,工作台路由下不会加载 */
+      img {
+        width: @logo-width;
+        height: @logo-width;
+        margin-right: 2px;
+        flex: 0 0 auto;
+      }
+    }
+
+    &__spacer {
+      flex: 1 1 auto;
+      min-width: 0;
+    }
+
+    /* 案件胶囊:开案后一眼看到当前案件,且明确「切换需退出重进」 */
+    &__case {
+      display: inline-flex;
+      align-items: center;
+      gap: 6px;
+      flex: 0 0 auto;
+      max-width: 320px;
+      padding: 3px 11px;
+      overflow: hidden;
+      font-size: 12.5px;
+      /* 自带行高:布局壳给顶栏设过 48px 行高,继承下来会把胶囊撑出顶栏 */
+      line-height: 1.5;
+      color: @text-color-secondary;
+      white-space: nowrap;
+      background: @component-background;
+      border: 1px solid @border-color-base;
+      border-radius: 99px;
+
+      b {
+        overflow: hidden;
+        font-weight: 600;
+        color: @text-color-base;
+        text-overflow: ellipsis;
+      }
+    }
+
+    &__case-label {
+      flex: 0 0 auto;
+      color: @text-color-secondary;
+    }
+
+    &__case-exit {
+      flex: 0 0 auto;
+      padding: 0;
+      font-size: 11.5px;
+      color: @text-color-help-dark;
+      background: transparent;
+      border: none;
+      cursor: pointer;
+
+      &:hover {
+        color: @error-color;
+      }
+    }
+
+    /* 交互元件必须退出拖拽区,否则点不动 */
+    &__case,
+    &__actions,
+    .jeesite-workbench-switch,
+    .user-dropdown-trigger,
+    .jeesite-window-controls {
+      -webkit-app-region: no-drag;
+    }
+
+    &__actions {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      flex: 0 0 auto;
+      padding-right: 4px;
+      color: @text-color-base;
+    }
+
+    &__divider {
+      display: block;
+      flex: 0 0 auto;
+      width: 1px;
+      height: 20px;
+      background: @border-color-base;
+      pointer-events: none;
+    }
+
+    &--dark &__divider {
+      background: rgba(255, 255, 255, 0.24);
+    }
+
+    /* 工作台里「数据就绪 / 路由层 / 模型」等状态 chip 的外观由使用方决定,
+       这里只保证它们不参与拖拽与换行 */
+    &__actions > *,
+    &__left > * {
+      -webkit-app-region: no-drag;
+    }
+  }
+
+  html[data-theme='dark'] {
+    .app-topbar:not(.app-topbar--dark) {
+      background: @header-dark-bg-color;
+      border-bottom-color: @border-color-base;
+    }
+  }
+</style>

+ 18 - 2
ai-electron/frontend/src/core/components/Application/src/AppWorkbenchToggle.vue

@@ -71,9 +71,10 @@
       height: 26px;
       padding: 0 12px;
       font-size: 12.5px;
-      color: inherit;
+      /* 显式取文字色:布局壳给顶栏设过 color:#fff,继承下来未选中项会白字白底看不见 */
+      color: @text-color-secondary;
       white-space: nowrap;
-      opacity: 0.75;
+      opacity: 0.85;
       background: transparent;
       border: none;
       border-radius: 99px;
@@ -96,6 +97,21 @@
     }
   }
 
+  /* 顶栏主题为深色(菜单配色选择深色)时,切换器文字要反白 */
+  .app-topbar--dark .jeesite-workbench-switch {
+    background: rgba(255, 255, 255, 0.1);
+    border-color: rgba(255, 255, 255, 0.16);
+
+    button {
+      color: rgba(255, 255, 255, 0.75);
+
+      &.is-on {
+        color: @primary-color;
+        background: rgba(255, 255, 255, 0.92);
+      }
+    }
+  }
+
   html[data-theme='dark'] .jeesite-workbench-switch {
     background: rgba(255, 255, 255, 0.08);
     border-color: rgba(255, 255, 255, 0.12);

+ 18 - 0
ai-electron/frontend/src/core/design/ant/index.less

@@ -221,3 +221,21 @@ html[data-theme='dark'] {
     display: none;
   }
 }
+
+/* 退出 / 删除类确认框的主按钮必须为红色(静止态即红,不靠 hover):
+   antd v4 的 okType 只接受按钮类型,危险色来自 okButtonProps.danger;
+   这里显式兜一层,避免其它全局按钮样式把它盖成蓝/白。 */
+.ant-modal-confirm .ant-btn-primary.ant-btn-dangerous {
+  color: #fff;
+  background: @error-color;
+  border-color: @error-color;
+
+  &:hover,
+  &:focus,
+  &:not(:disabled):hover,
+  &:not(:disabled):focus {
+    color: #fff;
+    background: lighten(@error-color, 8%);
+    border-color: lighten(@error-color, 8%);
+  }
+}

+ 27 - 4
ai-electron/frontend/src/core/layouts/default/header/components/user-dropdown/index.vue

@@ -16,8 +16,12 @@
           <span class="user-dropdown-item">用户管理</span>
         </MenuItem>
         <MenuDivider />
-        <MenuItem key="logout">
+        <MenuItem v-if="caseName" key="exitCase">
           <LogoutOutlined />
+          <span class="user-dropdown-item">退出案件</span>
+        </MenuItem>
+        <MenuItem key="logout">
+          <PoweroffOutlined />
           <span class="user-dropdown-item">退出登录</span>
         </MenuItem>
       </Menu>
@@ -45,11 +49,12 @@
   import { computed, defineComponent, reactive, ref } from 'vue';
   import { useRouter } from 'vue-router';
   import { Avatar, Dropdown, Form, FormItem, InputPassword, Menu, MenuDivider, MenuItem, Modal } from 'ant-design-vue';
-  import { DownOutlined, LockOutlined, LogoutOutlined, TeamOutlined } from '@ant-design/icons-vue';
+  import { DownOutlined, LockOutlined, LogoutOutlined, PoweroffOutlined, TeamOutlined } from '@ant-design/icons-vue';
   import { PageEnum } from '@/core/enums/pageEnum';
   import { useMessage } from '@/core/hooks/web/useMessage';
   import { useAccountStore } from '@/core/store/modules/account';
   import { useUserStore } from '@/core/store/modules/case';
+  import { exitCaseConfirmOptions } from '@/core/logics/exitCaseConfirm';
   import UserManageModal from '@/plat/views/user/UserManageModal.vue';
 
   export default defineComponent({
@@ -63,6 +68,7 @@
       InputPassword,
       LockOutlined,
       LogoutOutlined,
+      PoweroffOutlined,
       Menu,
       MenuDivider,
       MenuItem,
@@ -79,6 +85,8 @@
       const displayName = computed(() => accountStore.getDisplayName || '未登录');
       const isAdmin = computed(() => accountStore.getIsAdmin);
       const avatarText = computed(() => (displayName.value || '?').slice(0, 1).toUpperCase());
+      /** 当前案件名:为空说明没开案件,此时不提供「退出案件」 */
+      const caseName = computed(() => String(caseStore.getCaseInfo?.name || ''));
 
       const pwdVisible = ref(false);
       const pwdLoading = ref(false);
@@ -104,11 +112,25 @@
           userVisible.value = true;
           return;
         }
+        if (key === 'exitCase') {
+          // 与两个壳顶栏的案件胶囊「退出」完全同一套确认框与动作(保留登录态)
+          createConfirm(
+            exitCaseConfirmOptions(async () => {
+              await caseStore.logout(true);
+            }, caseName.value),
+          );
+          return;
+        }
         if (key === 'logout') {
+          // 注销登录:确认框样式与「退出案件」一致,但语义不同(会退出案件并注销 token)
           createConfirm({
             iconType: 'warning',
-            title: '提示',
-            content: '确认退出登录?退出后需要重新输入账号密码。',
+            title: '退出登录',
+            content: '确认退出登录?将退出当前案件并注销登录,需要重新输入账号密码。',
+            okText: '退出登录',
+            okType: 'primary',
+                    okButtonProps: { danger: true },
+            cancelText: '取消',
             onOk: async () => {
               await doLogout();
             },
@@ -147,6 +169,7 @@
         displayName,
         isAdmin,
         avatarText,
+        caseName,
         pwdVisible,
         pwdLoading,
         pwdForm,

+ 59 - 66
ai-electron/frontend/src/core/layouts/default/header/index.vue

@@ -1,40 +1,36 @@
 <template>
-  <ALayoutHeader :class="getHeaderClass">
-    <!-- left start -->
-    <div :class="`${prefixCls}-left`">
-      <!-- logo -->
-      <AppLogo
-        v-if="getShowHeaderLogo || getIsMobile"
-        :class="`${prefixCls}-logo`"
-        :theme="getHeaderTheme"
-        :style="getLogoWidth"
-      />
-      <!-- <LayoutTrigger
-        v-if="(getShowContent && getShowHeaderTrigger && !getSplit && !getIsMixSidebar) || getIsMobile"
-        :theme="getHeaderTheme"
-        :sider="false"
-      />
-      <LayoutBreadcrumb v-if="getShowContent && getShowBread" :theme="getHeaderTheme" /> -->
-    </div>
-    <!-- left end -->
-
-    <!-- menu start -->
-    <div :class="`${prefixCls}-menu`" v-if="getIsInitMenu && getShowTopMenu && !getIsMobile">
-      <LayoutMenu :isHorizontal="true" :theme="getHeaderTheme" :splitType="getSplitType" :menuMode="getMenuMode" />
-    </div>
-    <!-- menu-end -->
-
-    <div v-if="currentCaseName" :class="`${prefixCls}-case-name`" :title="currentCaseName">
-      {{ currentCaseName }}
-    </div>
-
-    <!-- action  -->
-    <div :class="`${prefixCls}-action`">
+  <!--
+    顶栏壳:结构 / 案件 / 模式切换 / 头像 / 窗口控制都在 AppTopBar 里,这里只注入传统布局特有的部分。
+    注意 ant-layout-header 这个类不能省:项目原有样式
+    `.ant-layout .ant-layout-header.jeesite-layout-header { position: relative }`(特异性高于 --fixed)
+    才是把顶栏留在文档流里的那一条;少了它,--fixed 的 position:fixed 生效,
+    主区会整体上移 48px 被顶栏压住(页签栏与内容首行显示不全)。
+  -->
+  <AppTopBar
+    :class="[getHeaderClass, 'ant-layout-header']"
+    :theme="getHeaderTheme"
+    :case-name="currentCaseName"
+    :show-logo="getShowHeaderLogo || getIsMobile"
+    :logo-min-width="logoMinWidth"
+    :show-mode-switch="false"
+    show-case-exit
+    @exit-case="handleExitCase"
+  >
+    <!-- 顶部菜单(品牌 logo 由公共顶栏统一渲染) -->
+    <template #nav>
+      <div v-if="getIsInitMenu && getShowTopMenu && !getIsMobile" :class="`${prefixCls}-menu`">
+        <LayoutMenu :isHorizontal="true" :theme="getHeaderTheme" :splitType="getSplitType" :menuMode="getMenuMode" />
+      </div>
+    </template>
+
+    <template #actions>
       <div :class="`${prefixCls}-search`">
         <AppSearch mode="bar" placeholder="搜索系统内容" />
       </div>
       <span :class="`${prefixCls}-search-divider`"></span>
-      <AppWorkbenchToggle @change="handleWorkbenchModeChange" />
+      <!-- 传统 ⇄ 智能:放在模板库前面 -->
+      <AppWorkbenchToggle @change="setWorkbenchMode" />
+
       <Tooltip v-if="showTemplateLibraryAction" title="模板库" placement="bottom" :mouseEnterDelay="0.5">
         <Button
           type="text"
@@ -51,23 +47,17 @@
       <!-- <FullScreen v-if="getShowFullScreen" class="fullscreen-item" /> -->
 
       <SettingDrawer v-if="getShowSetting" />
-
-      <LogoutButton class="logout-item" />
-      <!-- UserDropdown 是多根节点(Dropdown + 两个 Modal),传 class 无法继承会被丢弃,只会产生告警 -->
-      <UserDropdown />
-      <span :class="`${prefixCls}-search-divider`"></span>
-      <WindowControls class="window-controls-item" />
-    </div>
-  </ALayoutHeader>
+    </template>
+  </AppTopBar>
 </template>
 <script lang="ts">
   import { defineComponent, ref, unref, computed } from 'vue';
   import { storeToRefs } from 'pinia';
   import { useRoute } from 'vue-router';
   import { propTypes } from '@/core/utils/propTypes';
-  import { Button, Layout, Tooltip } from 'ant-design-vue';
-  import { AppLogo, AppWorkbenchToggle } from '@/core/components/Application';
-  import { AppSearch, WindowControls } from '@/core/components/Application';
+  import { Button, Tooltip } from 'ant-design-vue';
+  import { AppTopBar, AppWorkbenchToggle } from '@/core/components/Application';
+  import { AppSearch } from '@/core/components/Application';
   import { Icon } from '@/core/components/Icon';
   import { PageEnum } from '@/core/enums/pageEnum';
   import { MenuModeEnum, MenuSplitTyeEnum } from '@/core/enums/menuEnum';
@@ -77,18 +67,19 @@
   import { useRootSetting } from '@/core/hooks/setting/useRootSetting';
   import { useAppInject } from '@/core/hooks/web/useAppInject';
   import { useDesign } from '@/core/hooks/web/useDesign';
+  import { useMessage } from '@/core/hooks/web/useMessage';
+  import { useWorkbenchModeSwitch } from '@/core/logics/workbenchMode';
+  import { exitCaseConfirmOptions } from '@/core/logics/exitCaseConfirm';
   import { useGo } from '@/core/hooks/web/usePage';
   import { useLocale } from '@/core/locales/useLocale';
   import { onMountedOrActivated } from '@/core/hooks/core/onMountedOrActivated';
   import { useUserStore } from '@/core/store/modules/case';
-  import { useAppStoreWithOut } from '@/core/store/modules/app';
-  import type { WorkbenchMode } from '@/core/store/modules/app';
   import { useEmitter } from '@/core/store/modules/case';
   import { getAuthCache } from '@/core/utils/auth';
   import { CASE_INFO_KEY } from '@/core/enums/cacheEnum';
   import LayoutMenu from '../menu/index.vue';
   import LayoutTrigger from '../trigger/index.vue';
-  import { LayoutBreadcrumb, FullScreen, ErrorAction, SettingDrawer, LogoutButton, UserDropdown } from './components';
+  import { LayoutBreadcrumb, FullScreen, ErrorAction, SettingDrawer } from './components';
 
   const HEADER_TEMPLATE_LIBRARY_OPEN_EVENT = 'layout-header:open-template-library';
 
@@ -96,19 +87,15 @@
     name: 'LayoutHeader',
     components: {
       Button,
-      ALayoutHeader: Layout.Header,
-      AppLogo,
+      AppTopBar,
       AppWorkbenchToggle,
       LayoutTrigger,
       LayoutBreadcrumb,
       LayoutMenu,
       FullScreen,
       AppSearch,
-      WindowControls,
       ErrorAction,
       SettingDrawer,
-      LogoutButton,
-      UserDropdown,
       Tooltip,
       Icon,
     },
@@ -117,6 +104,8 @@
     },
     setup(props) {
       const { prefixCls } = useDesign('layout-header');
+      const { createConfirm } = useMessage();
+      const { setMode: setWorkbenchMode } = useWorkbenchModeSwitch();
       const userStore = useUserStore();
       const emitter = useEmitter();
       const route = useRoute();
@@ -172,12 +161,10 @@
         return settingButtonPosition === SettingButtonPositionEnum.HEADER;
       });
 
-      const getLogoWidth = computed(() => {
-        if (!unref(getIsMixMode) || unref(getIsMobile)) {
-          return {};
-        }
-        const width = unref(getMenuWidth) < 180 ? 180 : unref(getMenuWidth);
-        return { minWidth: `${width}px` };
+      /** MIX 侧栏模式下给 logo 留出与侧栏等宽的最小宽度(数值交给公共顶栏用) */
+      const logoMinWidth = computed(() => {
+        if (!unref(getIsMixMode) || unref(getIsMobile)) return null;
+        return unref(getMenuWidth) < 180 ? 180 : unref(getMenuWidth);
       });
 
       const getSplitType = computed(() => {
@@ -198,13 +185,6 @@
 
       const showTemplateLibraryAction = computed(() => true);
 
-      /** 主界面模式切换:智能 = 进入全屏 AI 工作台 */
-      const handleWorkbenchModeChange = (mode: WorkbenchMode) => {
-        useAppStoreWithOut().setWorkbenchMode(mode);
-        if (mode === 'smart') {
-          go(PageEnum.BASE_WORKBENCH, true);
-        }
-      };
 
       const handleTemplateLibraryClick = async () => {
         if (route.name !== 'ViewsDataIndex') {
@@ -213,6 +193,18 @@
         emitter.emit(HEADER_TEMPLATE_LIBRARY_OPEN_EVENT, undefined);
       };
 
+      /**
+       * 退出案件:与工作台、头像菜单同一套确认框与同一动作
+       * (store.logout(true) = 调后端 exitCase + 清案件态 + 回案件管理;账号登录态保留)。
+       */
+      const handleExitCase = () => {
+        createConfirm(
+          exitCaseConfirmOptions(async () => {
+            await userStore.logout(true);
+          }, currentCaseName.value),
+        );
+      };
+
       return {
         prefixCls,
         getHeaderClass,
@@ -230,7 +222,7 @@
         getShowLocalePicker,
         getShowFullScreen,
         getUseErrorHandle,
-        getLogoWidth,
+        logoMinWidth,
         getIsMixSidebar,
         getShowSettingButton,
         getShowSetting,
@@ -238,7 +230,8 @@
         currentCaseName,
         showTemplateLibraryAction,
         handleTemplateLibraryClick,
-        handleWorkbenchModeChange,
+        handleExitCase,
+        setWorkbenchMode,
       };
     },
   });

+ 33 - 0
ai-electron/frontend/src/core/logics/exitCaseConfirm.ts

@@ -0,0 +1,33 @@
+import { h } from 'vue';
+
+/**
+ * 「退出案件」确认框的统一参数。
+ *
+ * 三处入口共用同一套外观与动作,避免各写一遍出现样式/文案漂移:
+ *  - 传统布局壳顶栏的案件胶囊「退出」
+ *  - 智能工作台顶栏的案件胶囊「退出」
+ *  - 头像菜单的「退出案件」
+ *
+ * 说明:内容用 VNode 而不是字符串 —— createConfirm 对字符串走 innerHTML,
+ * 案件名属于用户数据,不能拼进 HTML。
+ */
+export function exitCaseConfirmOptions(
+  onOk: () => void | Promise<void>,
+  caseName?: string | null,
+) {
+  return {
+    iconType: 'warning' as const,
+    title: '退出案件',
+    content: h(
+      'div',
+      caseName
+        ? `确认退出「${caseName}」?退出后将返回案件管理,需要重新打开案件。`
+        : '确认退出当前案件?退出后将返回案件管理,需要重新打开案件。',
+    ),
+    okText: '退出案件',
+    okType: 'primary' as const,
+    okButtonProps: { danger: true },
+    cancelText: '取消',
+    onOk,
+  };
+}

+ 28 - 0
ai-electron/frontend/src/core/logics/workbenchMode.ts

@@ -0,0 +1,28 @@
+import { useRouter } from 'vue-router';
+import { useAppStoreWithOut, type WorkbenchMode } from '@/core/store/modules/app';
+import { PageEnum } from '@/core/enums/pageEnum';
+
+/** 智能工作台的全屏路由地址(守卫与切换器同口径) */
+export const WORKBENCH_PATH = '/workbench';
+
+/**
+ * 主界面模式切换的唯一实现:写持久化状态 + 只在「当前页与目标模式不匹配」时跳转。
+ *
+ * 传统布局壳(顶栏里切换)与智能工作台共用,避免两处各写一份跳转口径而后漂移。
+ */
+export function useWorkbenchModeSwitch() {
+  const router = useRouter();
+  const appStore = useAppStoreWithOut();
+
+  function setMode(mode: WorkbenchMode) {
+    appStore.setWorkbenchMode(mode);
+    const path = router.currentRoute.value.path;
+    if (mode === 'smart' && path !== WORKBENCH_PATH) {
+      router.replace(WORKBENCH_PATH);
+    } else if (mode === 'traditional' && path === WORKBENCH_PATH) {
+      router.replace(PageEnum.BASE_HOME);
+    }
+  }
+
+  return { setMode };
+}

+ 24 - 4
ai-electron/frontend/src/core/router/guard/index.ts

@@ -1,6 +1,7 @@
 import type { Router, RouteLocationNormalized } from 'vue-router';
 import { useAppStoreWithOut } from '@/core/store/modules/app';
 import { useUserStoreWithOut } from '@/core/store/modules/case';
+import { PageEnum } from '@/core/enums/pageEnum';
 import { useTransitionSetting } from '@/core/hooks/setting/useTransitionSetting';
 import { AxiosCanceler } from '@/core/utils/http/axios/axiosCancel';
 import { Modal, notification } from 'ant-design-vue';
@@ -45,16 +46,35 @@ function createWorkbenchGuard(router: Router) {
     '/aiPlugin/index',
     '/codexAnalysis/codexAnalysis',
   ];
+  const WORKBENCH_PATH = '/workbench';
+  /** 各自成壳的全屏页:不套 LAYOUT,两种模式下都直接访问 */
+  const SHELL_FREE_PATHS = [
+    PageEnum.BASE_CASE,
+    PageEnum.BASE_UPLOAD,
+    PageEnum.BASE_CLEANING,
+    PageEnum.BASE_CLEAN_PROGRESS,
+    PageEnum.BASE_AUTHORIZATION,
+  ];
+
   router.beforeEach((to) => {
-    const mode = useAppStoreWithOut().getWorkbenchMode;
+    const appStore = useAppStoreWithOut();
+    const mode = appStore.getWorkbenchMode;
+
     if (mode === 'smart') {
+      // AI 能力集中在工作台:老的 AI 菜单页一律引导回工作台
       if (AI_GROUP_PATHS.some((p) => to.path === p || to.path.startsWith(`${p}/`))) {
-        return { path: '/workbench', replace: true };
+        return { path: WORKBENCH_PATH, replace: true };
       }
+      // 业务分析页属于传统壳:智能模式下请求它们时把模式切回传统,
+      // 否则会出现「壳是传统布局、模式开关却还是智能」的错位
+      const shellFree = to.path === WORKBENCH_PATH || SHELL_FREE_PATHS.includes(to.path as PageEnum);
+      if (!shellFree) appStore.setWorkbenchMode('traditional');
       return;
     }
-    if (to.path === '/workbench') {
-      return { path: '/data/index', replace: true };
+
+    // 传统模式不接受工作台地址
+    if (to.path === WORKBENCH_PATH) {
+      return { path: PageEnum.BASE_HOME, replace: true };
     }
   });
 }

+ 0 - 5
ai-electron/frontend/src/plat/views/case/case.vue

@@ -251,11 +251,6 @@
           name: matchedCase.name,
         });
       }
-      notification.success({
-        message: '打开成功',
-        description: res?.name || matchedCase?.name || '',
-        duration: 2,
-      });
       return true;
     } catch (error: any) {
       const err: string = error?.toString?.() ?? '';