浏览代码

feat(frontend): 智能工作台插件与技能页卡片化改版

- 顶部工具栏卡 + 内容卡结构,卡片/按钮/筛选控件对齐持续联系页
- 补齐技能卡片图标 safelist,工具包编辑器弹框统一样式
cc 1 天之前
父节点
当前提交
ea1927a52a
共有 2 个文件被更改,包括 899 次插入 和 199 次删除
  1. 896 199
      ai-electron/frontend/src/ai/views/aiPlugin/index.vue
  2. 3 0
      ai-electron/frontend/uno.config.ts

+ 896 - 199
ai-electron/frontend/src/ai/views/aiPlugin/index.vue

@@ -1,49 +1,95 @@
 <template>
   <div class="ai-page ai-plugin">
-    <a-tabs v-model:active-key="activeTab" class="ai-plugin__tabs" size="small" destroy-inactive-tab-pane>
-      <template #rightExtra>
-        <div class="ai-topbar__actions">
-          <span class="ai-topbar__meta ai-plugin__summary">{{ summary }}</span>
-          <a-button :loading="loading" @click="reload">
-            <template #icon><Icon icon="mdi:refresh" :size="14" /></template>
-            刷新
-          </a-button>
-          <!-- 顶栏主操作按页签提供(与模型页同构):技能=新增技能 + 上传 zip 安装,工具包=新建工具包;工具只读无顶栏操作 -->
-          <a-button v-if="activeTab === 'skill'" :disabled="installing" @click="openSkillCreator">
+    <!-- 顶部工具卡:页签切换 + 全局摘要 + 当前页签操作(视觉对齐自主模式「持续联系」页) -->
+    <section class="ai-plugin__toolbar">
+      <div class="ai-plugin__card-title">插件与技能</div>
+      <div class="ai-plugin__tabs" role="tablist" aria-label="插件与技能分类">
+        <button
+          v-for="tab in TABS"
+          :key="tab.key"
+          type="button"
+          role="tab"
+          class="ai-plugin__tab"
+          :class="{ 'is-on': activeTab === tab.key }"
+          :aria-selected="activeTab === tab.key"
+          @click="activeTab = tab.key"
+        >
+          <Icon :icon="tab.icon" :size="14" />
+          <span>{{ tab.label }}</span>
+          <span class="ai-plugin__tab-count">{{ tabCount(tab.key) }}</span>
+        </button>
+      </div>
+      <span class="ai-plugin__summary">{{ summary }}</span>
+      <span class="ai-plugin__spacer"></span>
+
+      <div class="ai-plugin__actions">
+        <a-button :loading="loading" @click="reload">
+          <template #icon><Icon icon="mdi:refresh" :size="14" /></template>
+          刷新
+        </a-button>
+        <!-- 主操作按页签提供:技能=新增/上传 zip,工具包=新建;工具只读无操作 -->
+        <template v-if="activeTab === 'skill'">
+          <a-button :disabled="installing" @click="openSkillCreator">
             <template #icon><Icon icon="mdi:plus" :size="14" /></template>
             新增技能
           </a-button>
-          <a-button
-            v-if="activeTab === 'skill'"
-            type="primary"
-            :disabled="installing"
-            :loading="installing"
-            @click="pickZip"
-          >
+          <a-button type="primary" :disabled="installing" :loading="installing" @click="pickZip">
             <template #icon><Icon icon="mdi:upload" :size="14" /></template>
             上传 zip 安装
           </a-button>
-          <a-button v-else-if="activeTab === 'pack'" type="primary" @click="openPackEditor(null)">
-            <template #icon><Icon icon="mdi:plus" :size="14" /></template>
-            新建工具包
-          </a-button>
-          <input ref="fileRef" class="ai-plugin__file" type="file" accept=".zip" @change="onZipPicked" />
-        </div>
-      </template>
-
-      <!-- ── ① 技能(SKILL.md 包) ──────────────────────────────── -->
-      <a-tab-pane key="skill">
-        <template #tab>
-          <span>技能</span>
-          <span class="ai-plugin__tab-count">{{ skills.length }}</span>
         </template>
+        <a-button v-else-if="activeTab === 'pack'" type="primary" @click="openPackEditor(null)">
+          <template #icon><Icon icon="mdi:plus" :size="14" /></template>
+          新建工具包
+        </a-button>
+      </div>
+      <input ref="fileRef" class="ai-plugin__file" type="file" accept=".zip" @change="onZipPicked" />
+    </section>
+
+    <!-- 内容卡:标题/说明 + 页签内筛选 + 卡片网格 -->
+    <section class="ai-plugin__panel">
+      <div class="ai-plugin__panel-head">
+        <span class="ai-plugin__card-title">{{ activeTabMeta.label }}</span>
+        <span class="ai-plugin__panel-desc">{{ activeTabMeta.desc }}</span>
+        <span class="ai-plugin__spacer"></span>
+
+        <div v-if="activeTab === 'tool'" class="ai-plugin__filters">
+          <a-select
+            v-model:value="groupFilter"
+            class="ai-plugin__select"
+            :options="groupOptions"
+            placeholder="全部分类"
+          />
+          <a-input
+            v-model:value="toolKeyword"
+            class="ai-plugin__search"
+            allow-clear
+            placeholder="搜索工具,如 夜间通话"
+          >
+            <template #prefix><Icon icon="mdi:magnify" :size="14" /></template>
+          </a-input>
+          <span class="ai-plugin__filter-count">
+            {{
+              filteredTools.length === tools.length
+                ? `共 ${tools.length} 个`
+                : `匹配 ${filteredTools.length} / ${tools.length} 个`
+            }}
+          </span>
+        </div>
+        <span v-else class="ai-plugin__panel-count">{{ activeTabMeta.countText }}</span>
+      </div>
 
