xiaohaizhao 2 hari lalu
induk
melakukan
4abaaf1408

+ 37 - 1
.claude/settings.local.json

@@ -3,7 +3,43 @@
     "allow": [
       "Bash(npm install *)",
       "Bash(timeout 30 npm run serve)",
-      "mcp__ide__getDiagnostics"
+      "mcp__ide__getDiagnostics",
+      "Bash(pandoc \"D:\\\\Desktop\\\\AI管理-知识库管理.docx\" -o /tmp/doc-output.md)",
+      "Read(//tmp/**)",
+      "Bash(python3)",
+      "Bash(npx vue-cli-service *)",
+      "Read(//home/zxh/.vscode-server/data/Machine/**)",
+      "Read(//home/zxh/.config/Code/User/**)",
+      "Bash(ls ~/.vscode*/extensions/ 2>/dev/null | grep -i chinese || echo \"未找到中文语言包\")",
+      "Read(//home/zxh/**)",
+      "Bash(code --install-extension MS-CEINTL.vscode-language-pack-zh-hans)",
+      "Bash(dpkg -l)",
+      "Read(//opt/**)",
+      "Read(//usr/share/**)",
+      "Bash(find / -maxdepth 4 -name \"gitkraken\" -type f)",
+      "Bash(/home/zxh/下载/GitKrakenPatcher_Linux_x64 --help)",
+      "Read(//snap/gitkraken/307/**)",
+      "Bash(mount)",
+      "Bash(/snap/bin/gitkraken --version)",
+      "Bash(snap info *)",
+      "Bash(cat /snap/gitkraken/current/meta/snap.yaml 2>/dev/null; ls /snap/gitkraken/current/usr/share/gitkraken/ 2>/dev/null || ls /snap/gitkraken/current/usr/lib/gitkraken/ 2>/dev/null || find /snap/gitkraken/current -maxdepth 4 -name \"app.asar\" 2>/dev/null)",
+      "Read(//snap/gitkraken/current/usr/lib/**)",
+      "Bash(chmod +x /home/zxh/下载/GitKrakenPatcher_Linux_x64)",
+      "Bash(npx asar *)",
+      "Bash(mkdir -p ~/.local/share/gitkraken-patched)",
+      "Bash(cp -a /snap/gitkraken/current/usr/share/gitkraken/* ~/.local/share/gitkraken-patched/)",
+      "Bash(cp /home/zxh/下载/strings_12.1.1.json ~/.local/share/gitkraken-patched/app_extracted/src/strings.json && echo \"汉化文件已替换\")",
+      "Bash(rm resources/app.asar)",
+      "Bash(rm -rf app_extracted)",
+      "Bash(~/.local/share/gitkraken-patched/gitkraken --no-sandbox)",
+      "Bash(sudo mount --bind ~/.local/share/gitkraken-patched/resources/app.asar /snap/gitkraken/current/usr/share/gitkraken/resources/app.asar)",
+      "Bash(gtk-launch gitkraken_gitkraken)",
+      "Read(//etc/**)",
+      "Bash(sudo tee -a /etc/fstab)",
+      "Bash(sudo mount -a --target /snap/gitkraken/current/usr/share/gitkraken/resources/app.asar)"
+    ],
+    "additionalDirectories": [
+      "/home/zxh/下载"
     ]
   }
 }

+ 859 - 0
src/AIManagement/KBM/API.md

