Explorar el Código

feat(frontend): 产物栏开关移至聊天头并优化收起与配色

- 聊天头右侧新增产物开关,收起状态持久化
- 产物栏改为白色卡片,收起后宽度与边距归零、不再保留触发条占位
cc hace 2 días
padre
commit
f9fe6d6eb0

+ 50 - 111
ai-electron/frontend/src/ai/components/ArtifactPanel.vue

@@ -1,32 +1,13 @@
 <template>
-  <!-- 右栏产物面板:宽 340(可拖 280–560),收起后只留一条带角标的触发条 -->
-  <aside
-    class="awb-aside"
-    :class="{ 'is-collapsed': collapsed }"
-    :style="{ width: `${collapsed ? COLLAPSED_WIDTH : width}px` }"
-  >
-    <!-- 收起态:右缘触发条,点击/悬停角标展开 -->
-    <button
-      v-if="collapsed"
-      class="awb-aside__strip"
-      type="button"
-      :title="artifacts.length ? `展开产物栏(${artifacts.length} 个文件)` : '展开产物栏'"
-      @click="setCollapsed(false)"
-    >
-      <Icon icon="mdi:file-delimited-outline" :size="15" />
-      <span v-if="artifacts.length" class="awb-aside__badge">{{ artifacts.length }}</span>
-    </button>
-
-    <template v-else>
+  <!-- 右栏产物面板:宽 340(可拖 280–560);收起由聊天头的产物按钮控制,收起后宽度为 0 -->
+  <aside class="awb-aside" :class="{ 'is-collapsed': collapsed }" :style="{ width: collapsed ? '0px' : `${width}px` }">
+    <template v-if="!collapsed">
       <header class="awb-aside__head">
         <span class="awb-aside__title">
           <Icon icon="mdi:file-delimited-outline" :size="15" />
           产物
           <em v-if="artifacts.length">{{ artifacts.length }}</em>
         </span>
-        <button class="awb-aside__btn" type="button" title="收起产物栏" @click="setCollapsed(true)">
-          <Icon icon="mdi:chevron-double-right" :size="16" />
-        </button>
       </header>
 
       <div class="awb-aside__body">
@@ -88,14 +69,15 @@
   import { artifactRowsText, artifactSizeText, openArtifactPreview } from '../utils/artifacts';
   import { toolLabel } from '../utils/constants';
 
-  const props = defineProps<{ artifacts: SessionArtifact[] }>();
+  const props = withDefaults(defineProps<{ artifacts: SessionArtifact[]; collapsed?: boolean }>(), {
+    collapsed: false,
+  });
+
+  const emit = defineEmits<{ 'update:collapsed': [value: boolean] }>();
 
   const { createMessage } = useMessage();
 
-  /** 收起态占位宽度(触发条) */
-  const COLLAPSED_WIDTH = 18;
   const WIDTH_KEY = 'awb.artifact.width';
-  const COLLAPSED_KEY = 'awb.artifact.collapsed';
   const MIN_WIDTH = 280;
   const MAX_WIDTH = 560;
 
@@ -105,7 +87,6 @@
   }
 
   const width = ref(readWidth());
-  const collapsed = ref(localStorage.getItem(COLLAPSED_KEY) === '1');
 
   /** 会话里已有的产物(挂载时打底):之后新到的才标「新」、才触发自动展开 */
   let knownCount = props.artifacts.length;
@@ -116,12 +97,22 @@
     (next) => {
       if (next > knownCount) {
         // 有新产物落盘:收起状态自动展开(Claude/Canvas 共性),否则用户感知不到
-        if (collapsed.value) setCollapsed(false);
+        if (props.collapsed) emit('update:collapsed', false);
       }
       knownCount = next;
     },
   );
 
+  watch(
+    () => props.collapsed,
+    (value) => {
+      if (!value) {
+        // 展开后,此刻列表里的都算已读
+        props.artifacts.forEach((a) => seenIds.add(a.id));
+      }
+    },
+  );
+
   /** 新在上 */
   const newestFirst = computed(() => [...props.artifacts].reverse());
 
@@ -129,15 +120,6 @@
     return !seenIds.has(a.id);
   }
 
-  function setCollapsed(value: boolean): void {
-    collapsed.value = value;
-    localStorage.setItem(COLLAPSED_KEY, value ? '1' : '0');
-    if (!value) {
-      // 展开后,此刻列表里的都算已读
-      props.artifacts.forEach((a) => seenIds.add(a.id));
-    }
-  }
-
   function onPreview(a: SessionArtifact): void {
     seenIds.add(a.id);
     openArtifactPreview(a);
@@ -190,62 +172,36 @@
     flex-direction: column;
     flex: 0 0 auto;
     min-height: 0;
+    margin: 8px 10px 8px 0;
     overflow: hidden;
-    background: var(--ai-bg-page);
-    border-left: 1px solid var(--ai-border);
+    background: #fff;
+    border: 1px solid #e7ebf3;
+    border-radius: 14px;
+    box-shadow: 0 1px 2px rgb(16 24 40 / 4%);
+    box-sizing: border-box;
     transition:
       width 0.18s ease-out,
+      margin 0.18s ease-out,
       border-color 0.18s ease-out;
 
+    /* 收起后不占位:宽度 0、无边距/描边/底色 */
     &.is-collapsed {
+      margin: 0;
       background: transparent;
-      border-left-color: var(--ai-border);
-    }
-  }
-
-  /* ── 收起态触发条 ── */
-  .awb-aside__strip {
-    display: flex;
-    flex-direction: column;
-    align-items: center;
-    gap: 6px;
-    width: 100%;
-    height: 100%;
-    padding: 10px 0 0;
-    color: var(--ai-text-2);
-    background: var(--ai-bg-subtle);
-    border: none;
-    cursor: pointer;
-    transition: color 0.15s ease;
-
-    &:hover {
-      color: var(--ai-primary);
+      border: 0;
+      box-shadow: none;
     }
   }
 