-        <a-spin :spinning="loading">
+      <div class="ai-plugin__panel-body">
+        <!-- ── ① 技能(SKILL.md 包) ──────────────────────────────── -->
+        <a-spin v-if="activeTab === 'skill'" :spinning="loading">
           <div v-if="skills.length" class="ai-plugin__grid">
             <article v-for="skill in skills" :key="skill.path || skill.name" class="ai-plugin__card">
               <div class="ai-plugin__card-head">
-                <div class="ai-plugin__name">
-                  <span class="ai-plugin__ellipsis" :title="skill.name">{{ skill.name }}</span>
+                <span class="ai-plugin__card-icon">
+                  <Icon icon="mdi:script-text-outline" :size="18" />
+                </span>
+                <div class="ai-plugin__card-heading">
+                  <span class="ai-plugin__name" :title="skill.name">{{ skill.name }}</span>
                   <span class="ai-plugin__chip" :class="sourceMeta(skill).cls">{{ sourceMeta(skill).label }}</span>
                 </div>
                 <a-switch
@@ -52,187 +98,89 @@
                   size="small"
                   @change="(checked) => onToggle(skill, checked === true)"
                 />
-                <span v-else class="ai-plugin__status is-off">只读</span>
+                <span v-else class="ai-plugin__chip">只读</span>
               </div>
 
               <p class="ai-plugin__card-desc">{{ skill.description || '(SKILL.md 里没有 description)' }}</p>
 
               <div class="ai-plugin__card-ops">
-                <a-button type="link" size="small" @click="openSkill(skill)">查看详情</a-button>
-                <a-button v-if="skill.managed" type="link" size="small" @click="openSkillEditor(skill)">
+                <button type="button" class="ai-plugin__op" @click="openSkill(skill)">查看详情</button>
+                <button v-if="skill.managed" type="button" class="ai-plugin__op" @click="openSkillEditor(skill)">
                   编辑
-                </a-button>
-                <a-button v-if="skill.managed" type="link" size="small" danger @click="confirmRemove(skill)">
-                  卸载
-                </a-button>
-              </div>
-            </article>
-          </div>
-          <div v-else class="ai-plugin__empty">
-            还没有技能。点右上角「新增技能」在页面上直接创建,或上传一个包含 <b>SKILL.md</b> 的 zip
-            安装(包里只有一层技能目录时最省事)。
-          </div>
-        </a-spin>
-      </a-tab-pane>
-
-      <!-- ── ② 工具包(用户自定义工具组合,新建研判方案时勾选) ───────── -->
-      <a-tab-pane key="pack">
-        <template #tab>
-          <span>工具包</span>
-          <span class="ai-plugin__tab-count">{{ packs.length }}</span>
-        </template>
-
-        <!-- 工具包编辑器:新建/修改共用,默认窗口弹框(可右上角全屏);工具按业务组以卡片勾选(基础工具始终可用、不进包) -->
-        <BasicModal
-          :open="packEditor.open"
-          :title="packEditor.editing ? `修改工具包:${packEditor.name}` : '新建工具包'"
-          :width="1100"
-          wrap-class-name="ai-pack-modal"
-          :confirm-loading="packEditor.saving"
-          ok-text="保存"
-          cancel-text="取消"
-          @ok="savePack"
-          @cancel="closePackEditor"
-        >
-          <div class="ai-plugin__pack-form">
-            <div class="ai-plugin__pack-row">
-              <label class="ai-plugin__pack-label"><i class="ai-plugin__req">*</i>工具包名</label>
-              <input
-                v-model="packEditor.name"
-                class="ai-plugin__pack-input"
-                :maxlength="100"
-                placeholder="工具包名(如:影子公司穿透)"
-              />
-            </div>
-            <div class="ai-plugin__pack-row ai-plugin__pack-row--top">
-              <label class="ai-plugin__pack-label">描述</label>
-              <textarea
-                v-model="packEditor.description"
-                class="ai-plugin__pack-input ai-plugin__pack-textarea"
-                :maxlength="500"
-                placeholder="一句话描述(可选,会出现在新建研判方案的勾选卡片上)"
-              ></textarea>
-            </div>
-          </div>
-
-          <div class="ai-plugin__pack-section">
-            <div class="ai-plugin__pack-section-head">
-              <span class="ai-plugin__pack-section-title">选择工具</span>
-              <span class="ai-plugin__pack-count">{{ packEditorCount ? `已选 ${packEditorCount} 个` : '未选择' }}</span>
-              <span class="ai-plugin__pack-section-sp"></span>
-              <label class="ai-plugin__search">
-                <Icon icon="mdi:magnify" :size="14" />
-                <input v-model="packEditor.keyword" type="text" placeholder="搜索工具,如 大额交易" />
-              </label>
-            </div>
-            <div class="ai-plugin__pack-hint"
-              >点卡片勾选工具;基础工具(如「执行 SQL 查询」)始终可用、不进工具包。</div
-            >
-
-            <div v-for="g in packToolGroups" :key="g.label" class="ai-plugin__pack-group">
-              <div class="ai-plugin__pack-group-title">
-                {{ g.label }}
-                <span class="ai-plugin__chip">{{ g.tools.length }} 个</span>
-                <span class="ai-plugin__pack-group-sp"></span>
+                </button>
+                <span class="ai-plugin__ops-sp"></span>
                 <button