@@ -0,0 +1,859 @@
+# 知识库管理 - 后端接口对接文档
+
+## 概述
+
+本文档梳理知识库管理模块前端所需的所有后端接口。接口统一遵循现有项目的调用规范:
+
+```
+请求方式: this.$api.requested({ classname, method, content })
+请求路径: POST /yos/rest/index
+Content-Type: application/json;charset=UTF-8
+```
+
+---
+
+## 接口清单
+
+### 一、知识库管理(5 个接口)
+
+#### 1.1 获取知识库列表
+
+> 页面加载时调用,返回全部知识库
+
+| 字段 | 值 |
+|------|-----|
+| classname | `knowledgeBase` |
+| method | `getList` |
+
+**请求参数 (content):**
+```json
+{
+  "pageNumber": 1,
+  "pageSize": 999,
+  "where": {
+    "condition": "",
+    "tablefilter": {}
+  }
+}
+```
+
+**响应数据:**
+```json
+{
+  "code": 0,
+  "data": {
+    "list": [
+      {
+        "id": 1,
+        "name": "产品知识库",
+        "description": "产品相关文档、功能说明",
+        "tags": ["产品手册", "版本发布"],
+        "documentCount": 12,
+        "createdAt": "2025-01-15 10:30:00",
+        "updatedAt": "2025-06-20 14:22:00"
+      }
+    ],
+    "total": 3
+  }
+}
+```
+
+---
+
+#### 1.2 创建空知识库
+
+> 点击"新建知识库"后调用
+
+| 字段 | 值 |
+|------|-----|
+| classname | `knowledgeBase` |
+| method | `create` |
+
+**请求参数 (content):**
+```json
+{
+  "name": "产品知识库",
+  "description": "产品相关文档和资料"
+}
+```
+
+**响应数据:**
+```json
+{
+  "code": 0,
+  "data": {
+    "id": 4,
+    "name": "产品知识库",
+    "description": "产品相关文档和资料",
+    "tags": [],
+    "createdAt": "2025-06-20 14:22:00",
+    "updatedAt": "2025-06-20 14:22:00"
+  },
+  "message": "创建成功"
+}
+```
+
+---
+
+#### 1.3 更新知识库
+
+> 右键"重命名"时调用
+
+| 字段 | 值 |
+|------|-----|
+| classname | `knowledgeBase` |
+| method | `update` |
+
+**请求参数 (content):**
+```json
+{
+  "id": 1,
+  "name": "产品知识库(更新)",
+  "description": "更新后的描述"
+}
+```
+
+**响应数据:**
+```json
+{
+  "code": 0,
+  "message": "更新成功"
+}
+```
+
+---
+
+#### 1.4 删除指定知识库
+
+> 右键"删除",二次确认后调用。级联删除其下全部文档。
+
+| 字段 | 值 |
+|------|-----|
+| classname | `knowledgeBase` |
+| method | `delete` |
+
+**请求参数 (content):**
+```json
+{
+  "id": 1
+}
+```
+
+**响应数据:**
+```json
+{
+  "code": 0,
+  "message": "删除成功"
+}
+```
+
+---
+
+#### 1.5 设置知识库标签
+
+> 右键"设置标签",在弹窗中增删标签后调用
+
+| 字段 | 值 |
+|------|-----|
+| classname | `knowledgeBase` |
+| method | `setTags` |
+
+**请求参数 (content):**
+```json
+{
+  "id": 1,
+  "tags": ["产品手册", "FAQ", "新标签"]
+}
+```
+
+**响应数据:**
+```json
+{
+  "code": 0,
+  "message": "标签设置成功"
+}
+```
+
+---
+
+### 二、知识库详情(1 个接口)
+
+#### 2.1 获取指定知识库的详细信息
+
+> 选中知识库后,在右侧面板顶部展示
+
+| 字段 | 值 |
+|------|-----|
+| classname | `knowledgeBase` |
+| method | `getDetail` |
+
+**请求参数 (content):**
+```json
+{
+  "id": 1
+}
+```
+
+**响应数据:**
+```json
+{
+  "code": 0,
+  "data": {
+    "id": 1,
+    "name": "产品知识库",
+    "description": "产品相关文档和资料",
+    "tags": ["产品手册"],
+    "documentCount": 12,
+    "createdAt": "2025-01-15 10:30:00",
+    "updatedAt": "2025-06-20 14:22:00"
+  }
+}
+```
+
+**说明:** 若页面进入时已通过列表接口获取了基本信息,此接口为可选(用于刷新最新状态)。
+
+---
+
+### 三、文档管理(9 个接口)
+
+#### 3.1 获取知识库文档列表
+
+> 选中知识库后,在"文档管理"Tab 分页展示。支持按名称搜索和嵌入状态筛选。
+
+| 字段 | 值 |
+|------|-----|
+| classname | `knowledgeBase.document` |
+| method | `getList` |
+
+**请求参数 (content):**
+```json
+{
+  "pageNumber": 1,
+  "pageSize": 20,
+  "where": {
+    "condition": "",
+    "tablefilter": {
+      "knowledgeBaseId": 1,
+      "embedStatus": "",
+      "status": ""
+    }
+  }
+}
+```
+
+**响应数据:**
+```json
+{
+  "code": 0,
+  "data": {
+    "list": [
+      {
+        "id": 1,
+        "knowledgeBaseId": 1,
+        "name": "产品功能清单_v3.2.xlsx",
+        "size": "1.2 MB",
+        "sizeBytes": 1258291,
+        "type": "xlsx",
+        "version": 5,
+        "status": "enabled",
+        "embedStatus": "completed",
+        "embedProgress": 100,
+        "uploadTime": "2025-06-20 14:22:00",
+        "metadata": {
+          "productLine": "CRM系统",
+          "applicableRole": ["市场", "销售"],
+          "validUntil": "2026-06-20"
+        }
+      }
+    ],
+    "total": 12
+  }
+}
+```
+
+**字段说明:**
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| status | string | `enabled` 启用 / `disabled` 禁用 |
+| embedStatus | string | `pending` 待处理 / `processing` 处理中 / `completed` 已完成 / `failed` 失败 |
+| embedProgress | number | 0-100,向量化进度百分比 |
+| metadata | object | 动态键值对,按元数据配置字段存入 |
+
+---
+
+#### 3.2 上传文档
+
+> 上传弹窗提交后调用。需同时支持文件流上传和元数据提交。
+
+| 字段 | 值 |
+|------|-----|
+| classname | `knowledgeBase.document` |
+| method | `upload` |
+
+**请求参数 (content):**
+```json
+{
+  "knowledgeBaseId": 1,
+  "metadata": {
+    "productLine": "CRM系统",
+    "applicableRole": ["市场", "销售"],
+    "validUntil": "2026-06-20"
+  }
+}
+```
+
+**说明:** 文件和 content 通过 FormData 一起提交。前端使用 `el-upload` 组件,后端需处理 `multipart/form-data` 格式。若走 JSON 接口,文件需先转为 Base64 或走独立上传通道(如 Huawei OBS)获取 fileKey 后再调用此接口。
+
+**建议方案(与现有 OBS 模式一致):**
+1. 前端先调 OBS 获取上传 URL → 直传文件 → 获取 fileKey
+2. 再调此接口提交 fileKey + 元数据
+
+**响应数据:**
+```json
+{
+  "code": 0,
+  "data": {
+    "id": 9,
+    "name": "产品功能清单_v3.2.xlsx",
+    "embedStatus": "pending",
+    "embedProgress": 0
+  },
+  "message": "上传成功,正在进行向量化处理"
+}
+```
+
+---
+
+#### 3.3 获取文档嵌入状态(进度)
+
+> 上传文档后,前端轮询此接口显示向量化进度条。轮询间隔约 800ms,完成后停止。
+
+| 字段 | 值 |
+|------|-----|
+| classname | `knowledgeBase.document` |
+| method | `getEmbedStatus` |
+
+**请求参数 (content):**
+```json
+{
+  "id": 9
+}
+```
+
+**响应数据:**
+```json
+{
+  "code": 0,
+  "data": {
+    "id": 9,
+    "embedStatus": "processing",
+    "embedProgress": 65
+  }
+}
+```
+
+**说明:** 前端在 `embedStatus === 'completed'` 或 `'failed'` 时停止轮询。
+
+---
+
+#### 3.4 更新文档(替换文件)
+
+> 在详情面板点击"更新文档",替换已有文件内容,版本号递增。
+
+| 字段 | 值 |
+|------|-----|
+| classname | `knowledgeBase.document` |
+| method | `updateFile` |
+
+**请求参数 (content):**
+```json
+{
+  "id": 1,
+  "fileKey": "obs://bucket/path/new_file_v2.pdf"
+}
+```
+
+**响应数据:**
+```json
+{
+  "code": 0,
+  "data": {
+    "version": 6,
+    "embedStatus": "pending",
+    "embedProgress": 0
+  },
+  "message": "文件已更新,正在重新向量化"
+}
+```
+
+---
+
+#### 3.5 更新文档状态(启用/禁用)
+
+> 表格行中的开关切换,或详情面板点击"启用/禁用"。
+
+| 字段 | 值 |
+|------|-----|
+| classname | `knowledgeBase.document` |
+| method | `updateStatus` |
+
+**请求参数 (content):**
+```json
+{
+  "id": 1,
+  "status": "disabled"
+}
+```
+
+**响应数据:**
+```json
+{
+  "code": 0,
+  "message": "文档已禁用"
+}
+```
+
+---
+
+#### 3.6 设置文档元数据
+
+> 在文档详情面板编辑元数据后保存。
+
+| 字段 | 值 |
+|------|-----|
+| classname | `knowledgeBase.document` |
+| method | `setMetadata` |
+
+**请求参数 (content):**
+```json
+{
+  "id": 1,
+  "metadata": {
+    "productLine": "ERP系统",
+    "applicableRole": ["售后"],
+    "validUntil": "2026-12-31",
+    "docType": "技术文档"
+  }
+}
+```
+
+**响应数据:**
+```json
+{
+  "code": 0,
+  "message": "元数据保存成功"
+}
+```
+
+---
+
+#### 3.7 获取指定文档的详细信息
+
+> 点击文档名或"详情"按钮时调用。
+
+| 字段 | 值 |
+|------|-----|
+| classname | `knowledgeBase.document` |
+| method | `getDetail` |
+
+**请求参数 (content):**
+```json
+{
+  "id": 1
+}
+```
+
+**响应数据:**
+```json
+{
+  "code": 0,
+  "data": {
+    "id": 1,
+    "knowledgeBaseId": 1,
+    "name": "产品功能清单_v3.2.xlsx",
+    "size": "1.2 MB",
+    "sizeBytes": 1258291,
+    "type": "xlsx",
+    "version": 5,
+    "status": "enabled",
+    "embedStatus": "completed",
+    "embedProgress": 100,
+    "uploadTime": "2025-06-20 14:22:00",
+    "metadata": {
+      "productLine": "CRM系统",
+      "applicableRole": ["市场", "销售"],
+      "validUntil": "2026-06-20"
+    },
+    "previewUrl": "https://obs.example.com/preview/doc_1.pdf",
+    "downloadUrl": "https://obs.example.com/download/doc_1.pdf",
+    "versionHistory": [
+      { "version": 5, "time": "2025-06-20 14:22:00", "changeDesc": "替换文件" },
+      { "version": 4, "time": "2025-05-10 09:00:00", "changeDesc": "更新元数据" }
+    ]
+  }
+}
+```
+
+**说明:** `previewUrl` 和 `downloadUrl` 若由 OBS 直签 URL 生成,可在获取详情时动态生成并返回。`versionHistory` 用于记录变更轨迹。
+
+---
+
+#### 3.8 删除指定文档
+
+> 表格或详情面板中点击删除,`el-popconfirm` 二次确认后调用。
+
+| 字段 | 值 |
+|------|-----|
+| classname | `knowledgeBase.document` |
+| method | `delete` |
+
+**请求参数 (content):**
+```json
+{
+  "id": 1
+}
+```
+
+**响应数据:**
+```json
+{
+  "code": 0,
+  "message": "文档已删除"
+}
+```
+
+---
+
+#### 3.9 下载 / 批量下载指定文档
+
+> 表格行"下载"按钮,或勾选多条后"批量下载"。
+
+| 下载单个 | 批量下载 |
+|----------|----------|
+| classname: `knowledgeBase.document` | classname: `knowledgeBase.document` |
+| method: `download` | method: `batchDownload` |
+
+**下载单个请求:**
+```json
+{ "id": 1 }
+```
+
+**批量下载请求:**
+```json
+{ "ids": [1, 2, 3] }
+```
+
+**响应数据:**
+```json
+{
+  "code": 0,
+  "data": {
+    "downloadUrl": "https://obs.example.com/pack/download_abc123.zip"
+  }
+}
+```
+
+**说明:** 单个下载返回文件直链 URL(前端 `window.open` 或创建 `<a>` 下载),批量下载建议后端打包为 ZIP 后返回下载链接。
+
+---
+
+### 四、元数据管理(4 个接口)
+
+#### 4.1 获取元数据列表
+
+> "元数据配置"Tab 进入时调用,返回所有预定义字段。
+
+| 字段 | 值 |
+|------|-----|
+| classname | `knowledgeBase.metadata` |
+| method | `getList` |
+
+**请求参数 (content):**
+```json
+{
+  "pageNumber": 1,
+  "pageSize": 999
+}
+```
+
+**响应数据:**
+```json
+{
+  "code": 0,
+  "data": {
+    "list": [
+      {
+        "id": 1,
+        "name": "产品线",
+        "key": "productLine",
+        "type": "select",
+        "options": ["CRM系统", "ERP系统", "OA系统", "WMS系统"],
+        "isSystem": true,
+        "enabled": true
+      },
+      {
+        "id": 2,
+        "name": "适用角色",
+        "key": "applicableRole",
+        "type": "multiSelect",
+        "options": ["市场", "销售", "售后"],
+        "isSystem": true,
+        "enabled": true
+      }
+    ],
+    "total": 4
+  }
+}
+```
+
+**字段说明:**
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| type | string | `text` 文本 / `select` 下拉单选 / `multiSelect` 下拉多选 / `date` 日期 |
+| isSystem | boolean | `true` 为系统内置字段,不可删除 |
+| enabled | boolean | 是否启用(禁用后上传弹窗不展示此字段) |
+| options | array | type为 select/multiSelect 时的可选值列表 |
+
+---
+
+#### 4.2 创建元数据
+
+> "新建字段"弹窗提交后调用。
+
+| 字段 | 值 |
+|------|-----|
+| classname | `knowledgeBase.metadata` |
+| method | `create` |
+
+**请求参数 (content):**
+```json
+{
+  "name": "文档分类",
+  "key": "docType",
+  "type": "select",
+  "options": ["手册", "FAQ", "白皮书", "技术文档"]
+}
+```
+
+**验证规则:**
+- `name` 必填,最长 20 字符
+- `key` 必填,格式 `/^[a-zA-Z][a-zA-Z0-9_]*$/`,全局唯一
+- `type` 必填,枚举 `text/select/multiSelect/date`
+- `type` 为 `select/multiSelect` 时 `options` 至少有一项
+
+**响应数据:**
+```json
+{
+  "code": 0,
+  "data": {
+    "id": 5,
+    "name": "文档分类",
+    "key": "docType",
+    "type": "select",
+    "options": ["手册", "FAQ", "白皮书", "技术文档"],
+    "isSystem": false,
+    "enabled": true
+  },
+  "message": "创建成功"
+}
+```
+
+---
+
+#### 4.3 更新元数据
+
+> 编辑弹窗提交后调用。key 不可改(前端已禁用),仅允许改 name / options。
+
+| 字段 | 值 |
+|------|-----|
+| classname | `knowledgeBase.metadata` |
+| method | `update` |
+
+**请求参数 (content):**
+```json
+{
+  "id": 4,
+  "name": "文档分类(已更新)",
+  "type": "select",
+  "options": ["手册", "FAQ", "白皮书", "技术文档", "操作指南"]
+}
+```
+
+**响应数据:**
+```json
+{
+  "code": 0,
+  "message": "更新成功"
+}
+```
+
+---
+
+#### 4.4 删除 / 启用禁用元数据
+
+> 两个独立操作,共用一个 classname。
+
+| 操作 | method | content |
+|------|--------|---------|
+| 删除自定义字段 | `delete` | `{ "id": 4 }` |
+| 启用/禁用 | `toggleStatus` | `{ "id": 4, "enabled": false }` |
+
+| 字段 | 值 |
+|------|-----|
+| classname | `knowledgeBase.metadata` |
+
+**约束:** 系统字段 (`isSystem: true`) 不允许删除,`toggleStatus` 需支持。
+
+**响应数据:**
+```json
+{
+  "code": 0,
+  "message": "操作成功"
+}
+```
+
+---
+
+### 五、检索测试(1 个接口)
+
+#### 5.1 检索测试
+
+> 输入问题后点击"检索"调用。不经过 LLM,直接返回向量检索命中的文档片段。
+
+| 字段 | 值 |
+|------|-----|
+| classname | `knowledgeBase` |
+| method | `search` |
+
+**请求参数 (content):**
+```json
+{
+  "knowledgeBaseId": 1,
+  "query": "如何配置用户权限",
+  "topK": 10
+}
+```
+
+**响应数据:**
+```json
+{
+  "code": 0,
+  "data": {
+    "results": [
+      {
+        "documentId": 1,
+        "documentName": "产品功能清单_v3.2.xlsx",
+        "content": "用户权限配置:管理员可在系统设置 > 权限管理中进行角色分配,支持按模块、按数据范围设置不同级别的访问权限。",
+        "score": 0.9421
+      },
+      {
+        "documentId": 3,
+        "documentName": "API接口说明_v1.5.docx",
+        "content": "权限相关接口:POST /api/auth/assignRole 用于为用户分配角色,请求参数包括 userId 和 roleId。",
+        "score": 0.8734
+      }
+    ]
+  }
+}
+```
+
+**字段说明:**
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| documentId | number | 来源文档 ID |
+| documentName | string | 来源文档名称 |
+| content | string | 命中的文本片段 |
+| score | number | 相似度得分(0-1),前端转为百分比展示 |
+
+---
+
+## 调用汇总表
+
+| # | 接口 | classname | method | 调用时机 |
+|---|------|-----------|--------|----------|
+| 1 | 获取知识库列表 | `knowledgeBase` | `getList` | 页面加载 |
+| 2 | 创建空知识库 | `knowledgeBase` | `create` | 新建弹窗确认 |
+| 3 | 更新知识库 | `knowledgeBase` | `update` | 重命名弹窗确认 |
+| 4 | 删除知识库 | `knowledgeBase` | `delete` | 右键删除+确认 |
+| 5 | 设置知识库标签 | `knowledgeBase` | `setTags` | 标签弹窗确认 |
+| 6 | 获取知识库详情 | `knowledgeBase` | `getDetail` | 选中知识库(可选刷新) |
+| 7 | 获取文档列表 | `knowledgeBase.document` | `getList` | 选中知识库 / 筛选 / 翻页 |
+| 8 | 上传文档 | `knowledgeBase.document` | `upload` | 上传弹窗确认 |
+| 9 | 获取嵌入状态 | `knowledgeBase.document` | `getEmbedStatus` | 上传后轮询 |
+| 10 | 更新文档文件 | `knowledgeBase.document` | `updateFile` | 替换文件确认 |
+| 11 | 更新文档状态 | `knowledgeBase.document` | `updateStatus` | 开关切换 |
+| 12 | 设置文档元数据 | `knowledgeBase.document` | `setMetadata` | 详情面板保存 |
+| 13 | 获取文档详情 | `knowledgeBase.document` | `getDetail` | 点击详情 |
+| 14 | 删除文档 | `knowledgeBase.document` | `delete` | 删除+确认 |
+| 15 | 下载文档 | `knowledgeBase.document` | `download` | 点击下载 |
+| 16 | 批量下载 | `knowledgeBase.document` | `batchDownload` | 勾选+批量下载 |
+| 17 | 获取元数据列表 | `knowledgeBase.metadata` | `getList` | 元数据配置Tab加载 |
+| 18 | 创建元数据 | `knowledgeBase.metadata` | `create` | 新建字段确认 |
+| 19 | 更新元数据 | `knowledgeBase.metadata` | `update` | 编辑字段确认 |
+| 20 | 删除元数据 | `knowledgeBase.metadata` | `delete` | 删除+确认 |
+| 21 | 启禁元数据 | `knowledgeBase.metadata` | `toggleStatus` | 开关切换 |
+| 22 | 检索测试 | `knowledgeBase` | `search` | 点击检索 |
+
+---
+
+## 通用约定
+
+1. **分页参数**:统一使用 `{ pageNumber, pageSize, where: { condition, tablefilter } }`
+2. **响应格式**:`{ code: 0, data: {...}, message: "xxx" }` — code=0 表示成功
+3. **错误处理**:前端通过 `this.tool.showMessage(res, callback)` 统一处理,code≠0 时自动弹出 message 中的错误提示
+4. **文件上传**:建议沿用华为云 OBS 模式(先取签名URL → 直传 → 回传 fileKey),保证与项目内其他上传模块一致
+5. **向量化**:文档上传/更新后后端异步触发向量化,前端轮询 `getEmbedStatus` 直到 completed/failed
+
+---
+
+## 后端已提供接口(对接状态)
+
+| # | 后端接口ID | 方法名 | 对应功能 | 对接状态 |
+|---|-----------|--------|----------|----------|
+| 1 | `2026052714301001` | queryDatasetList | 获取知识库列表 | 已对接 `fetchKBList()` |
+| 2 | `2026052714301002` | queryDatasetInfo | 获取知识库详情 | 已对接(onSelectKB 内) |
+| 3 | `2026052714301007` | queryDatasetTags | 获取知识库标签 | 已对接 `fetchKBTags()` |
+| 4 | `2026052714301017` | queryDocumentlist | 获取文档列表 | 已对接 `fetchDocumentList()` |
+| 5 | `2026052714301008` | queryDocumentInfo | 获取文档详情 | 已对接 `fetchDocumentDetail()` |
+| 6 | `2026052714301009` | deleteDocument | 删除文档 | 已对接 `deleteDocumentApi()` |
+| 7 | `2026052714301010` | downloadDocument | 下载文档 | 已对接 `downloadDocumentApi()` |
+| 8 | `2026052714301011` | downloadDocumentsBatch | 批量下载 | 已对接 `batchDownloadApi()` |
+| 9 | `2026052714301012` | queryDocumentIndexingStatus | 索引状态查询 | 已对接 `fetchIndexingStatus()` |
+| 10 | `2026052714301013` | createOrUpdateDocumentByText | 新建/更新文本文档 | 待接入 |
+| 11 | `2026052714301016` | updateDocumentsStatusBatch | 批量更新文档状态 | 已对接 `batchUpdateDocStatus()` |
+| 12 | `2026052715313901` | createDocumentByFile | 文件上传创建文档 | 部分对接(需 OBS attachmentid) |
+
+---
+
+## 缺口记录
+
+### 一、缺少的接口(后端未提供)
+
+| 缺口编号 | 功能 | 说明 |
+|----------|------|------|
+| GAP-01 | 创建空知识库 | 前端"新建知识库"功能无对应接口,当前使用 Mock |
+| GAP-02 | 更新知识库 | 右键"重命名"、修改描述无对应接口,当前使用 Mock |
+| GAP-03 | 删除知识库 | 右键"删除知识库"无对应接口,当前使用 Mock |
+| GAP-04 | 设置知识库标签 | 后端仅提供 `queryDatasetTags` 查询标签,无绑定/解绑标签接口。前端"设置标签"功能当前使用 Mock |
+| GAP-05 | 检索测试 | 知识库检索测试(输入问题返回匹配片段)无对应接口,当前使用 Mock 数据 |
+| GAP-06 | 元数据管理(整套) | 元数据字段的 CRUD、启用/禁用,后端完全未提供。前端"元数据配置"Tab 全部使用 Mock |
+| GAP-07 | 设置文档元数据 | 文档详情面板中编辑保存元数据键值对,无对应接口 |
+
+### 二、接口差异 / 字段缺失
+
+| 差异编号 | 接口 | 问题 |
+|----------|------|------|
+| DIFF-01 | 文档列表 `2026052714301017` | 响应字段名待确认:文档名 (`name`/`document_name`/`file_name`?)、文件大小 (`size`/`file_size`?)、文件类型 (`type`/`file_type`?)。前端已做多字段兼容映射,具体以 console.log 实际返回为准 |
+| DIFF-02 | 文档详情 `2026052714301008` | 同上,响应字段名待 console.log 确认 |
+| DIFF-03 | 知识库列表 `2026052714301001` | 响应字段名待确认:`id`/`dataset_id`?、名称 `name`/`dataset_name`?、描述 `description`/`desc`?。前端已做多字段兼容映射 |
+| DIFF-04 | 文档索引状态 `2026052714301012` | 参数用 `batch` 而非 `document_id`,前端轮询单个文档时需要传什么 batch 值?待确认 |
+| DIFF-05 | 上传文档 `2026052715313901` | 需要 `attachmentid` 参数(OBS 文件上传后的 ID),无法直接通过文件流上传。前端需先调 OBS 获取 attachmentid 再调此接口 |
+| DIFF-06 | 文档状态更新 | 后端仅提供 `updateDocumentsStatusBatch` 批量接口,无单个文档状态切换接口。前端单个开关已对接批量接口(传单元素数组) |
+| DIFF-07 | 知识库列表 `2026052714301001` | 请求参数仅 `keyword`,无分页参数。大量知识库时的分页如何支持? |
+
+### 三、调用时日志说明
+
+所有 API 调用的请求参数和响应结果均已通过 `console.log('[API] id=xxxxx', ...)` 打印到浏览器控制台。打开 F12 → Console 即可查看实际数据结构。Mock 操作和缺口接口标注了 `console.warn('[GAP] ...')`。