-  .awb-aside__badge {
-    display: inline-flex;
-    align-items: center;
-    justify-content: center;
-    min-width: 15px;
-    height: 15px;
-    padding: 0 4px;
-    font-size: 10px;
-    line-height: 15px;
-    color: #fff;
-    background: var(--ai-primary);
-    border-radius: 99px;
-  }
-
   /* ── 头部 ── */
   .awb-aside__head {
     display: flex;
     align-items: center;
     gap: var(--ai-sp-2);
     flex: 0 0 auto;
-    height: 40px;
-    padding: 0 var(--ai-sp-2) 0 var(--ai-sp-3);
-    border-bottom: 1px solid var(--ai-border);
+    height: 44px;
+    padding: 0 14px;
+    border-bottom: 1px solid #eff2f7;
   }
 
   .awb-aside__title {
@@ -254,33 +210,15 @@
     gap: 6px;
     flex: 1 1 auto;
     min-width: 0;
-    font-size: 13px;
+    color: #111827;
+    font-size: 13.5px;
     font-weight: 600;
-    color: var(--ai-text);
 
     em {
+      color: #98a2b3;
       font-style: normal;
-      font-variant-numeric: tabular-nums;
-      color: var(--ai-text-2);
       font-weight: 500;
-    }
-  }
-
-  .awb-aside__btn {
-    display: inline-flex;
-    align-items: center;
-    justify-content: center;
-    width: 26px;
-    height: 26px;
-    color: var(--ai-text-2);
-    background: transparent;
-    border: none;
-    border-radius: 6px;
-    cursor: pointer;
-
-    &:hover {
-      color: var(--ai-primary);
-      background: var(--ai-bg-hover);
+      font-variant-numeric: tabular-nums;
     }
   }
 
@@ -298,18 +236,19 @@
     align-items: center;
     gap: 6px;
     padding: 48px var(--ai-sp-5);
-    color: var(--ai-text-3, var(--ai-text-2));
+    color: #2a50ec;
     text-align: center;
 
     .tip {
       margin: 6px 0 0;
+      color: #111827;
       font-size: 13px;
       font-weight: 600;
-      color: var(--ai-text-2);
     }
 
     .sub {
       margin: 0;
+      color: #98a2b3;
       font-size: 12px;
       line-height: 1.7;
     }
@@ -334,7 +273,7 @@
     }
 
     &:hover {
-      background: var(--ai-bg-hover);
+      background: #f4f6fa;
 
       .awb-art__ops {
         opacity: 1;
@@ -362,9 +301,9 @@
     flex: 0 0 auto;
     width: 30px;
     height: 30px;
-    color: var(--ai-primary);
-    background: var(--ai-primary-soft);
-    border-radius: 7px;
+    color: #2a50ec;
+    background: rgb(42 80 236 / 8%);
+    border-radius: 8px;
   }
 
   .awb-art__meta {
@@ -395,12 +334,12 @@
 
   .awb-art__new {
     flex: 0 0 auto;
-    padding: 0 5px;
+    padding: 0 6px;
+    color: #2a50ec;
     font-size: 10.5px;
     line-height: 16px;
-    color: var(--ai-primary);
-    background: var(--ai-primary-soft);
-    border-radius: 99px;
+    background: rgb(42 80 236 / 8%);
+    border-radius: 6px;
   }
 
   .awb-art__ops {
@@ -425,8 +364,8 @@
       cursor: pointer;
 
       &:hover {
-        color: var(--ai-primary);
-        background: var(--ai-bg-page);
+        color: #2a50ec;
+        background: #f4f6fa;
       }
     }
   }

+ 12 - 0
ai-electron/frontend/src/ai/components/ChatPanel.vue

@@ -16,6 +16,9 @@
           <i class="ai-chat__head-dot"></i>
           生成中
         </span>
+        <div v-if="$slots['header-actions']" class="ai-chat__head-actions">
+          <slot name="header-actions"></slot>
+        </div>
       </div>
     </header>
 
@@ -369,6 +372,15 @@
     border-radius: 999px;
   }
 