+                  v-if="skill.managed"
                   type="button"
-                  class="ai-plugin__pack-all"
-                  :class="{ 'is-on': isGroupAllOn(g) }"
-                  @click="toggleGroupAll(g)"
+                  class="ai-plugin__op is-danger"
+                  @click="confirmRemove(skill)"
                 >
-                  <span class="ai-plugin__pack-ck"><Icon icon="mdi:check" :size="11" /></span>
-                  全选
+                  卸载
                 </button>
               </div>
-              <div class="ai-plugin__pack-cards">
-                <div
-                  v-for="t in g.tools"
-                  :key="t.toolName"
-                  class="ai-plugin__pack-card"
-                  :class="{ 'is-on': packEditor.checked[t.toolName] }"
-                  @click="packEditor.checked[t.toolName] = !packEditor.checked[t.toolName]"
-                >
-                  <span class="ai-plugin__pack-ck"><Icon icon="mdi:check" :size="11" /></span>
-                  <div class="ai-plugin__pack-card-name">{{ toolLabel(t.toolName) }}</div>
-                  <div class="ai-plugin__pack-card-desc">{{ t.description || '(无说明)' }}</div>
-                </div>
-              </div>
-            </div>
-            <div v-if="!packToolGroups.length" class="ai-plugin__empty">没有匹配的工具。</div>
+            </article>
           </div>
-        </BasicModal>
+          <EmptyState
+            v-else
+            title="还没有技能"
+            description="点右上角「新增技能」在页面上直接创建,或上传一个包含 SKILL.md 的 zip 安装。"
+            :min-height="300"
+            description-width="min(100%, 520px)"
+          />
+        </a-spin>
 
-        <a-spin :spinning="packsLoading">
+        <!-- ── ② 工具包(用户自定义工具组合,新建研判方案时勾选) ───────── -->
+        <a-spin v-else-if="activeTab === 'pack'" :spinning="packsLoading">
           <div v-if="packs.length" class="ai-plugin__grid">
             <article v-for="p in packs" :key="p.id" class="ai-plugin__card is-clickable" @click="openPack(p)">
-              <!-- 右上角来源角标(与研判方案卡同构);旧后端无 builtin 字段时不显示,避免误标 -->
-              <span
-                v-if="p.builtin !== undefined"
-                class="ai-plugin__pack-badge"
-                :class="p.builtin ? 'is-builtin' : 'is-custom'"
-              >
-                {{ p.builtin ? '内置' : '自定义' }}
-              </span>
-              <div class="ai-plugin__card-head ai-plugin__pack-head">
-                <div class="ai-plugin__name">
-                  <span class="ai-plugin__ellipsis" :title="p.name">{{ p.name }}</span>
-                  <span class="ai-plugin__chip">{{ p.toolCount }} 个工具</span>
+              <div class="ai-plugin__card-head">
+                <span class="ai-plugin__card-icon is-pack">
+                  <Icon icon="mdi:package-variant-closed" :size="18" />
+                </span>
+                <div class="ai-plugin__card-heading">
+                  <div class="ai-plugin__name-row">
+                    <span class="ai-plugin__name" :title="p.name">{{ p.name }}</span>
+                    <!-- 旧后端无 builtin 字段时不显示,避免误标 -->
+                    <span v-if="p.builtin !== undefined" class="ai-plugin__chip" :class="p.builtin ? '' : 'is-primary'">
+                      {{ p.builtin ? '内置' : '自定义' }}
+                    </span>
+                  </div>
+                  <span class="ai-plugin__card-sub">{{ p.toolCount }} 个工具</span>
                 </div>
               </div>
+
               <p class="ai-plugin__card-desc">{{ p.description || '(无描述)' }}</p>
+
               <div class="ai-plugin__card-tools">
-                <span v-for="n in p.toolNames.slice(0, 4)" :key="n" class="ai-plugin__chip">{{ toolLabel(n) }}</span>
-                <span v-if="p.toolNames.length > 4" class="ai-plugin__chip">+{{ p.toolNames.length - 4 }}</span>
+                <span v-for="n in p.toolNames.slice(0, 4)" :key="n" class="ai-plugin__chip is-soft">
+                  {{ toolLabel(n) }}
+                </span>
+                <span v-if="p.toolNames.length > 4" class="ai-plugin__chip is-soft">
+                  +{{ p.toolNames.length - 4 }}
+                </span>
               </div>
+
               <div class="ai-plugin__card-ops" @click.stop>
-                <a-button type="link" size="small" @click="openPack(p)">查看工具</a-button>
-                <span class="ai-plugin__ops-sp"></span>
-                <!-- 内置工具包不可编辑/删除(入口不渲染,后端另有双重校验) -->
+                <button type="button" class="ai-plugin__op" @click="openPack(p)">查看工具</button>
                 <template v-if="p.mine">
-                  <a-button type="link" size="small" @click="openPackEditor(p)">编辑</a-button>
-                  <a-button type="link" size="small" danger @click="confirmDeletePack(p)">删除</a-button>
+                  <button type="button" class="ai-plugin__op" @click="openPackEditor(p)">编辑</button>
+                  <span class="ai-plugin__ops-sp"></span>
+                  <button type="button" class="ai-plugin__op is-danger" @click="confirmDeletePack(p)"> 删除 </button>
                 </template>
               </div>
             </article>
           </div>