+ 458 - 0
src/AIManagement/KBM/DocumentDetail.vue

@@ -0,0 +1,458 @@
+<template>
+  <el-drawer
+    :visible.sync="drawerVisible"
+    title="文档详情"
+    size="560px"
+    direction="rtl"
+    append-to-body
+  >
+    <div class="dd-body">
+      <template v-if="document">
+        <!-- 文件图标 + 名称 -->
+      <div class="dd-file-head">
+        <div class="dd-file-icon" :class="fileTypeClass">
+          <i :class="fileIcon"></i>
+        </div>
+        <div class="dd-file-info">
+          <p class="dd-file-name" :title="document.name">{{ document.name }}</p>
+          <p class="dd-file-meta">
+            <span>{{ document.size }}</span>
+            <span class="dd-meta-divider">|</span>
+            <span>v{{ document.version }}</span>
+            <span class="dd-meta-divider">|</span>
+            <span>{{ document.type ? document.type.toUpperCase() : '-' }}</span>
+          </p>
+        </div>
+      </div>
+
+      <!-- 基本信息 -->
+      <div class="dd-section">
+        <h4 class="dd-section-title">基本信息</h4>
+        <div class="dd-info-list">
+          <div class="dd-info-row">
+            <span class="dd-info-label">上传时间</span>
+            <span class="dd-info-value">{{ document.uploadTime }}</span>
+          </div>
+          <div class="dd-info-row">
+            <span class="dd-info-label">当前状态</span>
+            <span class="dd-info-value">
+              <span class="dd-status-dot" :class="document.status === 'enabled' ? 'is-on' : 'is-off'"></span>
+              {{ document.status === 'enabled' ? '已启用' : '已禁用' }}
+            </span>
+          </div>
+        </div>
+      </div>
+
+      <!-- 嵌入状态 -->
+      <div class="dd-section">
+        <h4 class="dd-section-title">向量化嵌入</h4>
+        <div class="dd-embed-card" :class="'dd-embed--' + document.embedStatus">
+          <div class="dd-embed-icon">
+            <i v-if="document.embedStatus === 'completed'" class="el-icon-circle-check"></i>
+            <i v-else-if="document.embedStatus === 'processing'" class="el-icon-loading"></i>
+            <i v-else-if="document.embedStatus === 'failed'" class="el-icon-circle-close"></i>
+            <i v-else class="el-icon-time"></i>
+          </div>
+          <div class="dd-embed-body">
+            <p class="dd-embed-text">
+              {{ document.embedStatus === 'completed' ? '嵌入完成' : document.embedStatus === 'processing' ? '正在向量化处理中...' : document.embedStatus === 'failed' ? '嵌入失败' : '等待处理' }}
+            </p>
+            <el-progress
+              v-if="document.embedStatus === 'processing'"
+              :percentage="document.embedProgress"
+              :stroke-width="6"
+              :show-text="false"
+              style="margin-top: 6px;"
+            />
+            <el-button
+              v-if="document.embedStatus === 'failed'"
+              type="text"
+              size="mini"
+              style="padding: 0; margin-top: 4px;"
+              @click="$emit('retry-embed', document.id)"
+            >点击重试</el-button>
+          </div>
+        </div>
+      </div>
+
+      <!-- 元数据 -->
+      <div class="dd-section">
+        <h4 class="dd-section-title">
+          元数据
+          <el-button type="text" size="mini" @click="editMode = !editMode">{{ editMode ? '取消' : '编辑' }}</el-button>
+        </h4>
+
+        <div v-if="editMode" class="dd-meta-edit">
+          <div class="dd-meta-edit-item" v-for="field in metadataFields" :key="field.id">
+            <label class="dd-meta-edit-label">{{ field.name }}</label>
+            <template v-if="field.key === 'applicableRole'">
+              <el-select v-model="localMeta[field.key]" multiple size="small" style="width: 100%;">
+                <el-option v-for="opt in (field.options || [])" :key="opt" :label="opt" :value="opt" />
+              </el-select>
+            </template>
+            <template v-else-if="field.type === 'select'">
+              <el-select v-model="localMeta[field.key]" size="small" clearable style="width: 100%;">
+                <el-option v-for="opt in (field.options || [])" :key="opt" :label="opt" :value="opt" />
+              </el-select>
+            </template>
+            <template v-else-if="field.type === 'date'">
+              <el-date-picker v-model="localMeta[field.key]" type="date" size="small" value-format="yyyy-MM-dd" style="width: 100%;" />
+            </template>
+            <template v-else>
+              <el-input v-model="localMeta[field.key]" size="small" />
+            </template>
+          </div>
+          <el-button type="primary" size="small" @click="saveMeta" style="margin-top: 8px;">保存</el-button>
+        </div>
+
+        <div v-else class="dd-meta-view">
+          <div
+            v-for="(val, key) in (document.metadata || {})"
+            :key="key"
+            class="dd-meta-item"
+          >
+            <span class="dd-meta-key">{{ findFieldName(key) || key }}</span>
+            <span class="dd-meta-val">
+              <template v-if="Array.isArray(val)">
+                <el-tag v-for="v in val" :key="v" size="mini" style="margin-right: 4px;">{{ v }}</el-tag>
+                <span v-if="val.length === 0" class="dd-meta-none">未设置</span>
+              </template>
+              <template v-else>{{ val || '-' }}</template>
+            </span>
+          </div>
+          <div v-if="!document.metadata || Object.keys(document.metadata).length === 0" class="dd-meta-empty">
+            暂无元数据
+          </div>
+        </div>
+      </div>
+
+      <!-- 内容预览 -->
+      <div class="dd-section">
+        <h4 class="dd-section-title">内容预览</h4>
+        <div class="dd-preview-box">
+          <i class="el-icon-document" style="font-size: 36px; color: #dcdfe6;"></i>
+          <p>对接后端后可预览文档内容</p>
+        </div>
+      </div>
+
+      <!-- 操作 -->
+      <div class="dd-section">
+        <h4 class="dd-section-title">操作</h4>
+        <div class="dd-actions">
+          <el-button size="small" plain @click="$emit('toggle-status', document.id)">
+            {{ document.status === 'enabled' ? '禁用文档' : '启用文档' }}
+          </el-button>
+          <el-popconfirm
+            title="确定删除该文档?"
+            confirm-button-text="删除"
+            @confirm="$emit('delete', document.id)"
+          >
+            <el-button size="small" type="danger" plain slot="reference">删除文档</el-button>
+          </el-popconfirm>
+        </div>
+      </div>
+      </template>
+    </div>
+  </el-drawer>
+</template>
+
+<script>
+export default {
+  name: 'DocumentDetail',
+  props: {
+    visible: { type: Boolean, default: false },
+    document: { type: Object, default: null },
+    metadataFields: { type: Array, default: () => [] }
+  },
+  data() {
+    return {
+      editMode: false,
+      localMeta: {}
+    }
+  },
+  computed: {
+    drawerVisible: {
+      get() { return this.visible },
+      set(v) { if (!v) this.$emit('close') }
+    },
+    fileIcon() {
+      const map = {
+        pdf: 'el-icon-document',
+        docx: 'el-icon-document',
+        doc: 'el-icon-document',
+        xlsx: 'el-icon-s-data',
+        xls: 'el-icon-s-data',
+        pptx: 'el-icon-present',
+        ppt: 'el-icon-present',
+        txt: 'el-icon-tickets',
+        jpg: 'el-icon-picture-outline',
+        jpeg: 'el-icon-picture-outline',
+        png: 'el-icon-picture-outline',
+        gif: 'el-icon-picture-outline'
+      }
+      return map[this.document && this.document.type] || 'el-icon-document'
+    },
+    fileTypeClass() {
+      if (!this.document) return ''
+      const t = this.document.type
+      if (['pdf'].includes(t)) return 'is-pdf'
+      if (['docx', 'doc'].includes(t)) return 'is-word'
+      if (['xlsx', 'xls'].includes(t)) return 'is-excel'
+      if (['pptx', 'ppt'].includes(t)) return 'is-ppt'
+      return ''
+    }
+  },
+  watch: {
+    document: {
+      immediate: true,
+      handler(doc) {
+        if (doc && doc.metadata) {
+          this.localMeta = { ...doc.metadata }
+        } else {
+          this.localMeta = {}
+        }
+        this.editMode = false
+      }
+    }
+  },
+  methods: {
+    findFieldName(key) {
+      const field = this.metadataFields.find(f => f.key === key)
+      return field ? field.name : key
+    },
+    saveMeta() {
+      if (this.document) {
+        this.$emit('save-meta', { docId: this.document.id, metadata: { ...this.localMeta } })
+        this.editMode = false
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.dd-body {
+  padding: 0 8px;
+}
+
+/* ---- 文件头部 ---- */
+.dd-file-head {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  padding: 8px 0 20px;
+  border-bottom: 1px solid #ebeef5;
+  margin-bottom: 8px;
+}
+
+.dd-file-icon {
+  width: 52px;
+  height: 52px;
+  border-radius: 10px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 26px;
+  flex-shrink: 0;
+  background: #ecf5ff;
+  color: #3874F6;
+}
+.dd-file-icon.is-word { background: #e9f2ff; color: #2b5fcd; }
+.dd-file-icon.is-excel { background: #e8f8ef; color: #1d9e4c; }
+.dd-file-icon.is-ppt { background: #fef2e8; color: #e4721c; }
+.dd-file-icon.is-pdf { background: #fdecec; color: #e94141; }
+
+.dd-file-info {
+  flex: 1;
+  min-width: 0;
+}
+
+.dd-file-name {
+  font-size: 15px;
+  font-weight: 600;
+  color: #303133;
+  margin: 0 0 4px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.dd-file-meta {
+  margin: 0;
+  font-size: 12px;
+  color: #909399;
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.dd-meta-divider {
+  color: #dcdfe6;
+}
+
+/* ---- 分区 ---- */
+.dd-section {
+  padding: 14px 0;
+  border-bottom: 1px solid #f0f2f5;
+}
+
+.dd-section:last-child {
+  border-bottom: none;
+}
+
+.dd-section-title {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin: 0 0 10px;
+  font-size: 13px;
+  font-weight: 600;
+  color: #303133;
+}
+
+/* ---- 基本信息 ---- */
+.dd-info-list {
+  background: #fafbfc;
+  border-radius: 6px;
+  padding: 2px 0;
+}
+
+.dd-info-row {
+  display: flex;
+  align-items: center;
+  padding: 8px 14px;
+}
+
+.dd-info-row + .dd-info-row {
+  border-top: 1px solid #f0f2f5;
+}
+
+.dd-info-label {
+  font-size: 12px;
+  color: #909399;
+  width: 70px;
+  flex-shrink: 0;
+}
+
+.dd-info-value {
+  font-size: 13px;
+  color: #303133;
+  display: flex;
+  align-items: center;
+}
+
+.dd-status-dot {
+  width: 7px;
+  height: 7px;
+  border-radius: 50%;
+  margin-right: 6px;
+  display: inline-block;
+}
+.dd-status-dot.is-on  { background: #67C23A; box-shadow: 0 0 0 3px rgba(103, 194, 58, .15); }
+.dd-status-dot.is-off { background: #F56C6C; box-shadow: 0 0 0 3px rgba(245, 108, 108, .15); }
+
+/* ---- 嵌入状态 ---- */
+.dd-embed-card {
+  display: flex;
+  align-items: flex-start;
+  gap: 12px;
+  padding: 14px;
+  border-radius: 6px;
+}
+
+.dd-embed--completed { background: #f0f9eb; }
+.dd-embed--processing { background: #ecf5ff; }
+.dd-embed--failed     { background: #fef0f0; }
+.dd-embed--pending    { background: #fafbfc; }
+
+.dd-embed-icon {
+  font-size: 22px;
+  flex-shrink: 0;
+  margin-top: 2px;
+}
+.dd-embed--completed .dd-embed-icon { color: #67C23A; }
+.dd-embed--processing .dd-embed-icon { color: #3874F6; }
+.dd-embed--failed     .dd-embed-icon { color: #F56C6C; }
+.dd-embed--pending    .dd-embed-icon { color: #c0c4cc; }
+
+.dd-embed-body { flex: 1; }
+.dd-embed-text { margin: 0; font-size: 13px; color: #303133; }
+
+/* ---- 元数据查看 ---- */
+.dd-meta-view {
+  background: #fafbfc;
+  border-radius: 6px;
+  padding: 2px 0;
+}
+
+.dd-meta-item {
+  display: flex;
+  align-items: center;
+  padding: 8px 14px;
+}
+
+.dd-meta-item + .dd-meta-item {
+  border-top: 1px solid #f0f2f5;
+}
+
+.dd-meta-key {
+  font-size: 12px;
+  color: #909399;
+  width: 70px;
+  flex-shrink: 0;
+}
+
+.dd-meta-val {
+  font-size: 13px;
+  color: #303133;
+}
+
+.dd-meta-none {
+  color: #c0c4cc;
+}
+
+.dd-meta-empty {
+  font-size: 13px;
+  color: #c0c4cc;
+  padding: 16px;
+  text-align: center;
+}
+
+/* ---- 元数据编辑 ---- */
+.dd-meta-edit-item {
+  margin-bottom: 10px;
+}
+
+.dd-meta-edit-label {
+  display: block;
+  font-size: 12px;
+  color: #909399;
+  margin-bottom: 4px;
+}
+
+/* ---- 预览 ---- */
+.dd-preview-box {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 32px;
+  background: #fafbfc;
+  border-radius: 6px;
+  border: 1px dashed #e4e7ed;
+  text-align: center;
+  gap: 8px;
+  font-size: 13px;
+  color: #909399;
+}
+
+.dd-preview-box p {
+  margin: 0;
+}
+
+/* ---- 操作 ---- */
+.dd-actions {
+  display: flex;
+  gap: 12px;
+}
+</style>

+ 192 - 0
src/AIManagement/KBM/DocumentTable.vue

@@ -0,0 +1,192 @@
+<template>
+  <div class="dt-wrapper">
+    <!-- 工具栏 -->
+    <div class="dt-toolbar">
+      <div class="dt-toolbar-left">
+        <el-button type="primary" size="small" icon="el-icon-upload2" @click="$emit('upload')">
+          上传文档
+        </el-button>
+        <el-button
+          size="small"
+          icon="el-icon-download"
+          :disabled="selectedRows.length === 0"
+          @click="$emit('batch-download', selectedRows.map(r => r.id))"
+        >
+          批量下载
+        </el-button>
+      </div>
+      <div class="dt-toolbar-right">
+        <el-input
+          v-model="searchText"
+          placeholder="搜索文档名..."
+          size="small"
+          prefix-icon="el-icon-search"
+          clearable
+          style="width: 220px;"
+        />
+        <el-select
+          v-model="filterEmbedStatus"
+          placeholder="嵌入状态"
+          size="small"
+          clearable
+          style="width: 130px; margin-left: 8px;"
+        >
+          <el-option label="已完成" value="completed" />
+          <el-option label="处理中" value="processing" />
+          <el-option label="待处理" value="pending" />
+          <el-option label="失败" value="failed" />
+        </el-select>
+      </div>
+    </div>
+
+    <!-- 表格 -->
+    <el-table
+      :data="filteredDocuments"
+      size="small"
+      stripe
+      style="width: 100%;"
+      @selection-change="handleSelectionChange"
+      row-key="id"
+      height="calc(100vh - 340px)"
+    >
+      <el-table-column type="selection" width="40" />
+      <el-table-column label="文件名" min-width="200" show-overflow-tooltip>
+        <template v-slot="{ row }">
+          <span class="dt-file-link" @click="$emit('detail', row)">
+            <i :class="fileIcon(row.type)" style="margin-right: 6px;"></i>
+            {{ row.name }}
+          </span>
+        </template>
+      </el-table-column>
+      <el-table-column label="大小" width="90" prop="size" />
+      <el-table-column label="版本" width="60" align="center">
+        <template v-slot="{ row }">v{{ row.version }}</template>
+      </el-table-column>
+      <el-table-column label="上传时间" width="150" prop="uploadTime" />
+      <el-table-column label="嵌入状态" width="130" align="center">
+        <template v-slot="{ row }">
+          <el-progress
+            v-if="row.embedStatus === 'processing'"
+            :percentage="row.embedProgress"
+            :show-text="false"
+            :stroke-width="6"
+            style="width: 80px; display: inline-block;"
+          />
+          <el-tag v-else-if="row.embedStatus === 'completed'" type="success" size="mini">已完成</el-tag>
+          <el-tag v-else-if="row.embedStatus === 'failed'" type="danger" size="mini">失败</el-tag>
+          <el-tag v-else type="info" size="mini">待处理</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="状态" width="80" align="center">
+        <template v-slot="{ row }">
+          <el-switch
+            :value="row.status === 'enabled'"
+            active-color="#13ce66"
+            inactive-color="#ff4949"
+            @change="$emit('toggle-status', row.id)"
+          />
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" width="200" fixed="right">
+        <template v-slot="{ row }">
+          <el-button type="text" size="mini" @click="$emit('detail', row)">详情</el-button>
+          <el-button type="text" size="mini" @click="$emit('download', row)" style="margin-right: 12px;">下载</el-button>
+          <el-popconfirm
+            v-if="row.status === 'disabled'"
+            title="确定删除该文档?"
+            confirm-button-text="删除"
+            cancel-button-text="取消"
+            @confirm="$emit('delete', row.id)"
+          >
+            <el-button type="text" size="mini" slot="reference" style="color: #F56C6C;">删除</el-button>
+          </el-popconfirm>
+        </template>
+      </el-table-column>
+    </el-table>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'DocumentTable',
+  props: {
+    kb: { type: Object, default: null },
+    documents: { type: Array, default: () => [] },
+    metadataFields: { type: Array, default: () => [] }
+  },
+  data() {
+    return {
+      searchText: '',
+      filterEmbedStatus: '',
+      selectedRows: []
+    }
+  },
+  computed: {
+    filteredDocuments() {
+      let list = this.documents
+      if (this.searchText) {
+        const kw = this.searchText.toLowerCase()
+        list = list.filter(d => d.name.toLowerCase().includes(kw))
+      }
+      if (this.filterEmbedStatus) {
+        list = list.filter(d => d.embedStatus === this.filterEmbedStatus)
+      }
+      return list
+    }
+  },
+  methods: {
+    handleSelectionChange(rows) {
+      this.selectedRows = rows
+    },
+    fileIcon(type) {
+      const map = {
+        pdf: 'el-icon-document',
+        docx: 'el-icon-document',
+        doc: 'el-icon-document',
+        xlsx: 'el-icon-s-data',
+        xls: 'el-icon-s-data',
+        pptx: 'el-icon-present',
+        ppt: 'el-icon-present',
+        txt: 'el-icon-tickets',
+        jpg: 'el-icon-picture-outline',
+        jpeg: 'el-icon-picture-outline',
+        png: 'el-icon-picture-outline',
+        gif: 'el-icon-picture-outline'
+      }
+      return map[type] || 'el-icon-document'
+    }
+  }
+}
+</script>
+
+<style scoped>
+.dt-wrapper {
+  background: #fff;
+  border-radius: 4px;
+}
+
+.dt-toolbar {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 12px 0;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+.dt-toolbar-left,
+.dt-toolbar-right {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.dt-file-link {
+  color: #3874F6;
+  cursor: pointer;
+}
+
+.dt-file-link:hover {
+  text-decoration: underline;
+}
+</style>

+ 415 - 0
src/AIManagement/KBM/KnowledgeBaseList.vue

@@ -0,0 +1,415 @@
+<template>
+  <div class="kbl-wrapper">
+    <!-- 搜索区 -->
+    <div class="kbl-header">
+      <el-input
+        v-model="searchText"
+        placeholder="搜索知识库..."
+        size="small"
+        prefix-icon="el-icon-search"
+        clearable
+        @input="$emit('search', searchText)"
+      />
+      <el-button type="primary" size="small" icon="el-icon-plus" class="kbl-create-btn" @click="openCreateDialog">
+        新建知识库
+      </el-button>
+    </div>
+
+    <!-- 列表 -->
+    <div class="kbl-list">
+      <div
+        v-for="kb in list"
+        :key="kb.id"
+        class="kbl-item"
+        :class="{ 'kbl-item--active': activeId === kb.id }"
+        @click="$emit('select', kb.id)"
+        @contextmenu.prevent="openContextMenu($event, kb)"
+      >
+        <div class="kbl-item-top">
+          <span class="kbl-item-icon"><i class="el-icon-folder-opened"></i></span>
+          <span class="kbl-item-name" :title="kb.name">{{ kb.name }}</span>
+        </div>
+        <div class="kbl-item-bottom">
+          <span class="kbl-item-time">{{ kb.updatedAt || '-' }}</span>
+          <span class="kbl-item-right">
+            <span class="kbl-item-count">{{ kb.docCount || 0 }} 份文档</span>
+            <span class="kbl-item-tags">
+              <span
+                v-for="tag in (kb.tags || [])"
+                :key="tag"
+                class="kbl-tag-dot"
+                :style="{ background: getTagColor(tag) }"
+                :title="tag"
+              ></span>
+            </span>
+          </span>
+        </div>
+      </div>
+
+      <div v-if="list.length === 0" class="kbl-empty">
+        <p>暂无知识库</p>
+        <p class="kbl-empty-tip">点击上方按钮创建</p>
+      </div>
+    </div>
+
+    <!-- 右键菜单 -->
+    <div
+      v-show="contextMenu.visible"
+      class="kbl-context-menu"
+      :style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
+    >
+      <div class="kbl-menu-item" @click="openRenameDialog"><i class="el-icon-edit"></i> 重命名</div>
+      <div class="kbl-menu-item" @click="openTagDialog"><i class="el-icon-price-tag"></i> 设置标签</div>
+      <div class="kbl-menu-item kbl-menu-item--danger" @click="confirmDelete"><i class="el-icon-delete"></i> 删除</div>
+    </div>
+
+    <!-- 新建/重命名弹窗 -->
+    <el-dialog
+      :title="dialogTitle"
+      :visible.sync="dialogVisible"
+      width="460px"
+      :close-on-click-modal="false"
+      append-to-body
+    >
+      <el-form ref="kbForm" :model="formData" :rules="formRules" label-width="60px" size="small">
+        <el-form-item label="名称" prop="name">
+          <el-input v-model="formData.name" placeholder="请输入知识库名称" maxlength="30" />
+        </el-form-item>
+        <el-form-item label="描述" prop="description">
+          <el-input
+            v-model="formData.description"
+            type="textarea"
+            :rows="3"
+            placeholder="请输入描述(选填)"
+            maxlength="200"
+          />
+        </el-form-item>
+      </el-form>
+      <span slot="footer">
+        <el-button size="small" @click="dialogVisible = false">取 消</el-button>
+        <el-button size="small" type="primary" @click="submitForm">确 定</el-button>
+      </span>
+    </el-dialog>
+
+    <!-- 设置标签弹窗 -->
+    <el-dialog
+      title="设置标签"
+      :visible.sync="tagDialogVisible"
+      width="500px"
+      :close-on-click-modal="false"
+      append-to-body
+    >
+      <div class="kbl-tag-editor">
+        <el-tag
+          v-for="tag in editTags"
+          :key="tag"
+          closable
+          :disable-transitions="false"
+          size="small"
+          @close="removeTag(tag)"
+        >{{ tag }}</el-tag>
+        <el-input
+          v-if="tagInputVisible"
+          ref="tagInput"
+          v-model="tagInputValue"
+          size="mini"
+          class="kbl-tag-input"
+          @keyup.enter.native="addTag"
+          @blur="addTag"
+        />
+        <el-button v-else size="mini" @click="showTagInput">+ 添加标签</el-button>
+      </div>
+      <span slot="footer">
+        <el-button size="small" @click="tagDialogVisible = false">取 消</el-button>
+        <el-button size="small" type="primary" @click="submitTags">确 定</el-button>
+      </span>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+const TAG_COLORS = [
+  '#409EFF', '#67C23A', '#E6A23C', '#F56C6C',
+  '#909399', '#B37FEB', '#13C2C2', '#FA8C16'
+]
+
+export default {
+  name: 'KnowledgeBaseList',
+  props: {
+    list: { type: Array, default: () => [] },
+    activeId: { type: [Number, String], default: null },
+    searchKey: { type: String, default: '' },
+  },
+  data() {
+    return {
+      searchText: '',
+      contextMenu: { visible: false, x: 0, y: 0, kb: null },
+      dialogVisible: false,
+      dialogMode: 'create',
+      formData: { name: '', description: '' },
+      formRules: {
+        name: [{ required: true, message: '请输入知识库名称', trigger: 'blur' }]
+      },
+      tagDialogVisible: false,
+      editTags: [],
+      tagInputVisible: false,
+      tagInputValue: '',
+      tagEditKB: null,
+      tagColorMap: {}
+    }
+  },
+  computed: {
+    dialogTitle() {
+      return this.dialogMode === 'create' ? '新建知识库' : '重命名知识库'
+    }
+  },
+  methods: {
+    openCreateDialog() {
+      this.dialogMode = 'create'
+      this.formData = { name: '', description: '' }
+      this.dialogVisible = true
+      this.$nextTick(() => { this.$refs.kbForm && this.$refs.kbForm.clearValidate() })
+    },
+
+    openRenameDialog() {
+      const kb = this.contextMenu.kb
+      this.dialogMode = 'rename'
+      this.formData = { name: kb.name, description: kb.description || '' }
+      this.dialogVisible = true
+      this.hideContextMenu()
+      this.$nextTick(() => { this.$refs.kbForm && this.$refs.kbForm.clearValidate() })
+    },
+
+    submitForm() {
+      this.$refs.kbForm.validate(valid => {
+        if (!valid) return
+        if (this.dialogMode === 'create') {
+          this.$emit('create', { ...this.formData })
+        } else {
+          const kb = this.contextMenu.kb
+          this.$emit('rename', { id: kb.id, name: this.formData.name, description: this.formData.description })
+        }
+        this.dialogVisible = false
+      })
+    },
+
+    confirmDelete() {
+      const kb = this.contextMenu.kb
+      this.hideContextMenu()
+      this.$confirm(`确定删除知识库「${kb.name}」吗?知识库下的所有文档也将被删除。`, '删除确认', {
+        confirmButtonText: '确定删除',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        this.$emit('delete', kb.id)
+      }).catch(() => {})
+    },
+
+    // 标签
+    openTagDialog() {
+      const kb = this.contextMenu.kb
+      this.tagEditKB = kb
+      this.editTags = [...(kb.tags || [])]
+      this.tagDialogVisible = true
+      this.hideContextMenu()
+    },
+
+    showTagInput() {
+      this.tagInputVisible = true
+      this.$nextTick(() => {
+        this.$refs.tagInput && this.$refs.tagInput.$refs.input.focus()
+      })
+    },
+
+    addTag() {
+      const val = this.tagInputValue.trim()
+      if (val && !this.editTags.includes(val)) {
+        this.editTags.push(val)
+      }
+      this.tagInputVisible = false
+      this.tagInputValue = ''
+    },
+
+    removeTag(tag) {
+      const idx = this.editTags.indexOf(tag)
+      if (idx > -1) this.editTags.splice(idx, 1)
+    },
+
+    submitTags() {
+      if (this.tagEditKB) {
+        this.$emit('set-tags', { id: this.tagEditKB.id, tags: this.editTags })
+      }
+      this.tagDialogVisible = false
+    },
+
+    // 右键菜单
+    openContextMenu(e, kb) {
+      this.contextMenu = { visible: true, x: e.clientX, y: e.clientY, kb }
+      document.addEventListener('click', this.hideContextMenu, { once: true })
+    },
+
+    hideContextMenu() {
+      this.contextMenu.visible = false
+    },
+
+    getTagColor(tag) {
+      if (!this.tagColorMap[tag]) {
+        const i = Object.keys(this.tagColorMap).length % TAG_COLORS.length
+        this.$set(this.tagColorMap, tag, TAG_COLORS[i])
+      }
+      return this.tagColorMap[tag]
+    }
+  }
+}
+</script>
+
+<style scoped>
+.kbl-wrapper {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+
+.kbl-header {
+  padding: 12px;
+  border-bottom: 1px solid #e4e7ed;
+}
+
+.kbl-create-btn {
+  width: 100%;
+  margin-top: 8px;
+}
+
+.kbl-list {
+  flex: 1;
+  overflow-y: auto;
+  padding: 4px 0;
+}
+
+.kbl-item {
+  padding: 12px 16px;
+  cursor: pointer;
+  border-left: 3px solid transparent;
+  transition: background 0.15s;
+}
+
+.kbl-item:hover {
+  background: #f5f7fa;
+}
+
+.kbl-item--active {
+  background: #ecf5ff;
+  border-left-color: #3874F6;
+}
+
+.kbl-item-top {
+  display: flex;
+  align-items: center;
+  margin-bottom: 6px;
+}
+
+.kbl-item-icon {
+  color: #3874F6;
+  font-size: 16px;
+  margin-right: 8px;
+  flex-shrink: 0;
+}
+
+.kbl-item-name {
+  font-size: 14px;
+  color: #303133;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  font-weight: 500;
+}
+
+.kbl-item-bottom {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding-left: 24px;
+}
+
+.kbl-item-time {
+  font-size: 12px;
+  color: #c0c4cc;
+}
+
+.kbl-item-right {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.kbl-item-count {
+  font-size: 12px;
+  color: #909399;
+}
+
+.kbl-tag-dot {
+  display: inline-block;
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  margin-left: 4px;
+}
+
+.kbl-empty {
+  text-align: center;
+  color: #c0c4cc;
+  padding: 40px 0;
+  font-size: 13px;
+}
+
+.kbl-empty-tip {
+  font-size: 12px;
+  margin-top: 4px;
+}
+
+/* 右键菜单 */
+.kbl-context-menu {
+  position: fixed;
+  z-index: 3000;
+  background: #fff;
+  border: 1px solid #e4e7ed;
+  border-radius: 4px;
+  box-shadow: 0 2px 12px rgba(0,0,0,0.12);
+  padding: 4px 0;
+  min-width: 140px;
+}
+
+.kbl-menu-item {
+  padding: 8px 16px;
+  font-size: 13px;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.kbl-menu-item:hover {
+  background: #f5f7fa;
+}
+
+.kbl-menu-item--danger {
+  color: #F56C6C;
+}
+
+.kbl-menu-item--danger:hover {
+  background: #fef0f0;
+}
+
+/* 标签编辑 */
+.kbl-tag-editor {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  align-items: center;
+  min-height: 32px;
+}
+
+.kbl-tag-input {
+  width: 100px;
+}
+</style>

+ 226 - 0
src/AIManagement/KBM/MetadataConfig.vue

@@ -0,0 +1,226 @@
+<template>
+  <div class="mc-wrapper">
+    <div class="mc-toolbar">
+      <el-button type="primary" size="small" icon="el-icon-plus" @click="openCreateDialog">新建字段</el-button>
+    </div>
+
+    <el-table :data="fields" size="small" stripe style="width: 100%;" height="calc(100vh - 330px)">
+      <el-table-column label="字段名称" min-width="120" prop="name" />
+      <el-table-column label="字段 Key" min-width="120" prop="key" />
+      <el-table-column label="字段类型" width="100" align="center">
+        <template v-slot="{ row }">
+          <el-tag size="mini" type="info">{{ typeLabel(row.type) }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="可选值" min-width="180">
+        <template v-slot="{ row }">
+          <template v-if="row.options && row.options.length > 0">
+            <el-tag v-for="opt in row.options" :key="opt" size="mini" style="margin: 2px 4px 2px 0;">{{ opt }}</el-tag>
+          </template>
+          <span v-else style="color: #c0c4cc;">-</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="类型" width="90" align="center">
+        <template v-slot="{ row }">
+          <el-tag size="mini" :type="row.isSystem ? 'warning' : 'success'">
+            {{ row.isSystem ? '系统' : '自定义' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="启用" width="70" align="center">
+        <template v-slot="{ row }">
+          <el-switch
+            :value="row.enabled"
+            :disabled="row.isSystem && !row.enabled"
+            @change="$emit('toggle', row.id)"
+          />
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" width="120" fixed="right">
+        <template v-slot="{ row }">
+          <el-button type="text" size="mini" @click="openEditDialog(row)" style="margin-right: 12px;">编辑</el-button>
+          <el-popconfirm
+            v-if="!row.isSystem"
+            title="确定删除该字段?"
+            confirm-button-text="删除"
+            @confirm="$emit('delete', row.id)"
+          >
+            <el-button type="text" size="mini" slot="reference" style="color: #F56C6C;">删除</el-button>
+          </el-popconfirm>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <!-- 新建/编辑弹窗 -->
+    <el-dialog
+      :title="dialogMode === 'create' ? '新建元数据字段' : '编辑元数据字段'"
+      :visible.sync="dialogVisible"
+      width="500px"
+      :close-on-click-modal="false"
+      append-to-body
+    >
+      <el-form ref="fieldForm" :model="form" :rules="rules" label-width="80px" size="small">
+        <el-form-item label="字段名称" prop="name">
+          <el-input v-model="form.name" placeholder="如:文档分类" maxlength="20" />
+        </el-form-item>
+        <el-form-item label="字段 Key" prop="key">
+          <el-input v-model="form.key" placeholder="如:docCategory" maxlength="30" :disabled="dialogMode === 'edit'" />
+          <span class="mc-form-tip">用于程序识别,创建后不可修改</span>
+        </el-form-item>
+        <el-form-item label="字段类型" prop="type">
+          <el-select v-model="form.type" placeholder="选择类型" :disabled="dialogMode === 'edit'" style="width: 100%;">
+            <el-option label="文本" value="text" />
+            <el-option label="下拉单选" value="select" />
+            <el-option label="下拉多选" value="multiSelect" />
+            <el-option label="日期" value="date" />
+          </el-select>
+        </el-form-item>
+        <el-form-item
+          v-if="form.type === 'select' || form.type === 'multiSelect'"
+          label="可选值"
+          prop="options"
+        >
+          <div class="mc-option-editor">
+            <el-tag
+              v-for="(opt, idx) in form.options"
+              :key="idx"
+              closable
+              size="small"
+              @close="form.options.splice(idx, 1)"
+            >{{ opt }}</el-tag>
+            <el-input
+              v-if="optionInputVisible"
+              ref="optionInput"
+              v-model="optionInputValue"
+              size="mini"
+              class="mc-option-input"
+              @keyup.enter.native="addOption"
+              @blur="addOption"
+            />
+            <el-button v-else size="mini" @click="showOptionInput">+ 添加</el-button>
+          </div>
+        </el-form-item>
+      </el-form>
+      <span slot="footer">
+        <el-button size="small" @click="dialogVisible = false">取 消</el-button>
+        <el-button size="small" type="primary" @click="submitForm">确 定</el-button>
+      </span>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'MetadataConfig',
+  props: {
+    fields: { type: Array, default: () => [] }
+  },
+  data() {
+    return {
+      dialogVisible: false,
+      dialogMode: 'create',
+      editFieldId: null,
+      form: {
+        name: '',
+        key: '',
+        type: '',
+        options: []
+      },
+      rules: {
+        name: [{ required: true, message: '请输入字段名称', trigger: 'blur' }],
+        key: [
+          { required: true, message: '请输入字段Key', trigger: 'blur' },
+          { pattern: /^[a-zA-Z][a-zA-Z0-9_]*$/, message: 'Key只能包含字母、数字和下划线,且以字母开头', trigger: 'blur' }
+        ],
+        type: [{ required: true, message: '请选择字段类型', trigger: 'change' }]
+      },
+      optionInputVisible: false,
+      optionInputValue: ''
+    }
+  },
+  methods: {
+    typeLabel(type) {
+      const map = { text: '文本', select: '下拉单选', multiSelect: '下拉多选', date: '日期' }
+      return map[type] || type
+    },
+
+    openCreateDialog() {
+      this.dialogMode = 'create'
+      this.form = { name: '', key: '', type: '', options: [] }
+      this.dialogVisible = true
+      this.$nextTick(() => { this.$refs.fieldForm && this.$refs.fieldForm.clearValidate() })
+    },
+
+    openEditDialog(row) {
+      this.dialogMode = 'edit'
+      this.editFieldId = row.id
+      this.form = {
+        name: row.name,
+        key: row.key,
+        type: row.type,
+        options: [...(row.options || [])]
+      }
+      this.dialogVisible = true
+      this.$nextTick(() => { this.$refs.fieldForm && this.$refs.fieldForm.clearValidate() })
+    },
+
+    showOptionInput() {
+      this.optionInputVisible = true
+      this.$nextTick(() => {
+        this.$refs.optionInput && this.$refs.optionInput.$refs.input.focus()
+      })
+    },
+
+    addOption() {
+      const val = this.optionInputValue.trim()
+      if (val && !this.form.options.includes(val)) {
+        this.form.options.push(val)
+      }
+      this.optionInputVisible = false
+      this.optionInputValue = ''
+    },
+
+    submitForm() {
+      this.$refs.fieldForm.validate(valid => {
+        if (!valid) return
+        if (this.dialogMode === 'create') {
+          this.$emit('create', { ...this.form })
+        } else {
+          this.$emit('update', { id: this.editFieldId, ...this.form })
+        }
+        this.dialogVisible = false
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.mc-wrapper {
+  background: #fff;
+  border-radius: 4px;
+  padding: 12px 0;
+}
+
+.mc-toolbar {
+  padding: 0 0 12px 0;
+}
+
+.mc-form-tip {
+  font-size: 11px;
+  color: #c0c4cc;
+  display: block;
+  margin-top: 4px;
+}
+
+.mc-option-editor {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+  align-items: center;
+}
+
+.mc-option-input {
+  width: 100px;
+}
+</style>

+ 210 - 0
src/AIManagement/KBM/SearchTest.vue

@@ -0,0 +1,210 @@
+<template>
+  <div class="st-wrapper">
+    <!-- 检索输入 -->
+    <div class="st-search-bar">
+      <el-input
+        v-model="query"
+        placeholder="输入测试问题,检索知识库中的相关内容..."
+        size="small"
+        @keyup.native.enter="doSearch"
+        clearable
+        class="st-search-input"
+      >
+        <el-button slot="append" type="primary" icon="el-icon-search" @click="doSearch" :loading="searching">
+          检索
+        </el-button>
+      </el-input>
+    </div>
+
+    <!-- 检索结果 -->
+    <div class="st-results" v-if="searchDone">
+      <div class="st-result-header">
+        <span>共找到 <b>{{ results.length }}</b> 个相关片段</span>
+      </div>
+
+      <div v-if="results.length === 0" class="st-empty">
+        <i class="el-icon-warning-outline" style="font-size: 40px;"></i>
+        <p>未找到相关结果</p>
+        <p class="st-empty-tip">请尝试调整检索关键词</p>
+      </div>
+
+      <div v-for="(item, idx) in results" :key="idx" class="st-result-card">
+        <div class="st-result-score">
+          <span class="st-score-badge" :style="{ background: scoreColor(item.score) }">
+            相似度 {{ (item.score * 100).toFixed(1) }}%
+          </span>
+        </div>
+        <div class="st-result-content">
+          <div class="st-fragment-text">{{ item.content }}</div>
+          <div class="st-fragment-source">
+            <i class="el-icon-document"></i>
+            <span>{{ item.docName }}</span>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 初始状态 -->
+    <div v-else class="st-placeholder">
+      <i class="el-icon-search" style="font-size: 48px;"></i>
+      <p>输入问题测试知识库检索效果</p>
+      <p class="st-placeholder-tip">检索使用向量相似度匹配,不会生成LLM回答,用于调试知识库质量</p>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'SearchTest',
+  props: {
+    kbId: { type: [Number, String], default: null },
+    documents: { type: Array, default: () => [] }
+  },
+  data() {
+    return {
+      query: '',
+      searching: false,
+      searchDone: false,
+      results: []
+    }
+  },
+  methods: {
+    async doSearch() {
+      if (!this.query.trim()) {
+        this.$message.warning('请输入检索内容')
+        return
+      }
+
+      this.searching = true
+      this.searchDone = true
+
+      try {
+        const res = await this.$api.requested({
+          id: 2026052714301001, // 暂用已有接口ID占位,后端需提供检索接口
+          content: { dataset_id: String(this.kbId), query: this.query, topK: 10 }
+        })
+        console.log('[API] search', { request: { dataset_id: this.kbId, query: this.query }, response: res })
+        if (res && res.code === 1 && res.data) {
+          const list = res.data.results || res.data.list || res.data || []
+          this.results = list.map(item => ({
+            docName: item.documentName || item.document_name || item.docName || '',
+            content: item.content || item.text || '',
+            score: item.score || 0
+          }))
+          if (this.results.length === 0) {
+            this.$message.info('未找到相关结果')
+          }
+        }
+      } catch (e) {
+        console.error('[GAP] 检索测试接口尚未提供', e)
+        this.results = []
+        this.$message.warning('检索功能暂未开放,请等待后端接口就绪')
+      }
+
+      this.searching = false
+    },
+
+    scoreColor(score) {
+      if (score >= 0.85) return '#67C23A'
+      if (score >= 0.7) return '#E6A23C'
+      return '#F56C6C'
+    }
+  }
+}
+</script>
+
+<style scoped>
+.st-wrapper {
+  background: #fff;
+  border-radius: 4px;
+  min-height: 400px;
+}
+
+.st-search-bar {
+  padding: 12px 0;
+}
+
+.st-search-input {
+  max-width: 600px;
+}
+
+.st-results {
+  padding: 8px 0;
+}
+
+.st-result-header {
+  font-size: 13px;
+  color: #606266;
+  margin-bottom: 12px;
+  padding-bottom: 10px;
+  border-bottom: 1px solid #ebeef5;
+}
+
+.st-empty {
+  text-align: center;
+  padding: 60px 0;
+  color: #c0c4cc;
+}
+
+.st-empty-tip {
+  font-size: 12px;
+  margin-top: 4px;
+}
+
+.st-result-card {
+  border: 1px solid #ebeef5;
+  border-radius: 6px;
+  padding: 14px 16px;
+  margin-bottom: 12px;
+  transition: box-shadow 0.2s;
+}
+
+.st-result-card:hover {
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
+}
+
+.st-result-score {
+  margin-bottom: 10px;
+}
+
+.st-score-badge {
+  display: inline-block;
+  padding: 2px 10px;
+  border-radius: 10px;
+  font-size: 12px;
+  color: #fff;
+  font-weight: 500;
+}
+
+.st-fragment-text {
+  font-size: 13px;
+  color: #303133;
+  line-height: 1.7;
+  margin-bottom: 8px;
+}
+
+.st-fragment-source {
+  font-size: 12px;
+  color: #909399;
+  display: flex;
+  align-items: center;
+  gap: 4px;
+}
+
+.st-placeholder {
+  text-align: center;
+  padding: 60px 0;
+  color: #c0c4cc;
+}
+
+.st-placeholder p:first-of-type {
+  margin-top: 16px;
+  font-size: 14px;
+  color: #909399;
+}
+
+.st-placeholder-tip {
+  font-size: 12px;
+  margin-top: 4px;
+}
+</style>

+ 188 - 0
src/AIManagement/KBM/UploadDocument.vue

@@ -0,0 +1,188 @@
+<template>
+  <el-dialog
+    title="上传文档"
+    :visible.sync="visible"
+    width="620px"
+    :close-on-click-modal="false"
+    @close="resetForm"
+    append-to-body
+  >
+    <el-form ref="uploadForm" :model="form" :rules="rules" label-width="90px" size="small">
+      <!-- 文件选择 -->
+      <el-form-item label="选择文件" prop="files">
+        <el-upload
+          ref="upload"
+          action="#"
+          :auto-upload="false"
+          :multiple="true"
+          :file-list="fileList"
+          :before-upload="beforeUpload"
+          :on-change="handleFileChange"
+          :on-remove="handleFileRemove"
+          drag
+        >
+          <i class="el-icon-upload"></i>
+          <div class="dt-upload-text">将文件拖到此处,或<em>点击上传</em></div>
+          <div class="dt-upload-tip" slot="tip">支持 PDF、Word、Excel、PPT、TXT,单个文件不超过 20MB</div>
+        </el-upload>
+      </el-form-item>
+
+      <!-- 动态元数据字段 -->
+      <template v-for="field in metadataFields">
+        <el-form-item
+          v-if="field.key === 'productLine'"
+          :key="field.id"
+          :label="field.name"
+        >
+          <el-select v-model="form.metadata.productLine" placeholder="请选择产品线" clearable style="width: 100%;">
+            <el-option
+              v-for="opt in (field.options || [])"
+              :key="opt"
+              :label="opt"
+              :value="opt"
+            />
+          </el-select>
+        </el-form-item>
+
+        <el-form-item
+          v-else-if="field.key === 'applicableRole'"
+          :key="field.id"
+          :label="field.name"
+        >
+          <el-select v-model="form.metadata.applicableRole" placeholder="请选择适用角色" multiple clearable style="width: 100%;">
+            <el-option
+              v-for="opt in (field.options || [])"
+              :key="opt"
+              :label="opt"
+              :value="opt"
+            />
+          </el-select>
+        </el-form-item>
+
+        <el-form-item
+          v-else-if="field.key === 'validUntil'"
+          :key="field.id"
+          :label="field.name"
+        >
+          <el-date-picker v-model="form.metadata.validUntil" type="date" placeholder="选择有效期" value-format="yyyy-MM-dd" style="width: 100%;" />
+        </el-form-item>
+
+        <el-form-item
+          v-else
+          :key="field.id"
+          :label="field.name"
+        >
+          <template v-if="field.type === 'select'">
+            <el-select v-model="form.metadata[field.key]" :placeholder="'请选择' + field.name" clearable style="width: 100%;">
+              <el-option v-for="opt in (field.options || [])" :key="opt" :label="opt" :value="opt" />
+            </el-select>
+          </template>
+          <template v-else>
+            <el-input v-model="form.metadata[field.key]" :placeholder="'请输入' + field.name" />
+          </template>
+        </el-form-item>
+      </template>
+    </el-form>
+
+    <span slot="footer">
+      <el-button size="small" @click="closeDialog">取 消</el-button>
+      <el-button size="small" type="primary" :disabled="fileList.length === 0" @click="submitUpload">开始上传</el-button>
+    </span>
+  </el-dialog>
+</template>
+
+<script>
+export default {
+  name: 'UploadDocument',
+  props: {
+    visible: { type: Boolean, default: false },
+    metadataFields: { type: Array, default: () => [] }
+  },
+  data() {
+    return {
+      fileList: [],
+      form: {
+        files: [],
+        metadata: {
+          productLine: '',
+          applicableRole: [],
+          validUntil: ''
+        }
+      },
+      rules: {
+        files: [{ required: true, message: '请选择文件', trigger: 'change' }]
+      }
+    }
+  },
+  methods: {
+    beforeUpload(file) {
+      const maxSize = 20 * 1024 * 1024
+      if (file.size > maxSize) {
+        this.$message.error(`文件 ${file.name} 超过 20MB 限制`)
+        return false
+      }
+      return false
+    },
+
+    handleFileChange(file) {
+      this.form.files = this.$refs.upload.uploadFiles.map(f => f.raw).filter(Boolean)
+    },
+
+    handleFileRemove() {
+      this.$nextTick(() => {
+        this.form.files = this.$refs.upload.uploadFiles.map(f => f.raw).filter(Boolean)
+      })
+    },
+
+    submitUpload() {
+      if (this.fileList.length === 0 && this.form.files.length === 0) {
+        this.$message.warning('请选择文件')
+        return
+      }
+      this.$emit('submit', {
+        files: this.form.files,
+        metadata: { ...this.form.metadata }
+      })
+      this.resetForm()
+    },
+
+    closeDialog() {
+      this.$emit('close')
+      this.resetForm()
+    },
+
+    resetForm() {
+      this.fileList = []
+      this.form = {
+        files: [],
+        metadata: {
+          productLine: '',
+          applicableRole: [],
+          validUntil: ''
+        }
+      }
+      if (this.$refs.uploadForm) {
+        this.$refs.uploadForm.clearValidate()
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.dt-upload-text {
+  color: #606266;
+  font-size: 13px;
+}
+
+.dt-upload-text em {
+  color: #3874F6;
+  font-style: normal;
+}
+
+.dt-upload-tip {
+  color: #c0c4cc;
+  font-size: 12px;
+  margin-top: 8px;
+}
+</style>

+ 509 - 0
src/AIManagement/KBM/index.vue

@@ -0,0 +1,509 @@
+<template>
+  <div class="kb-manage">
+    <!-- 左侧:知识库列表 -->
+    <div class="kb-left-panel">
+      <KnowledgeBaseList
+        :list="filteredKBList"
+        :activeId="activeKBId"
+        :searchKey="kbSearchKey"
+        @search="kbSearchKey = $event"
+        @select="onSelectKB"
+        @create="onCreateKB"
+        @rename="onRenameKB"
+        @delete="onDeleteKB"
+        @set-tags="onSetTags"
+      />
+    </div>
+
+    <!-- 右侧:详情与操作区 -->
+    <div class="kb-right-panel">
+      <template v-if="activeKB">
+        <div class="kb-detail-header normal-panel">
+          <div class="kb-detail-info">
+            <h3 class="kb-detail-name">{{ activeKB.name }}</h3>
+            <p class="kb-detail-desc">{{ activeKB.description || '暂无描述' }}</p>
+            <div class="kb-detail-meta">
+              <span>创建时间:{{ activeKB.createdAt }}</span>
+              <span>更新时间:{{ activeKB.updatedAt }}</span>
+              <span>
+                标签:
+                <el-tag
+                  v-for="tag in activeKB.tags"
+                  :key="tag"
+                  :color="getTagColor(tag)"
+                  size="small"
+                  effect="dark"
+                  class="kb-tag-inline"
+                >{{ tag }}</el-tag>
+                <span v-if="!activeKB.tags || activeKB.tags.length === 0" class="kb-no-tag">无</span>
+              </span>
+            </div>
+          </div>
+        </div>
+
+        <div class="kb-tabs-wrapper">
+          <el-tabs v-model="activeTab" type="card" class="kb-tabs">
+            <el-tab-pane label="文档管理" name="docs">
+              <DocumentTable
+                :kb="activeKB"
+                :documents="currentDocuments"
+                :metadataFields="metadataFields"
+                @upload="openUploadDialog"
+                @detail="openDocumentDetail"
+                @toggle-status="onToggleDocStatus"
+                @delete="onDeleteDocument"
+                @download="onDownloadDocument"
+                @batch-download="onBatchDownload"
+                @refresh="loadDocuments"
+              />
+            </el-tab-pane>
+            <el-tab-pane label="元数据配置" name="metadata">
+              <MetadataConfig
+                :fields="metadataFields"
+                @create="onCreateField"
+                @update="onUpdateField"
+                @delete="onDeleteField"
+                @toggle="onToggleField"
+              />
+            </el-tab-pane>
+            <el-tab-pane label="检索测试" name="search">
+              <SearchTest :kbId="activeKBId" :documents="currentDocuments" />
+            </el-tab-pane>
+          </el-tabs>
+        </div>
+      </template>
+      <div v-else class="kb-empty-state">
+        <i class="el-icon-document"></i>
+        <p>{{ knowledgeBases.length === 0 ? '加载中...' : '请选择左侧知识库查看详情' }}</p>
+      </div>
+    </div>
+
+    <!-- 上传文档弹窗 -->
+    <UploadDocument
+      :visible="uploadDialogVisible"
+      :metadataFields="enabledMetadataFields"
+      @close="uploadDialogVisible = false"
+      @submit="onUploadDocument"
+    />
+
+    <!-- 文档详情抽屉 -->
+    <DocumentDetail
+      :visible="detailDrawerVisible"
+      :document="selectedDocument"
+      :metadataFields="enabledMetadataFields"
+      @close="detailDrawerVisible = false"
+      @save-meta="onSaveDocMetadata"
+      @toggle-status="onToggleDocStatus"
+      @retry-embed="onRetryEmbed"
+      @delete="onDeleteDocument"
+    />
+  </div>
+</template>
+
+<script>
+import KnowledgeBaseList from './KnowledgeBaseList.vue'
+import DocumentTable from './DocumentTable.vue'
+import UploadDocument from './UploadDocument.vue'
+import DocumentDetail from './DocumentDetail.vue'
+import MetadataConfig from './MetadataConfig.vue'
+import SearchTest from './SearchTest.vue'
+
+const TAG_COLORS = [
+  '#409EFF', '#67C23A', '#E6A23C', '#F56C6C',
+  '#909399', '#B37FEB', '#13C2C2', '#FA8C16'
+]
+
+// [缺] 后端未提供以下接口,前端暂不支持:
+const GAP_MSG = '该功能暂未开放,请等待后端接口就绪'
+
+export default {
+  name: 'KBManagement',
+  components: {
+    KnowledgeBaseList,
+    DocumentTable,
+    UploadDocument,
+    DocumentDetail,
+    MetadataConfig,
+    SearchTest
+  },
+  data() {
+    return {
+      activeKBId: null,
+      kbSearchKey: '',
+      activeTab: 'docs',
+      uploadDialogVisible: false,
+      detailDrawerVisible: false,
+      selectedDocument: null,
+      tagColorMap: {},
+
+      // 全部从接口获取
+      knowledgeBases: [],
+      allDocuments: [],
+      metadataFields: []
+    }
+  },
+  computed: {
+    activeKB() {
+      return this.knowledgeBases.find(kb => kb.id === this.activeKBId) || null
+    },
+    filteredKBList() {
+      if (!this.kbSearchKey) return this.knowledgeBases
+      const keyword = this.kbSearchKey.toLowerCase()
+      return this.knowledgeBases.filter(kb => kb.name.toLowerCase().includes(keyword))
+    },
+    currentDocuments() {
+      if (!this.activeKBId) return []
+      return this.allDocuments.filter(doc => doc.kbId === this.activeKBId)
+    },
+    enabledMetadataFields() {
+      return this.metadataFields.filter(f => f.enabled)
+    },
+  },
+  mounted() {
+    this.fetchKBList()
+  },
+  methods: {
+    // ====== 通用请求 ======
+    async apiCall(id, content = {}) {
+      try {
+        const res = await this.$api.requested({ id, content })
+        console.log(`[API] id=${id}`, { request: content, response: res })
+        return res
+      } catch (e) {
+        console.error(`[API] id=${id} error`, e)
+        return null
+      }
+    },
+
+    gapWarn(feature) {
+      console.warn(`[GAP] ${feature}`)
+      this.$message.warning(GAP_MSG)
+    },
+
+    // ====== 知识库 API ======
+    async fetchKBList() {
+      const res = await this.apiCall(2026052714301001, { keyword: this.kbSearchKey || '' })
+      if (res && res.code === 1 && res.data) {
+        const list = res.data.data || res.data.list || res.data || []
+        this.knowledgeBases = list.map(item => ({
+          id: item.id || item.dataset_id,
+          name: item.name || item.dataset_name || '',
+          description: item.description || item.desc || '',
+          docCount: item.total_documents || 0,
+          tags: item.tags || [],
+          createdAt: this.formatTimestamp(item.created_at || item.createdAt),
+          updatedAt: this.formatTimestamp(item.updated_at || item.updatedAt)
+        }))
+        if (!this.activeKBId && this.knowledgeBases.length > 0) {
+          this.activeKBId = this.knowledgeBases[0].id
+          this.fetchDocumentList()
+          this.fetchKBTags()
+        }
+      }
+    },
+
+    async fetchKBTags() {
+      if (!this.activeKBId) return
+      const res = await this.apiCall(2026052714301007, { dataset_id: String(this.activeKBId) })
+      if (res && res.code === 1 && res.data) {
+        const tags = res.data.tags || res.data.list || res.data || []
+        const kb = this.knowledgeBases.find(k => k.id === this.activeKBId)
+        if (kb) kb.tags = Array.isArray(tags) ? tags : []
+      }
+    },
+
+    // ====== 文档 API ======
+    async fetchDocumentList() {
+      if (!this.activeKBId) return
+      const res = await this.apiCall(2026052714301017, {
+        dataset_id: String(this.activeKBId),
+        keyword: '',
+        status: '',
+        pageNumber: 1,
+        pageSize: 999
+      })
+      if (res && res.code === 1 && res.data) {
+        const list = res.data.list || res.data.data || res.data || []
+        this.allDocuments = list.map(item => ({
+          id: item.id || item.document_id,
+          kbId: this.activeKBId,
+          name: item.name || item.document_name || item.file_name || '',
+          size: item.size || item.file_size || '-',
+          type: (item.type || item.file_type || item.name || '').split('.').pop().toLowerCase(),
+          version: item.version || 1,
+          status: item.status || 'enabled',
+          embedStatus: item.indexing_status || item.embedStatus || 'pending',
+          embedProgress: item.completed_segments !== undefined && item.total_segments
+            ? Math.round(item.completed_segments / item.total_segments * 100)
+            : (item.embedProgress || 0),
+          uploadTime: item.created_at || item.uploadTime || '',
+          metadata: item.metadata || item.doc_metadata || {}
+        }))
+      }
+    },
+
+    async fetchDocumentDetail(docId) {
+      const res = await this.apiCall(2026052714301008, {
+        dataset_id: String(this.activeKBId),
+        document_id: String(docId)
+      })
+      if (res && res.code === 1 && res.data) {
+        const d = res.data
+        const idx = this.allDocuments.findIndex(doc => doc.id === docId)
+        if (idx > -1) {
+          this.allDocuments[idx] = {
+            ...this.allDocuments[idx],
+            name: d.name || d.document_name || this.allDocuments[idx].name,
+            size: d.size || d.file_size || this.allDocuments[idx].size,
+            type: (d.type || d.file_type || d.name || '').split('.').pop().toLowerCase(),
+            version: d.version || this.allDocuments[idx].version,
+            status: d.status || this.allDocuments[idx].status,
+            embedStatus: d.indexing_status || d.embedStatus || this.allDocuments[idx].embedStatus,
+            embedProgress: d.embedProgress || this.allDocuments[idx].embedProgress,
+            metadata: d.metadata || d.doc_metadata || this.allDocuments[idx].metadata
+          }
+        }
+        this.selectedDocument = { ...this.allDocuments[idx] || this.selectedDocument }
+      }
+    },
+
+    async fetchIndexingStatus(docId, batch) {
+      const res = await this.apiCall(2026052714301012, {
+        dataset_id: String(this.activeKBId),
+        batch: batch || String(docId)
+      })
+      if (res && res.code === 1 && res.data) {
+        const doc = this.allDocuments.find(d => d.id === docId)
+        if (doc) {
+          doc.embedStatus = res.data.indexing_status || res.data.status || doc.embedStatus
+          if (res.data.completed_segments !== undefined && res.data.total_segments) {
+            doc.embedProgress = Math.round(res.data.completed_segments / res.data.total_segments * 100)
+          }
+        }
+      }
+    },
+
+    // ====== 知识库操作 ======
+    onSelectKB(id) {
+      this.activeKBId = id
+      this.activeTab = 'docs'
+      this.fetchDocumentList()
+      this.fetchKBTags()
+    },
+
+    onCreateKB() {
+      this.gapWarn('创建知识库')
+    },
+
+    onRenameKB() {
+      this.gapWarn('更新知识库')
+    },
+
+    onDeleteKB() {
+      this.gapWarn('删除知识库')
+    },
+
+    onSetTags() {
+      this.gapWarn('设置知识库标签')
+    },
+
+    // ====== 文档操作 ======
+    openUploadDialog() {
+      this.gapWarn('上传文档(需先接入OBS获取attachmentid)')
+    },
+
+    onUploadDocument() {
+      this.uploadDialogVisible = false
+    },
+
+    loadDocuments() {
+      this.fetchDocumentList()
+    },
+
+    openDocumentDetail(doc) {
+      this.selectedDocument = { ...doc }
+      this.detailDrawerVisible = true
+      this.fetchDocumentDetail(doc.id)
+    },
+
+    onSaveDocMetadata() {
+      this.gapWarn('设置文档元数据')
+    },
+
+    async onToggleDocStatus(docId) {
+      const doc = this.allDocuments.find(d => d.id === docId)
+      if (!doc) return
+      const newStatus = doc.status === 'enabled' ? 'disabled' : 'enabled'
+      const action = newStatus === 'enabled' ? 'enable' : 'disable'
+
+      const ok = await this.apiCall(2026052714301016, {
+        dataset_id: String(this.activeKBId),
+        document_ids: [String(docId)],
+        action
+      })
+      if (ok && ok.code === 1) {
+        doc.status = newStatus
+        this.$message.success(newStatus === 'enabled' ? '文档已启用' : '文档已禁用')
+      }
+    },
+
+    async onDeleteDocument(docId) {
+      const ok = await this.apiCall(2026052714301009, {
+        dataset_id: String(this.activeKBId),
+        document_id: String(docId)
+      })
+      if (ok && ok.code === 1) {
+        const idx = this.allDocuments.findIndex(d => d.id === docId)
+        if (idx > -1) this.allDocuments.splice(idx, 1)
+        this.$message.success('文档已删除')
+      }
+    },
+
+    async onDownloadDocument(doc) {
+      const res = await this.apiCall(2026052714301010, {
+        dataset_id: String(this.activeKBId),
+        document_id: String(doc.id)
+      })
+      if (res && res.code === 1 && res.data) {
+        const url = res.data.download_url || res.data.downloadUrl || res.data.url
+        if (url) window.open(url)
+      }
+    },
+
+    async onBatchDownload(ids) {
+      const res = await this.apiCall(2026052714301011, {
+        dataset_id: String(this.activeKBId),
+        document_ids: ids.map(String)
+      })
+      if (res && res.code === 1 && res.data) {
+        const url = res.data.download_url || res.data.downloadUrl || res.data.url
+        if (url) window.open(url)
+      }
+    },
+
+    onRetryEmbed() {
+      this.gapWarn('重新向量化')
+    },
+
+    // ====== 元数据操作 ======
+    onCreateField() { this.gapWarn('元数据管理') },
+    onUpdateField() { this.gapWarn('元数据管理') },
+    onDeleteField() { this.gapWarn('元数据管理') },
+    onToggleField() { this.gapWarn('元数据管理') },
+
+    // ====== 工具方法 ======
+    formatTimestamp(ts) {
+      if (!ts) return ''
+      const d = new Date(ts * 1000)
+      const pad = n => String(n).padStart(2, '0')
+      return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
+    },
+    getTagColor(tag) {
+      if (!this.tagColorMap[tag]) {
+        const idx = Object.keys(this.tagColorMap).length % TAG_COLORS.length
+        this.$set(this.tagColorMap, tag, TAG_COLORS[idx])
+      }
+      return this.tagColorMap[tag]
+    }
+  }
+}
+</script>
+
+<style scoped>
+.kb-manage {
+  display: flex;
+  height: 100%;
+  overflow: hidden;
+}
+
+.kb-left-panel {
+  width: 280px;
+  min-width: 280px;
+  border-right: 1px solid #e4e7ed;
+  background: #fff;
+  display: flex;
+  flex-direction: column;
+}
+
+.kb-right-panel {
+  flex: 1;
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+  background: #f0f2f5;
+}
+
+.kb-detail-header {
+  flex-shrink: 0;
+  padding: 16px 20px;
+  background: #fff;
+  border-bottom: 1px solid #ebeef5;
+}
+
+.kb-detail-name {
+  margin: 0 0 8px 0;
+  font-size: 18px;
+  color: #303133;
+}
+
+.kb-detail-desc {
+  margin: 0 0 10px 0;
+  font-size: 13px;
+  color: #909399;
+  line-height: 1.5;
+}
+
+.kb-detail-meta {
+  display: flex;
+  gap: 24px;
+  font-size: 12px;
+  color: #909399;
+  align-items: center;
+  flex-wrap: wrap;
+}
+
+.kb-tag-inline { margin: 0 2px; }
+.kb-no-tag { color: #c0c4cc; }
+
+.kb-tabs-wrapper {
+  flex: 1;
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+}
+
+.kb-tabs {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  background: #fff;
+  padding: 0 20px 16px;
+}
+
+.kb-tabs >>> .el-tabs__content {
+  flex: 1;
+  overflow: auto;
+}
+
+.kb-tabs >>> .el-tabs__header {
+  margin-bottom: 16px;
+}
+
+.kb-empty-state {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  color: #c0c4cc;
+}
+
+.kb-empty-state i {
+  font-size: 64px;
+  margin-bottom: 16px;
+}
+
+.kb-empty-state p {
+  font-size: 14px;
+}
+</style>

+ 13 - 0
src/router/AIManagement.js

@@ -0,0 +1,13 @@
+const AIManagement = [
+  {
+    path: '/KBM',
+    name: 'KBM',
+    meta: {
+      title: '知识库管理',
+      ast_nav: true,
+      keeproute: true,
+    },
+    component: () => import(/* webpackChunkName: "about" */ '@/AIManagement/KBM/index')
+  },
+];
+export default AIManagement;

+ 2 - 1
src/router/index.js

@@ -5,6 +5,7 @@ import SManagement from './SManagement.js';
 import HDrpManagement from './HDrpManagement.js';
 import SDrpManagement from './SDrpManagement.js';
 import bgj from './bgj.js';
+import AIManagement from './AIManagement.js';
 import WebsiteManagement from './WebsiteManagement';
 import OptionSystem from './optionSystem';
 
@@ -173,7 +174,7 @@ let routes = [
 ];
 
 
-routes[2].children = [...routes[2].children,...HManagement,...SManagement,...HDrpManagement,...SDrpManagement,...WebsiteManagement,...OptionSystem,...bgj, {
+routes[2].children = [...routes[2].children,...HManagement,...SManagement,...HDrpManagement,...SDrpManagement,...WebsiteManagement,...OptionSystem,...bgj,...AIManagement, {
   path: '/404',
   name: 'notFound',
   meta: { title: '404', ast_nav: false },