+  .ai-chat__head-actions {
+    display: flex;
+    flex: 0 0 auto;
+    align-items: center;
+    margin-left: 4px;
+    padding-left: 12px;
+    border-left: 1px solid #eff2f7;
+  }
+
   .ai-chat__head-dot {
     width: 6px;
     height: 6px;

+ 92 - 3
ai-electron/frontend/src/ai/views/aiWorkbench/index.vue

@@ -56,7 +56,28 @@
           @stop="store.stop()"
           @star="store.toggleStar($event)"
           @remove="onRemoveMessage"
-        />
+        >
+          <template #header-actions>
+            <button
+              class="awb-artifact-toggle"
+              :class="{ 'is-collapsed': artifactsCollapsed }"
+              type="button"
+              :title="
+                artifactsCollapsed
+                  ? artifacts.length
+                    ? `展开产物栏(${artifacts.length} 个文件)`
+                    : '展开产物栏'
+                  : '收起产物栏'
+              "
+              @click="toggleArtifacts"
+            >
+              <Icon icon="mdi:file-delimited-outline" :size="14" />
+              <span class="awb-artifact-toggle__label">产物</span>
+              <span v-if="artifacts.length" class="awb-artifact-toggle__badge">{{ artifacts.length }}</span>
+              <Icon class="awb-artifact-toggle__caret" icon="mdi:chevron-double-right" :size="14" />
+            </button>
+          </template>
+        </ChatPanel>
 
         <!-- ② 模型管理 -->
         <AiModelPage v-else-if="view === 'model'" />
@@ -68,8 +89,8 @@
         <AiPluginPage v-else-if="view === 'plugin'" />
       </main>
 
-      <!-- 右栏:本会话产物(工具结果导出的 CSV)。可收起/拖宽,首次产出自动展开 -->
-      <ArtifactPanel v-if="view === 'chat'" :artifacts="artifacts" />
+      <!-- 右栏:本会话产物(工具结果导出的 CSV)。开关在聊天头,可收起/拖宽,首次产出自动展开 -->
+      <ArtifactPanel v-if="view === 'chat'" v-model:collapsed="artifactsCollapsed" :artifacts="artifacts" />
     </div>
 
     <!-- 产物预览弹窗:全局唯一实例,消息卡片与产物栏共用 -->
@@ -178,6 +199,21 @@
    */
   const artifacts = computed(() => extractArtifacts(store.messages || []));
 
+  /**
+   * 产物栏收起态:由聊天头右侧的开关控制。
+   * 状态放在工作台而非产物组件里,聊天头才能显示正确的图标与数量;
+   * 收起时产物栏宽度真正归零,不再保留触发条占位。
+   */
+  const ARTIFACT_COLLAPSED_KEY = 'awb.artifact.collapsed';
+  const artifactsCollapsed = ref(localStorage.getItem(ARTIFACT_COLLAPSED_KEY) === '1');
+  watch(artifactsCollapsed, (value) => {
+    localStorage.setItem(ARTIFACT_COLLAPSED_KEY, value ? '1' : '0');
+  });
+
+  function toggleArtifacts() {
+    artifactsCollapsed.value = !artifactsCollapsed.value;
+  }
+
   /**
    * 研判方案页「开始分析」:切回对话视图并进入**新对话草稿**——欢迎页可见、此时不落库,
    * 发送第一条消息时才创建会话并绑定该研判方案(绑定后不可更换)。
@@ -375,6 +411,59 @@
     background: #f4f6fa;
   }
 
+  /* 聊天头右侧的产物栏开关:展开时箭头指右,收起后翻转指左 */
+  .awb-artifact-toggle {
+    display: inline-flex;
+    align-items: center;
+    gap: 6px;
+    height: 28px;
+    padding: 0 9px;
+    color: #5b6577;
+    font-size: 11.5px;
+    font-weight: 500;
+    font-family: inherit;
+    background: #fff;
+    border: 1px solid #e7ebf3;
+    border-radius: 8px;
+    cursor: pointer;
+    transition:
+      color 0.15s ease,
+      background-color 0.15s ease,
+      border-color 0.15s ease;
+
+    &:hover {
+      color: #2a50ec;
+      background: #f8faff;
+      border-color: #bfcdfb;
+    }
+
+    &.is-collapsed {
+      color: #2a50ec;
+      background: rgb(42 80 236 / 7%);
+      border-color: rgb(42 80 236 / 18%);
+    }
+  }
+
+  .awb-artifact-toggle__badge {
+    min-width: 16px;
+    height: 16px;
+    padding: 0 4px;
+    color: #fff;
+    font-size: 10px;
+    line-height: 16px;
+    text-align: center;
+    background: #2a50ec;
+    border-radius: 999px;
+  }
+
+  .awb-artifact-toggle__caret {
+    transition: transform 0.15s ease;
+  }
+
+  .awb-artifact-toggle.is-collapsed .awb-artifact-toggle__caret {
+    transform: rotate(180deg);
+  }
+
   /*
    * 模型 / 插件页是按传统布局容器写的(.ai-page 反冲了左右 10px、自带圆角描边)。
    * 工作台中栏是全屏列,这里把页壳还原成「铺满、无外框」,避免溢出与多余卡片感。