-          <div v-else class="ai-plugin__empty">
-            还没有工具包。点右上角「新建工具包」,把任意工具组合成可复用的工具包,新建研判方案时可整体勾选。
-          </div>
-        </a-spin>
-      </a-tab-pane>
-
-      <!-- ── ③ 工具(只读,数据来自 agent_tool 表) ─────────────── -->
-      <a-tab-pane key="tool">
-        <template #tab>
-          <span>工具</span>
-          <span class="ai-plugin__tab-count">{{ tools.length }}</span>
-        </template>
-
-        <div class="ai-plugin__ops">
-          <a-select
-            v-model:value="groupFilter"
-            class="ai-plugin__select"
-            :options="groupOptions"
-            placeholder="全部分类"
+          <EmptyState
+            v-else
+            title="还没有工具包"
+            description="点右上角「新建工具包」,把任意工具组合成可复用的工具包,新建研判方案时可整体勾选。"
+            :min-height="300"
+            description-width="min(100%, 560px)"
           />
-          <a-input
-            v-model:value="toolKeyword"
-            class="ai-plugin__search"
-            allow-clear
-            placeholder="搜索工具,如 夜间通话"
-          >
-            <template #prefix><Icon icon="mdi:magnify" :size="14" /></template>
-          </a-input>
-          <span class="ai-plugin__filter-count">
-            {{
-              filteredTools.length === tools.length
-                ? `共 ${tools.length} 个`
-                : `匹配 ${filteredTools.length} / ${tools.length} 个`
-            }}
-          </span>
-        </div>
+        </a-spin>
 
-        <a-spin :spinning="loading">
+        <!-- ── ③ 工具(只读,数据来自 agent_tool 表) ─────────────── -->
+        <a-spin v-else :spinning="loading">
           <div v-if="filteredTools.length" class="ai-plugin__grid">
             <article
               v-for="tool in filteredTools"
@@ -241,25 +189,118 @@
               @click="openTool(tool)"
             >
               <div class="ai-plugin__card-head">
-                <span class="ai-plugin__tool-name ai-plugin__ellipsis">{{ toolLabel(tool.toolName) }}</span>
+                <span class="ai-plugin__card-icon is-tool">
+                  <Icon icon="mdi:tools" :size="18" />
+                </span>
+                <div class="ai-plugin__card-heading">
+                  <span class="ai-plugin__name" :title="tool.toolName">{{ toolLabel(tool.toolName) }}</span>
+                  <span class="ai-plugin__card-sub">{{ tool.toolName }}</span>
+                </div>
                 <span v-if="tool.readOnly" class="ai-plugin__chip">内置</span>
               </div>
 
               <div class="ai-plugin__card-meta">
-                <span class="ai-plugin__chip">{{ tool.groupLabel || tool.groupCode || '未分组' }}</span>
+                <span class="ai-plugin__chip is-soft">{{ tool.groupLabel || tool.groupCode || '未分组' }}</span>
               </div>
 
               <p class="ai-plugin__card-desc">{{ tool.description || '(无说明)' }}</p>
+
+              <div class="ai-plugin__card-ops">
+                <span class="ai-plugin__link">
+                  查看详情
+                  <Icon icon="mdi:chevron-right" :size="14" />
+                </span>
+              </div>
             </article>
           </div>
-          <div v-else class="ai-plugin__empty">
-            {{
-              tools.length ? '没有匹配的工具' : '还没有同步到工具清单(服务端启动时会自动同步,需先建 agent_tool 表)'
-            }}
-          </div>
+          <EmptyState
+            v-else
+            :title="tools.length ? '没有匹配的工具' : '还没有同步到工具清单'"
+            :description="tools.length ? '换个分类或关键字再试试。' : '服务端启动时会自动同步,需先建 agent_tool 表。'"
+            :min-height="300"
+            description-width="min(100%, 520px)"
+          />
         </a-spin>
-      </a-tab-pane>
-    </a-tabs>
+      </div>
+    </section>
+
+    <!-- 弹框:工具包编辑器(新建/修改共用;工具按业务组以卡片勾选) -->
+    <BasicModal
+      :open="packEditor.open"
+      :title="packEditor.editing ? `修改工具包:${packEditor.name}` : '新建工具包'"
+      :width="1100"
+      wrap-class-name="ai-pack-modal"
+      :confirm-loading="packEditor.saving"
+      ok-text="保存"
+      cancel-text="取消"
+      @ok="savePack"
+      @cancel="closePackEditor"
+    >
+      <div class="ai-plugin__pack-form">
+        <div class="ai-plugin__pack-row">
+          <label class="ai-plugin__pack-label"><i class="ai-plugin__req">*</i>工具包名</label>
+          <input
+            v-model="packEditor.name"
+            class="ai-plugin__pack-input"
+            :maxlength="100"
+            placeholder="工具包名(如:影子公司穿透)"
+          />
+        </div>
+        <div class="ai-plugin__pack-row ai-plugin__pack-row--top">
+          <label class="ai-plugin__pack-label">描述</label>
+          <textarea
+            v-model="packEditor.description"
+            class="ai-plugin__pack-input ai-plugin__pack-textarea"
+            :maxlength="500"
+            placeholder="一句话描述(可选,会出现在新建研判方案的勾选卡片上)"
+          ></textarea>
+        </div>
+      </div>
+
+      <div class="ai-plugin__pack-section">
+        <div class="ai-plugin__pack-section-head">
+          <span class="ai-plugin__pack-section-title">选择工具</span>
+          <span class="ai-plugin__pack-count">{{ packEditorCount ? `已选 ${packEditorCount} 个` : '未选择' }}</span>
+          <span class="ai-plugin__pack-section-sp"></span>
+          <label class="ai-plugin__pack-search">
+            <Icon icon="mdi:magnify" :size="14" />
+            <input v-model="packEditor.keyword" type="text" placeholder="搜索工具,如 大额交易" />
+          </label>
+        </div>
+        <div class="ai-plugin__pack-hint">点卡片勾选工具;基础工具(如「执行 SQL 查询」)始终可用、不进工具包。</div>
+
+        <div v-for="g in packToolGroups" :key="g.label" class="ai-plugin__pack-group">
+          <div class="ai-plugin__pack-group-title">
+            {{ g.label }}
+            <span class="ai-plugin__chip">{{ g.tools.length }} 个</span>
+            <span class="ai-plugin__pack-group-sp"></span>
+            <button
+              type="button"
+              class="ai-plugin__pack-all"
+              :class="{ 'is-on': isGroupAllOn(g) }"
+              @click="toggleGroupAll(g)"
+            >
+              <span class="ai-plugin__pack-ck"><Icon icon="mdi:check" :size="11" /></span>
+              全选
+            </button>
+          </div>
+          <div class="ai-plugin__pack-cards">
+            <div
+              v-for="t in g.tools"
+              :key="t.toolName"
+              class="ai-plugin__pack-card"
+              :class="{ 'is-on': packEditor.checked[t.toolName] }"
+              @click="packEditor.checked[t.toolName] = !packEditor.checked[t.toolName]"
+            >
+              <span class="ai-plugin__pack-ck"><Icon icon="mdi:check" :size="11" /></span>
+              <div class="ai-plugin__pack-card-name">{{ toolLabel(t.toolName) }}</div>
+              <div class="ai-plugin__pack-card-desc">{{ t.description || '(无说明)' }}</div>
+            </div>
+          </div>
+        </div>
+        <div v-if="!packToolGroups.length" class="ai-plugin__pack-empty">没有匹配的工具。</div>
+      </div>
+    </BasicModal>
 
     <!-- 弹框:工具详情(说明 + 所属工具组说明) -->
     <a-modal
@@ -350,8 +391,9 @@
    * 原「MCP 接入」页签已按需求移除(服务端 MCP Server 能力本身保留,仅不再在页面展示)。
    */
   import { computed, onMounted, reactive, ref } from 'vue';
-  import { Button, Checkbox, Input, Modal, Select, Spin, Switch, Tabs } from 'ant-design-vue';
+  import { Button, Input, Modal, Select, Spin, Switch } from 'ant-design-vue';
   import { BasicModal } from '@/core/components/Modal';
+  import { EmptyState } from '@/core/components/EmptyState';
   import Icon from '@/core/components/Icon/src/Icon.vue';
   import { useMessage } from '@/core/hooks/web/useMessage';
   import { useUserStore } from '@/core/store/modules/case';
@@ -363,10 +405,7 @@
   import SkillEditorModal from '../../components/SkillEditorModal.vue';
   import type { AgentTool, SaveToolPackParams, Skill, ToolPack } from '../../api/types';
 
-  const ATabs = Tabs;
-  const ATabPane = Tabs.TabPane;
   const AButton = Button;
-  const ACheckbox = Checkbox;
   const AInput = Input;
   const AModal = Modal;
   const ASelect = Select;
@@ -407,6 +446,41 @@
     () => `${skills.value.length} 个技能 · ${packs.value.length} 个工具包 · ${tools.value.length} 个工具`,
   );
 
+  /** 页签元数据:顶部切换按钮与内容卡标题/说明共用一份,避免两处文案漂移 */
+  const TABS: Array<{ key: PluginTab; label: string; icon: string; desc: string }> = [
+    {
+      key: 'skill',
+      label: '技能',
+      icon: 'mdi:script-text-outline',
+      desc: '用 SKILL.md 描述任务步骤,AI 在对话中按需加载。',
+    },
+    {
+      key: 'pack',
+      label: '工具包',
+      icon: 'mdi:package-variant-closed',
+      desc: '把常用工具组合成可复用的工具包,新建研判方案时整体勾选。',
+    },
+    {
+      key: 'tool',
+      label: '工具',
+      icon: 'mdi:tools',
+      desc: '服务端注册的可用工具清单,按分类筛选查看工具说明。',
+    },
+  ];
+
+  const activeTabMeta = computed(() => {
+    const meta = TABS.find((t) => t.key === activeTab.value) ?? TABS[0];
+    if (meta.key === 'skill') return { ...meta, countText: `共 ${skills.value.length} 个技能` };
+    if (meta.key === 'pack') return { ...meta, countText: `共 ${packs.value.length} 个工具包` };
+    return { ...meta, countText: `共 ${tools.value.length} 个工具` };
+  });
+
+  function tabCount(key: PluginTab) {
+    if (key === 'skill') return skills.value.length;
+    if (key === 'pack') return packs.value.length;
+    return tools.value.length;
+  }
+
   /** 已安装技能名:新增技能弹框创建前做同名预判(服务端仍会再校验一次) */
   const skillNames = computed(() => skills.value.map((s) => s.name));
 
@@ -1355,9 +1429,632 @@
     -webkit-box-orient: vertical;
     overflow: hidden;
   }
+
+  /* ======================================================================
+   * 卡片化改版:对齐自主模式「持续联系」页的视觉规范
+   * 页面底 #f4f6fa · 白卡圆角 14 · 描边 #e7ebf3 · 标题蓝条 · 主按钮 #2a50ec
+   * ==================================================================== */
+  .ai-page.ai-plugin {
+    gap: 8px;
+    width: 100%;
+    height: 100%;
+    min-height: 0;
+    margin: 0;
+    padding: 8px 10px;
+    background: #f4f6fa;
+    border: 0;
+    border-radius: 0;
+    box-sizing: border-box;
+    overflow: hidden;
+  }
+
+  .ai-plugin {
+    &__toolbar {
+      display: flex;
+      flex: 0 0 auto;
+      align-items: center;
+      gap: 12px;
+      min-height: 52px;
+      padding: 10px 14px;
+      background: #fff;
+      border: 1px solid #e7ebf3;
+      border-radius: 14px;
+      box-shadow: 0 1px 2px rgb(16 24 40 / 4%);
+      box-sizing: border-box;
+    }
+
+    &__card-title {
+      position: relative;
+      flex: 0 0 auto;
+      padding-left: 11px;
+      color: #111827;
+      font-size: 15px;
+      font-weight: 600;
+      line-height: 22px;
+      white-space: nowrap;
+
+      &::before {
+        content: '';
+        position: absolute;
+        top: 4px;
+        bottom: 4px;
+        left: 0;
+        width: 3px;
+        background: #2a50ec;
+        border-radius: 2px;
+      }
+    }
+
+    &__tabs {
+      display: inline-flex;
+      flex: 0 0 auto;
+      align-items: center;
+      gap: 2px;
+      padding: 3px;
+      background: #f4f6fa;
+      border: 1px solid #e7ebf3;
+      border-radius: 10px;
+    }
+
+    &__tab {
+      display: inline-flex;
+      align-items: center;
+      gap: 6px;
+      height: 28px;
+      padding: 0 10px;
+      color: #5b6577;
+      font-size: 12.5px;
+      font-weight: 500;
+      font-family: inherit;
+      line-height: 1;
+      white-space: nowrap;
+      background: transparent;
+      border: 0;
+      border-radius: 8px;
+      cursor: pointer;
+      transition:
+        color 0.15s ease,
+        background-color 0.15s ease,
+        box-shadow 0.15s ease;
+
+      &:hover {
+        color: #2a50ec;
+        background: rgb(42 80 236 / 6%);
+      }
+
+      &.is-on {
+        color: #2a50ec;
+        font-weight: 600;
+        background: #fff;
+        box-shadow: 0 1px 3px rgb(16 24 40 / 10%);
+      }
+    }
+
+    &__tab-count {
+      display: inline-block;
+      min-width: 18px;
+      padding: 0 5px;
+      font-size: 11px;
+      line-height: 16px;
+      color: inherit;
+      text-align: center;
+      background: rgb(42 80 236 / 8%);
+      border-radius: 999px;
+    }
+
+    &__summary {
+      max-width: 220px;
+      overflow: hidden;
+      color: #98a2b3;
+      font-size: 11.5px;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+    }
+
+    &__spacer {
+      flex: 1 1 auto;
+    }
+
+    &__actions {
+      display: flex;
+      flex: 0 0 auto;
+      align-items: center;
+      gap: 8px;
+
+      :deep(.ant-btn) {
+        height: 30px;
+        padding: 0 14px;
+        color: #5b6577;
+        font-size: 12px;
+        font-weight: 500;
+        background: #fff;
+        border: 1px solid #e7ebf3;
+        border-radius: 8px;
+        box-shadow: none;
+
+        &:hover,
+        &:focus {
+          color: #2a50ec;
+          background: #f8faff;
+          border-color: #bfcdfb;
+        }
+      }
+
+      :deep(.ant-btn-primary) {
+        color: #fff;
+        background: #2a50ec;
+        border-color: #2a50ec;
+
+        &:hover,
+        &:focus {
+          color: #fff;
+          background: #4b6bf0;
+          border-color: #4b6bf0;
+        }
+
+        &:active {
+          background: #1e3ed0;
+          border-color: #1e3ed0;
+        }
+      }
+
+      :deep(.ant-btn[disabled]) {
+        color: rgb(0 0 0 / 25%);
+        background: #f5f5f5;
+        border-color: #e7ebf3;
+      }
+    }
+
+    &__panel {
+      display: flex;
+      flex: 1 1 auto;
+      flex-direction: column;
+      min-height: 0;
+      background: #fff;
+      border: 1px solid #e7ebf3;
+      border-radius: 14px;
+      box-shadow: 0 1px 2px rgb(16 24 40 / 4%);
+      overflow: hidden;
+    }
+
+    &__panel-head {
+      display: flex;
+      flex: 0 0 auto;
+      align-items: center;
+      gap: 10px;
+      min-height: 48px;
+      padding: 8px 14px;
+      border-bottom: 1px solid #eff2f7;
+      box-sizing: border-box;
+    }
+
+    &__panel-desc {
+      min-width: 0;
+      overflow: hidden;
+      color: #98a2b3;
+      font-size: 11.5px;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+    }
+
+    &__panel-count {
+      color: #98a2b3;
+      font-size: 11.5px;
+      white-space: nowrap;
+      font-variant-numeric: tabular-nums;
+    }
+
+    &__panel-body {
+      flex: 1 1 auto;
+      min-height: 0;
+      padding: 14px;
+      overflow-y: auto;
+      overscroll-behavior: contain;
+      box-sizing: border-box;
+    }
+
+    &__filters {
+      display: flex;
+      flex: 0 0 auto;
+      align-items: center;
+      gap: 8px;
+    }
+
+    &__select {
+      width: 180px;
+
+      :deep(.ant-select-selector) {
+        height: 32px !important;
+        padding: 0 10px;
+        background: #fff !important;
+        border: 1px solid #e7ebf3 !important;
+        border-radius: 8px !important;
+        box-shadow: none !important;
+      }
+
+      :deep(.ant-select-selection-item),
+      :deep(.ant-select-selection-placeholder) {
+        font-size: 12px;
+        line-height: 30px;
+      }
+    }
+
+    &__search {
+      display: inline-flex;
+      align-items: center;
+      width: 240px;
+      height: 32px;
+      padding: 0 10px;
+      font-size: 12px;
+      background: #fff;
+      border: 1px solid #e7ebf3;
+      border-radius: 8px;
+      box-shadow: none;
+      transition:
+        border-color 0.2s ease,
+        box-shadow 0.2s ease;
+
+      &:hover {
+        border-color: #bfcdfb;
+      }
+
+      &:focus,
+      &.ant-input-affix-wrapper-focused {
+        border-color: #2a50ec;
+        box-shadow: 0 0 0 2px rgb(42 80 236 / 12%);
+      }
+
+      :deep(.ant-input) {
+        height: 100%;
+        padding: 0;
+        font-size: 12px;
+        background: transparent;
+        border: 0;
+        box-shadow: none;
+      }
+
+      :deep(.ant-input-clear-icon) {
+        color: #98a2b3;
+        font-size: 12px;
+      }
+    }
+
+    &__filter-count {
+      margin-left: 2px;
+      color: #98a2b3;
+      font-size: 11.5px;
+      white-space: nowrap;
+      font-variant-numeric: tabular-nums;
+    }
+
+    &__grid {
+      display: grid;
+      grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+      gap: 12px;
+    }
+
+    &__card {
+      gap: 10px;
+      padding: 14px;
+      background: #fff;
+      border: 1px solid #e7ebf3;
+      border-radius: 12px;
+      box-sizing: border-box;
+      transition:
+        border-color 0.15s ease,
+        box-shadow 0.15s ease;
+
+      &:hover {
+        border-color: #bfcdfb;
+        box-shadow: 0 4px 14px rgb(16 24 40 / 8%);
+        transform: none;
+      }
+
+      &.is-clickable {
+        cursor: pointer;
+      }
+
+      &.is-clickable:hover {
+        border-color: #bfcdfb;
+        box-shadow: 0 4px 14px rgb(16 24 40 / 8%);
+        transform: none;
+      }
+    }
+
+    &__card-head {
+      align-items: flex-start;
+      justify-content: flex-start;
+      gap: 10px;
+    }
+
+    &__card-icon {
+      display: inline-flex;
+      flex: 0 0 auto;
+      align-items: center;
+      justify-content: center;
+      width: 34px;
+      height: 34px;
+      color: #2a50ec;
+      background: rgb(42 80 236 / 8%);
+      border-radius: 9px;
+
+      &.is-pack {
+        color: #7c3aed;
+        background: rgb(124 58 237 / 8%);
+      }
+
+      &.is-tool {
+        color: #0d9488;
+        background: rgb(13 148 136 / 8%);
+      }
+    }
+
+    &__card-heading {
+      display: flex;
+      flex: 1 1 auto;
+      flex-direction: column;
+      gap: 4px;
+      min-width: 0;
+    }
+
+    &__name-row {
+      display: flex;
+      align-items: center;
+      gap: 6px;
+      min-width: 0;
+    }
+
+    &__name {
+      display: block;
+      min-width: 0;
+      overflow: hidden;
+      color: #111827;
+      font-size: 14px;
+      font-weight: 600;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+    }
+
+    &__card-sub {
+      overflow: hidden;
+      color: #98a2b3;
+      font-size: 11.5px;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+      font-variant-numeric: tabular-nums;
+    }
+
+    &__card-head :deep(.ant-switch) {
+      flex: 0 0 auto;
+      margin-top: 6px;
+    }
+
+    /* 技能卡里 chip 是纵向 flex 的直接子项,不收回内容宽度会被拉满 */
+    &__card-heading > .ai-plugin__chip {
+      align-self: flex-start;
+    }
+
+    &__chip {
+      max-width: 220px;
+      height: 20px;
+      padding: 0 7px;
+      overflow: hidden;
+      color: #5b6577;
+      font-size: 11px;
+      font-weight: 500;
+      line-height: 18px;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+      background: #f4f6fa;
+      border: 1px solid #e7ebf3;
+      border-radius: 6px;
+
+      &.is-soft {
+        color: #4b5a7a;
+        background: #f7f9ff;
+        border-color: #e3e9fb;
+      }
+
+      &.is-primary {
+        color: #2a50ec;
+        background: rgb(42 80 236 / 7%);
+        border-color: rgb(42 80 236 / 18%);
+      }
+    }
+
+    &__card-desc {
+      min-height: calc(12.5px * 1.7 * 2);
+      margin: 0;
+      color: #5b6577;
+      font-size: 12.5px;
+      line-height: 1.7;
+      -webkit-line-clamp: 3;
+    }
+
+    &__card-meta,
+    &__card-tools {
+      display: flex;
+      flex-wrap: wrap;
+      align-items: center;
+      gap: 6px;
+      min-width: 0;
+    }
+
+    &__card-ops {
+      display: flex;
+      align-items: center;
+      gap: 6px;
+      margin-top: auto;
+      padding-top: 10px;
+      border-top: 1px solid #eff2f7;
+    }
+
+    &__ops-sp {
+      flex: 1 1 auto;
+    }
+
+    &__op {
+      display: inline-flex;
+      align-items: center;
+      justify-content: center;
+      height: 26px;
+      padding: 0 10px;
+      color: #5b6577;
+      font-size: 11.5px;
+      font-weight: 500;
+      font-family: inherit;
+      white-space: nowrap;
+      background: #fff;
+      border: 1px solid #e7ebf3;
+      border-radius: 6px;
+      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-danger:hover {
+        color: #e5484d;
+        background: #fff5f5;
+        border-color: rgb(229 72 77 / 35%);
+      }
+    }
+
+    &__link {
+      display: inline-flex;
+      align-items: center;
+      gap: 2px;
+      color: #2a50ec;
+      font-size: 11.5px;
+      font-weight: 500;
+    }
+  }
+
+  /* 弹框内搜索框(工具包编辑器)沿用 32px 白底控件 */
+  .ai-plugin__pack-section-head .ai-plugin__pack-search {
+    display: flex;
+    flex: 0 0 auto;
+    align-items: center;
+    gap: 8px;
+    width: 240px;
+    height: 32px;
+    padding: 0 10px;
+    color: #98a2b3;
+    background: #fff;
+    border: 1px solid #e7ebf3;
+    border-radius: 8px;
+    cursor: text;
+
+    input {
+      flex: 1;
+      min-width: 0;
+      color: #111827;
+      font-size: 12px;
+      font-family: inherit;
+      background: transparent;
+      border: 0;
+      outline: none;
+
+      &::placeholder {
+        color: #98a2b3;
+      }
+    }
+  }
+
+  .ai-plugin__pack-input {
+    border-color: #e7ebf3;
+
+    &:focus {
+      border-color: #2a50ec;
+      box-shadow: 0 0 0 2px rgb(42 80 236 / 12%);
+    }
+  }
+
+  .ai-plugin__pack-count {
+    color: #5b6577;
+    background: #f4f6fa;
+  }
+
+  .ai-plugin__pack-card {
+    border-color: #e7ebf3;
+    border-radius: 10px;
+  }
+
+  .ai-plugin__pack-card.is-on {
+    border-color: #2a50ec;
+    background: rgb(42 80 236 / 3%);
+  }
+
+  .ai-plugin__pack-empty {
+    padding: 18px;
+    color: #98a2b3;
+    font-size: 12px;
+    text-align: center;
+    background: #fafbfe;
+    border: 1px dashed #dbe2ef;
+    border-radius: 10px;
+  }
+
+  @media (max-width: 1440px) {
+    .ai-plugin__summary {
+      display: none;
+    }
+  }
+
+  @media (max-width: 1180px) {
+    .ai-plugin__panel-desc {
+      display: none;
+    }
+  }
+
+  /* 窄窗口先收起页签图标,给右侧操作按钮让位(页签文字与计数仍完整) */
+  @media (max-width: 1120px) {
+    .ai-plugin__tab .jeesite-icon {
+      display: none !important;
+    }
+  }
 </style>
 
 <style lang="less">
+  /*
+   * 窗口态观感与页面卡片统一:14px 圆角、白底标题条、30px 操作按钮。
+   * 项目全局对 .ant-modal 有过高优先级覆盖(:not(...) 链),这里必须用 !important。
+   */
+  .ai-pack-modal .ant-modal-content {
+    overflow: hidden;
+    border-radius: 14px !important;
+    padding: 0 !important;
+  }
+
+  .ai-pack-modal .ant-modal-header {
+    margin: 0 !important;
+    padding: 14px 20px !important;
+    background: #fff !important;
+    border-bottom: 1px solid #eff2f7 !important;
+    border-radius: 14px 14px 0 0 !important;
+  }
+
+  .ai-pack-modal .ant-modal-title {
+    color: #111827 !important;
+    font-size: 15px !important;
+    font-weight: 600 !important;
+  }
+
+  .ai-pack-modal .ant-modal-close {
+    top: 14px;
+    right: 16px;
+  }
+
+  .ai-pack-modal .ant-modal-body {
+    padding: 16px 20px 8px !important;
+  }
+
   /*
    * 全屏态补齐(弹框挂到 body 下,只能用全局样式):antd 弹框是 position:relative,
    * fullscreen 规则的 top/bottom 拉不开高度——这里显式补高度,正文区 flex 占满并内部滚动;

+ 3 - 0
ai-electron/frontend/uno.config.ts

@@ -110,6 +110,9 @@ export default defineConfig({
     'i-mdi:swap-horizontal',
     // AI 插件页(Skill / MCP / Codex 运行时);puzzle-outline 同时是 menu.json 里的菜单图标
     'i-mdi:puzzle-outline',
+    // 插件与技能卡片化改版:技能 / 工具包 / 工具卡片的类型图标
+    'i-mdi:script-text-outline',
+    'i-mdi:package-variant-closed',
     'i-mdi:server-network',
     'i-mdi:cog-outline',
     'i-mdi:power',