Преглед на файлове

feat(KBM): 元数据配置优化及文档详情增强

- MetadataConfig: 去掉下拉单选/多选类型,只保留文本和日期
- MetadataConfig: 类型选择改为单选按钮组
- MetadataConfig: 字段类型标签颜色区分(文本蓝色、日期红色)
- MetadataConfig: 开关操作改为气泡确认
- MetadataConfig: 删除操作加弹窗确认+loading
- MetadataConfig: 新建/编辑确定按钮加loading
- DocumentDetail: 操作区添加下载文档按钮
- DocumentDetail: 禁用/启用和删除加弹窗二次确认
- DocumentDetail: 元数据编辑区分系统字段(只读)和自定义字段
- DocumentDetail: 删除内容预览区块
- DocumentTable: 操作区添加详情按钮
- index.vue: 修复文件大小获取路径(data_source_info)
- index.vue: 元数据格式化(时间戳转日期、来源转中文)
- index.vue: 元数据保存后重新获取详情确保数据一致
- index.vue: 状态更新同步到详情抽屉
- API.md: 融入元数据管理接口文档
Zachary преди 2 дни
родител
ревизия
b0d19b7968
променени са 5 файла, в които са добавени 638 реда и са изтрити 269 реда
  1. 154 88
      src/AIManagement/KBM/API.md
  2. 166 29
      src/AIManagement/KBM/DocumentDetail.vue
  3. 4 3
      src/AIManagement/KBM/DocumentTable.vue
  4. 91 110
      src/AIManagement/KBM/MetadataConfig.vue
  5. 223 39
      src/AIManagement/KBM/index.vue

+ 154 - 88
src/AIManagement/KBM/API.md

@@ -558,159 +558,221 @@ Content-Type: application/json;charset=UTF-8
 
 ---
 
-### 四、元数据管理(4 个接口)
+### 四、元数据管理(6 个接口)
+
+> 元数据相关接口使用数字ID调用方式:`this.$api.requested({ id: '接口ID', content: {...} })`
 
 #### 4.1 获取元数据列表
 
-> "元数据配置"Tab 进入时调用,返回所有预定义字段。
+> "元数据配置"Tab 进入时调用,支持获取内置或自定义元数据
 
-| 字段 | 值 |
-|------|-----|
-| classname | `knowledgeBase.metadata` |
-| method | `getList` |
+**接口ID:** `2026052715313903`
 
 **请求参数 (content):**
 ```json
 {
-  "pageNumber": 1,
-  "pageSize": 999
+  "dataset_id": "知识库ID",
+  "iscustom": true
 }
 ```
 
+| 参数名 | 类型 | 必填 | 说明 |
+|--------|------|------|------|
+| dataset_id | String | 是 | 知识库唯一标识符 |
+| iscustom | Boolean | 否 | 是否获取自定义元数据,默认false(获取内置元数据) |
+
 **响应数据:**
 ```json
 {
-  "code": 0,
+  "code": 1,
   "data": {
-    "list": [
+    "doc_metadata": [
       {
-        "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
+        "id": "metadata_id_1",
+        "name": "文档分类",
+        "type": "string"
       }
-    ],
-    "total": 4
+    ]
   }
 }
 ```
 
-**字段说明:**
-
-| 字段 | 类型 | 说明 |
-|------|------|------|
-| type | string | `text` 文本 / `select` 下拉单选 / `multiSelect` 下拉多选 / `date` 日期 |
-| isSystem | boolean | `true` 为系统内置字段,不可删除 |
-| enabled | boolean | 是否启用(禁用后上传弹窗不展示此字段) |
-| options | array | type为 select/multiSelect 时的可选值列表 |
+**说明:** 
+- 系统元数据响应:`data.fields[]`(无id)
+- 自定义元数据响应:`data.doc_metadata[]`(有id)
 
 ---
 
 #### 4.2 创建元数据
 
-> "新建字段"弹窗提交后调用
+> "新建字段"弹窗提交后调用
 
-| 字段 | 值 |
-|------|-----|
-| classname | `knowledgeBase.metadata` |
-| method | `create` |
+**接口ID:** `2026052715313904`
 
 **请求参数 (content):**
 ```json
 {
-  "name": "文档分类",
-  "key": "docType",
-  "type": "select",
-  "options": ["手册", "FAQ", "白皮书", "技术文档"]
+  "dataset_id": "知识库ID",
+  "name": "元数据名称",
+  "type": "string"
 }
 ```
 
-**验证规则:**
-- `name` 必填,最长 20 字符
-- `key` 必填,格式 `/^[a-zA-Z][a-zA-Z0-9_]*$/`,全局唯一
-- `type` 必填,枚举 `text/select/multiSelect/date`
-- `type` 为 `select/multiSelect` 时 `options` 至少有一项
+| 参数名 | 类型 | 必填 | 说明 |
+|--------|------|------|------|
+| dataset_id | String | 是 | 知识库唯一标识符 |
+| name | String | 是 | 元数据名称 |
+| type | String | 是 | 元数据类型:`string`/`number`/`time` |
 
 **响应数据:**
 ```json
 {
-  "code": 0,
-  "data": {
-    "id": 5,
-    "name": "文档分类",
-    "key": "docType",
-    "type": "select",
-    "options": ["手册", "FAQ", "白皮书", "技术文档"],
-    "isSystem": false,
-    "enabled": true
-  },
-  "message": "创建成功"
+  "code": 1,
+  "data": {},
+  "errmsg": null
 }
 ```
 
+**类型映射:** 前端类型需映射到API类型
+| 前端类型 | API类型 |
+|----------|---------|
+| text | string |
+| select | string |
+| multiSelect | string |
+| date | time |
+
 ---
 
-#### 4.3 更新元数据
+#### 4.3 删除元数据
 
-> 编辑弹窗提交后调用。key 不可改(前端已禁用),仅允许改 name / options。
+> 删除指定知识库中的特定元数据项(仅自定义字段可删除)
 
-| 字段 | 值 |
-|------|-----|
-| classname | `knowledgeBase.metadata` |
-| method | `update` |
+**接口ID:** `2026052715313905`
 
 **请求参数 (content):**
 ```json
 {
-  "id": 4,
-  "name": "文档分类(已更新)",
-  "type": "select",
-  "options": ["手册", "FAQ", "白皮书", "技术文档", "操作指南"]
+  "dataset_id": "知识库ID",
+  "metadata_id": "元数据ID"
 }
 ```
 
+| 参数名 | 类型 | 必填 | 说明 |
+|--------|------|------|------|
+| dataset_id | String | 是 | 知识库唯一标识符 |
+| metadata_id | String | 是 | 要删除的元数据唯一标识符 |
+
 **响应数据:**
 ```json
 {
-  "code": 0,
-  "message": "更新成功"
+  "code": 1,
+  "data": {},
+  "errmsg": null
 }
 ```
 
 ---
 
-#### 4.4 删除 / 启用禁用元数据
+#### 4.4 更新元数据
 
-> 两个独立操作,共用一个 classname。
+> 编辑弹窗提交后调用,仅允许修改名称
 
-| 操作 | method | content |
-|------|--------|---------|
-| 删除自定义字段 | `delete` | `{ "id": 4 }` |
-| 启用/禁用 | `toggleStatus` | `{ "id": 4, "enabled": false }` |
+**接口ID:** `2026052715313906`
 
-| 字段 | 值 |
-|------|-----|
-| classname | `knowledgeBase.metadata` |
+**请求参数 (content):**
+```json
+{
+  "dataset_id": "知识库ID",
+  "metadata_id": "元数据ID",
+  "name": "新元数据名称"
+}
+```
 
-**约束:** 系统字段 (`isSystem: true`) 不允许删除,`toggleStatus` 需支持。
+| 参数名 | 类型 | 必填 | 说明 |
+|--------|------|------|------|
+| dataset_id | String | 是 | 知识库唯一标识符 |
+| metadata_id | String | 是 | 要更新的元数据唯一标识符 |
+| name | String | 是 | 新的元数据名称 |
 
 **响应数据:**
 ```json
 {
-  "code": 0,
-  "message": "操作成功"
+  "code": 1,
+  "data": {},
+  "errmsg": null
+}
+```
+
+---
+
+#### 4.5 启用/禁用系统元数据
+
+> 控制指定知识库中系统元数据的启用状态(全局开关)
+
+**接口ID:** `2026052715313907`
+
+**请求参数 (content):**
+```json
+{
+  "dataset_id": "知识库ID",
+  "enable": true
+}
+```
+
+| 参数名 | 类型 | 必填 | 说明 |
+|--------|------|------|------|
+| dataset_id | String | 是 | 知识库唯一标识符 |
+| enable | Boolean | 否 | 是否启用系统元数据,默认true |
+
+**响应数据:**
+```json
+{
+  "code": 1,
+  "data": {},
+  "errmsg": null
+}
+```
+
+---
+
+#### 4.6 为文档设置元数据
+
+> 在文档详情面板编辑元数据后保存
+
+**接口ID:** `2026053015572501`
+
+**请求参数 (content):**
+```json
+{
+  "dataset_id": "知识库ID",
+  "document_id": "文档ID",
+  "partial_update": true,
+  "metadata": [
+    {
+      "id": "元数据ID",
+      "name": "元数据名称",
+      "value": "元数据值"
+    }
+  ]
+}
+```
+
+| 参数名 | 类型 | 必填 | 说明 |
+|--------|------|------|------|
+| dataset_id | String | 是 | 知识库唯一标识符 |
+| document_id | String | 是 | 文档唯一标识符 |
+| partial_update | Boolean | 是 | 是否部分更新,保留未指定字段的现有值 |
+| metadata | Array | 是 | 元数据数组 |
+| metadata[].id | String | 是 | 元数据唯一标识符 |
+| metadata[].name | String | 是 | 元数据名称 |
+| metadata[].value | String | 是 | 元数据值 |
+
+**响应数据:**
+```json
+{
+  "code": 1,
+  "data": {},
+  "errmsg": null
 }
 ```
 
@@ -824,7 +886,13 @@ Content-Type: application/json;charset=UTF-8
 | 9 | `2026052714301012` | queryDocumentIndexingStatus | 索引状态查询 | 已对接 `fetchIndexingStatus()` |
 | 10 | `2026052714301013` | createOrUpdateDocumentByText | 新建/更新文本文档 | 待接入 |
 | 11 | `2026052714301016` | updateDocumentsStatusBatch | 批量更新文档状态 `{ dataset_id, document_ids[], action: 'enable'|'disable' }` | 已对接 `onToggleDocStatus()` |
-| 12 | `2026052715313901` | createDocumentByFile | 文件上传创建文档 | 部分对接(需 OBS attachmentid) |
+|| 12 | `2026052715313901` | createDocumentByFile | 文件上传创建文档 | 部分对接(需 OBS attachmentid) |
+|| 13 | `2026052715313903` | queryMetadataList | 获取元数据列表 | 已对接 `fetchMetadata()` |
+|| 14 | `2026052715313904` | createMetadata | 创建元数据 | 已对接 `onCreateField()` |
+|| 15 | `2026052715313905` | deleteMetadata | 删除元数据 | 已对接 `onDeleteField()` |
+|| 16 | `2026052715313906` | updateMetadata | 更新元数据 | 已对接 `onUpdateField()` |
+|| 17 | `2026052715313907` | toggleSystemMetadata | 启用/禁用系统元数据 | 已对接 `onToggleSystemMeta()` |
+|| 18 | `2026053015572501` | setDocumentMetadata | 设置文档元数据 | 已对接 `onSaveDocMetadata()` |
 
 ---
 
@@ -839,8 +907,6 @@ Content-Type: application/json;charset=UTF-8
 | GAP-03 | 删除知识库 | 右键"删除知识库"无对应接口,当前使用 Mock |
 | GAP-04 | 设置知识库标签 | 后端仅提供 `queryDatasetTags` 查询标签,无绑定/解绑标签接口。前端"设置标签"功能当前使用 Mock |
 | GAP-05 | 检索测试 | 知识库检索测试(输入问题返回匹配片段)无对应接口,当前使用 Mock 数据 |
-| GAP-06 | 元数据管理(整套) | 元数据字段的 CRUD、启用/禁用,后端完全未提供。前端"元数据配置"Tab 全部使用 Mock |
-| GAP-07 | 设置文档元数据 | 文档详情面板中编辑保存元数据键值对,无对应接口 |
 
 ### 二、接口差异 / 字段缺失
 

+ 166 - 29
src/AIManagement/KBM/DocumentDetail.vue

@@ -77,12 +77,18 @@
       <div class="dd-section">
         <h4 class="dd-section-title">
           元数据
-          <el-button type="text" size="mini" @click="editMode = !editMode">{{ editMode ? '取消' : '编辑' }}</el-button>
+          <el-button type="text" size="mini" @click="toggleEdit">{{ 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>
+          <!-- 基础字段(只读) -->
+          <div v-for="(val, key) in systemMeta" :key="'sys-' + key" class="dd-meta-edit-item">
+            <label class="dd-meta-edit-label">{{ systemMetaLabel(key) }}</label>
+            <el-input :value="val" size="small" disabled />
+          </div>
+          <!-- 自定义字段(可编辑) -->
+          <div class="dd-meta-edit-item" v-for="field in customFields" :key="field.id">
+            <label class="dd-meta-edit-label">{{ fieldDisplayName(field) }}</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" />
@@ -104,9 +110,10 @@
         </div>
 
         <div v-else class="dd-meta-view">
+          <!-- 系统字段 -->
           <div
             v-for="(val, key) in (document.metadata || {})"
-            :key="key"
+            :key="'sys-' + key"
             class="dd-meta-item"
           >
             <span class="dd-meta-key">{{ findFieldName(key) || key }}</span>
@@ -118,38 +125,72 @@
               <template v-else>{{ val || '-' }}</template>
             </span>
           </div>
-          <div v-if="!document.metadata || Object.keys(document.metadata).length === 0" class="dd-meta-empty">
+          <!-- 自定义字段 -->
+          <div
+            v-for="field in metadataFields"
+            :key="'custom-' + field.id"
+            class="dd-meta-item"
+          >
+            <span class="dd-meta-key">{{ field.name }}</span>
+            <span class="dd-meta-val">
+              <template v-if="document.metadata && document.metadata[field.name]">
+                {{ document.metadata[field.name] }}
+              </template>
+              <template v-else>
+                <span class="dd-meta-none">未设置</span>
+              </template>
+            </span>
+          </div>
+          <div v-if="!document.metadata || Object.keys(document.metadata).length === 0" class="dd-meta-empty" v-show="metadataFields.length === 0">
             暂无元数据
           </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)">
+          <el-button type="primary" size="small" @click="$emit('download', document)">下载文档</el-button>
+          <el-button type="primary" size="small" @click="onToggleStatus">
             {{ 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>
+          <el-button type="danger" size="small" @click="deleteDialogVisible = true">删除文档</el-button>
         </div>
       </div>
       </template>
+
+      <!-- 删除确认弹窗 -->
+      <el-dialog
+        title="确认删除"
+        :visible.sync="deleteDialogVisible"
+        width="400px"
+        :close-on-click-modal="false"
+        append-to-body
+        @keyup.enter.native="confirmDelete"
+      >
+        <span>确定删除「{{ document ? document.name : '' }}」吗?</span>
+        <span slot="footer">
+          <el-button size="small" @click="deleteDialogVisible = false">取 消</el-button>
+          <el-button size="small" type="danger" :loading="deleteLoading" @click="confirmDelete">删除</el-button>
+        </span>
+      </el-dialog>
+
+      <!-- 启用/禁用确认弹窗 -->
+      <el-dialog
+        title="确认操作"
+        :visible.sync="toggleDialogVisible"
+        width="400px"
+        :close-on-click-modal="false"
+        append-to-body
+        @keyup.enter.native="confirmToggle"
+      >
+        <span>确定{{ document && document.status === 'enabled' ? '禁用' : '启用' }}「{{ document ? document.name : '' }}」吗?</span>
+        <span slot="footer">
+          <el-button size="small" @click="toggleDialogVisible = false">取 消</el-button>
+          <el-button size="small" type="primary" :loading="toggleLoading" @click="confirmToggle">确定</el-button>
+        </span>
+      </el-dialog>
     </div>
   </el-drawer>
 </template>
@@ -165,7 +206,11 @@ export default {
   data() {
     return {
       editMode: false,
-      localMeta: {}
+      localMeta: {},
+      deleteDialogVisible: false,
+      deleteLoading: false,
+      toggleDialogVisible: false,
+      toggleLoading: false
     }
   },
   computed: {
@@ -211,26 +256,118 @@ export default {
         error: '嵌入失败'
       }
       return map[this.document.embedStatus] || '等待处理'
+    },
+    // 提取系统基础元数据(只读)
+    systemMeta() {
+      if (!this.document || !this.document.metadata) return {}
+      const sysKeys = ['文档名称', '上传者', '上传时间', '更新时间', '来源']
+      const sysKeyMap = { 'document_name': '文档名称', 'uploader': '上传者', 'upload_date': '上传时间', 'last_update_date': '更新时间', 'source': '来源' }
+      const result = {}
+      // 遍历metadata,找出系统字段
+      Object.entries(this.document.metadata).forEach(([key, val]) => {
+        // 通过原始key或已映射的中文key判断
+        if (sysKeyMap[key] || sysKeys.includes(key)) {
+          result[key] = val
+        }
+      })
+      return result
+    },
+    // 过滤掉系统字段,只保留自定义字段
+    customFields() {
+      const sysKeyMap = { 'document_name': true, 'uploader': true, 'upload_date': true, 'last_update_date': true, 'source': true }
+      const sysNames = ['文档名称', '上传者', '上传时间', '更新时间', '来源']
+      return this.metadataFields.filter(f => !sysKeyMap[f.key] && !sysNames.includes(f.name))
     }
   },
   watch: {
     document: {
       immediate: true,
-      handler(doc) {
-        if (doc && doc.metadata) {
-          this.localMeta = { ...doc.metadata }
-        } else {
-          this.localMeta = {}
-        }
+      handler() {
         this.editMode = false
       }
     }
   },
   methods: {
+    onToggleStatus() {
+      this.toggleDialogVisible = true
+    },
+
+    confirmToggle() {
+      this.toggleLoading = true
+      this.$emit('toggle-status', this.document.id, () => {
+        setTimeout(() => {
+          this.toggleLoading = false
+          this.toggleDialogVisible = false
+        }, 500)
+      })
+    },
+
+    confirmDelete() {
+      this.deleteLoading = true
+      this.$emit('delete', this.document.id, () => {
+        setTimeout(() => {
+          this.deleteLoading = false
+          this.deleteDialogVisible = false
+          this.$emit('close')
+        }, 500)
+      })
+    },
+
+    toggleEdit() {
+      if (this.editMode) {
+        this.editMode = false
+        return
+      }
+      // 进入编辑模式时,从文档数据初始化 localMeta
+      const docMeta = (this.document && this.document.metadata) || {}
+      const meta = {}
+      // 先用 metadataFields 的 key 初始化
+      this.metadataFields.forEach(field => {
+        meta[field.key] = docMeta[field.key] !== undefined ? docMeta[field.key] : ''
+      })
+      // 保留文档已有的其他元数据
+      Object.keys(docMeta).forEach(key => {
+        if (meta[key] === undefined) meta[key] = docMeta[key]
+      })
+      this.localMeta = meta
+      this.editMode = true
+    },
     findFieldName(key) {
+      const sysMap = {
+        document_name: '文档名称',
+        uploader: '上传者',
+        upload_date: '上传日期',
+        last_update_date: '最后更新日期',
+        source: '来源'
+      }
+      if (sysMap[key]) return sysMap[key]
       const field = this.metadataFields.find(f => f.key === key)
       return field ? field.name : key
     },
+    fieldDisplayName(field) {
+      const sysMap = {
+        document_name: '文档名称',
+        uploader: '上传者',
+        upload_date: '上传日期',
+        last_update_date: '最后更新日期',
+        source: '来源'
+      }
+      return sysMap[field.key] || sysMap[field.name] || field.name
+    },
+    systemMetaLabel(key) {
+      // 如果key已经是中文(被映射过),直接返回
+      const cnKeys = ['文档名称', '上传者', '上传时间', '更新时间', '来源']
+      if (cnKeys.includes(key)) return key
+      // 否则映射原始key
+      const map = {
+        document_name: '文档名称',
+        uploader: '上传者',
+        upload_date: '上传时间',
+        last_update_date: '更新时间',
+        source: '来源'
+      }
+      return map[key] || key
+    },
     saveMeta() {
       if (this.document) {
         this.$emit('save-meta', { docId: this.document.id, metadata: { ...this.localMeta } })

+ 4 - 3
src/AIManagement/KBM/DocumentTable.vue

@@ -56,7 +56,7 @@
       <el-table-column type="selection" width="40" />
       <el-table-column label="文件名" min-width="140" show-overflow-tooltip>
         <template v-slot="{ row }">
-          <span class="dt-file-link" @click="$emit('preview', row)">
+          <span class="dt-file-link" @click="$emit('detail', row)">
             <i :class="fileIcon(row.type)" style="margin-right: 6px;"></i>
             {{ row.name }}
           </span>
@@ -92,9 +92,10 @@
           />
         </template>
       </el-table-column>
-      <el-table-column label="操作" width="150" fixed="right">
+      <el-table-column label="操作" width="180" fixed="right">
         <template v-slot="{ row }">
-          <el-button type="text" size="mini" @click="$emit('download', row)">下载</el-button>
+          <el-button type="text" size="mini" @click="$emit('detail', row)">详情</el-button>
+          <el-button type="text" size="mini" @click="$emit('download', row)" style="margin-left: 10px;">下载</el-button>
           <el-popconfirm
             title="确定删除该文档?"
             confirm-button-text="删除"

+ 91 - 110
src/AIManagement/KBM/MetadataConfig.vue

@@ -2,22 +2,23 @@
   <div class="mc-wrapper">
     <div class="mc-toolbar">
       <el-button type="primary" size="small" icon="el-icon-plus" @click="openCreateDialog">新建字段</el-button>
+      <span class="mc-toolbar-switch">
+        <el-popconfirm
+          :title="`确定${sysMetaEnabled ? '停用' : '启用'}系统元数据吗?`"
+          confirm-button-text="确定"
+          @confirm="confirmToggle"
+        >
+          <el-switch slot="reference" :value="sysMetaEnabled" @change="onToggleSystem" />
+        </el-popconfirm>
+        <span class="mc-toolbar-label">启用系统元数据</span>
+      </span>
     </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>
+          <el-tag size="mini" :type="typeTagType(row.type)">{{ typeLabel(row.type) }}</el-tag>
         </template>
       </el-table-column>
       <el-table-column label="类型" width="90" align="center">
@@ -27,30 +28,30 @@
           </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>
+          <el-button v-if="!row.isSystem" type="text" size="mini" @click="onDeleteClick(row)" style="color: #F56C6C;">删除</el-button>
         </template>
       </el-table-column>
     </el-table>
 
+    <!-- 删除确认弹窗 -->
+    <el-dialog
+      title="确认删除"
+      :visible.sync="deleteDialogVisible"
+      width="400px"
+      :close-on-click-modal="false"
+      append-to-body
+      @keyup.enter.native="confirmDelete"
+    >
+      <span>确定删除「{{ deleteRow ? deleteRow.name : '' }}」字段吗?</span>
+      <span slot="footer">
+        <el-button size="small" @click="deleteDialogVisible = false">取 消</el-button>
+        <el-button size="small" type="danger" :loading="deleteLoading" @click="confirmDelete">删除</el-button>
+      </span>
+    </el-dialog>
+
     <!-- 新建/编辑弹窗 -->
     <el-dialog
       :title="dialogMode === 'create' ? '新建元数据字段' : '编辑元数据字段'"
@@ -63,47 +64,16 @@
         <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-radio-group v-model="form.type" :disabled="dialogMode === 'edit'">
+            <el-radio-button label="text">文本</el-radio-button>
+            <el-radio-button label="date">日期</el-radio-button>
+          </el-radio-group>
         </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>
+        <el-button size="small" type="primary" :loading="submitLoading" @click="submitForm">确 定</el-button>
       </span>
     </el-dialog>
   </div>
@@ -113,7 +83,8 @@
 export default {
   name: 'MetadataConfig',
   props: {
-    fields: { type: Array, default: () => [] }
+    fields: { type: Array, default: () => [] },
+    sysMetaEnabled: { type: Boolean, default: true }
   },
   data() {
     return {
@@ -122,31 +93,57 @@ export default {
       editFieldId: null,
       form: {
         name: '',
-        key: '',
-        type: '',
-        options: []
+        type: 'text'
       },
       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: ''
+      submitLoading: false,
+      deleteDialogVisible: false,
+      deleteLoading: false,
+      deleteRow: null
     }
   },
   methods: {
+    onToggleSystem() {
+      // 不做任何操作,只触发popconfirm弹窗
+    },
+
+    confirmToggle() {
+      this.$emit('toggle-system')
+    },
+
     typeLabel(type) {
-      const map = { text: '文本', select: '下拉单选', multiSelect: '下拉多选', date: '日期' }
+      const map = { text: '文本', date: '日期' }
       return map[type] || type
     },
 
+    typeTagType(type) {
+      const map = { text: 'primary', date: 'danger' }
+      return map[type] || 'info'
+    },
+
+    onDeleteClick(row) {
+      this.deleteRow = row
+      this.deleteDialogVisible = true
+    },
+
+    confirmDelete() {
+      if (!this.deleteRow) return
+      this.deleteLoading = true
+      this.$emit('delete', this.deleteRow.id, () => {
+        setTimeout(() => {
+          this.deleteLoading = false
+          this.deleteDialogVisible = false
+          this.deleteRow = null
+        }, 500)
+      })
+    },
+
     openCreateDialog() {
       this.dialogMode = 'create'
-      this.form = { name: '', key: '', type: '', options: [] }
+      this.form = { name: '', type: 'text' }
       this.dialogVisible = true
       this.$nextTick(() => { this.$refs.fieldForm && this.$refs.fieldForm.clearValidate() })
     },
@@ -156,39 +153,27 @@ export default {
       this.editFieldId = row.id
       this.form = {
         name: row.name,
-        key: row.key,
-        type: row.type,
-        options: [...(row.options || [])]
+        type: row.type
       }
       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
+        this.submitLoading = true
+        const callback = () => {
+          setTimeout(() => {
+            this.submitLoading = false
+            this.dialogVisible = false
+          }, 500)
+        }
         if (this.dialogMode === 'create') {
-          this.$emit('create', { ...this.form })
+          this.$emit('create', { ...this.form }, callback)
         } else {
-          this.$emit('update', { id: this.editFieldId, ...this.form })
+          this.$emit('update', { id: this.editFieldId, ...this.form }, callback)
         }
-        this.dialogVisible = false
       })
     }
   }
@@ -204,23 +189,19 @@ export default {
 
 .mc-toolbar {
   padding: 0 0 12px 0;
+  display: flex;
+  align-items: center;
+  gap: 16px;
 }
 
-.mc-form-tip {
-  font-size: 11px;
-  color: #c0c4cc;
-  display: block;
-  margin-top: 4px;
-}
-
-.mc-option-editor {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 6px;
+.mc-toolbar-switch {
+  display: inline-flex;
   align-items: center;
+  gap: 6px;
 }
 
-.mc-option-input {
-  width: 100px;
+.mc-toolbar-label {
+  font-size: 13px;
+  color: #606266;
 }
 </style>

+ 223 - 39
src/AIManagement/KBM/index.vue

@@ -38,24 +38,38 @@
         </div>
 
         <div class="kb-tabs-wrapper">
-          <DocumentTable
-            :kb="activeKB"
-            :documents="currentDocuments"
-            :total="docTotal"
-            :pageSize="docPageSize"
-            :currentPage="docPage"
-            @upload="openUploadDialog"
-            @detail="openDocumentDetail"
-            @preview="onPreviewFile"
-            @toggle-status="onToggleDocStatus"
-            @delete="onDeleteDocument"
-            @download="onDownloadDocument"
-            @batch-download="onBatchDownload"
-            @refresh="loadDocuments"
-            @search="onDocSearch"
-            @page-change="onDocPageChange"
-            @size-change="onDocSizeChange"
-          />
+          <el-tabs v-model="activeTab" type="card" class="kb-tabs">
+            <el-tab-pane label="文档管理" name="docs">
+              <DocumentTable
+                :kb="activeKB"
+                :documents="currentDocuments"
+                :total="docTotal"
+                :pageSize="docPageSize"
+                :currentPage="docPage"
+                @upload="openUploadDialog"
+                @detail="openDocumentDetail"
+                @preview="onPreviewFile"
+                @toggle-status="onToggleDocStatus"
+                @delete="onDeleteDocument"
+                @download="onDownloadDocument"
+                @batch-download="onBatchDownload"
+                @refresh="loadDocuments"
+                @search="onDocSearch"
+                @page-change="onDocPageChange"
+                @size-change="onDocSizeChange"
+              />
+            </el-tab-pane>
+            <el-tab-pane label="元数据配置" name="metadata">
+              <MetadataConfig
+                :fields="metadataFields"
+                :sysMetaEnabled="sysMetaEnabled"
+                @create="onCreateField"
+                @update="onUpdateField"
+                @delete="onDeleteField"
+                @toggle-system="onToggleSystemMeta"
+              />
+            </el-tab-pane>
+          </el-tabs>
         </div>
       </template>
       <div v-else class="kb-empty-state">
@@ -75,9 +89,12 @@
     <DocumentDetail
       :visible="detailDrawerVisible"
       :document="selectedDocument"
+      :metadataFields="metadataFields"
       @close="detailDrawerVisible = false"
+      @download="onDownloadDocument"
       @toggle-status="onToggleDocStatus"
       @delete="onDeleteDocument"
+      @save-meta="onSaveDocMetadata"
     />
   </div>
 </template>
@@ -87,6 +104,7 @@ 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'
 
 const TAG_COLORS = [
   '#409EFF', '#67C23A', '#E6A23C', '#F56C6C',
@@ -99,7 +117,8 @@ export default {
     KnowledgeBaseList,
     DocumentTable,
     UploadDocument,
-    DocumentDetail
+    DocumentDetail,
+    MetadataConfig
   },
   data() {
     return {
@@ -115,6 +134,8 @@ export default {
       // 全部从接口获取
       knowledgeBases: [],
       allDocuments: [],
+      metadataFields: [],
+      sysMetaEnabled: true,
       docPage: 1,
       docPageSize: 20,
       docTotal: 0,
@@ -169,6 +190,7 @@ export default {
           this.activeKBId = this.knowledgeBases[0].id
           this.fetchDocumentList()
           this.fetchKBTags()
+          this.fetchMetadata()
         }
       }
     },
@@ -201,7 +223,7 @@ export default {
           id: item.id || item.document_id,
           kbId: this.activeKBId,
           name: item.name || item.document_name || item.file_name || '',
-          size: this.formatSize(item.data_source_detail_dict && item.data_source_detail_dict.upload_file && item.data_source_detail_dict.upload_file.size),
+          size: this.formatSize(this.getFileSize(item)),
           type: (item.type || item.file_type || item.name || '').split('.').pop().toLowerCase(),
           version: item.version || 1,
           status: (item.display_status === 'available' ? 'enabled' : item.display_status) || (item.enabled === false ? 'disabled' : 'enabled'),
@@ -210,11 +232,53 @@ export default {
             ? Math.round(item.completed_segments / item.total_segments * 100)
             : (item.embedProgress || 0),
           uploadTime: this.formatTimestamp(item.created_at || item.createdBy),
-          metadata: item.metadata || item.doc_metadata || {}
+          metadata: this.formatMetadata(item.metadata || item.doc_metadata || [])
         })) : []
       }
     },
 
+    // 获取文件大小(兼容不同接口返回结构)
+    getFileSize(item) {
+      return item.data_source_info?.upload_file?.size
+        || item.data_source_detail_dict?.upload_file?.size
+        || item.size
+        || 0
+    },
+
+    // 格式化元数据为可读格式
+    formatMetadata(metadata) {
+      if (!Array.isArray(metadata)) return metadata
+      const result = {}
+      const labelMap = {
+        'document_name': '文档名称',
+        'uploader': '上传者',
+        'upload_date': '上传时间',
+        'last_update_date': '更新时间',
+        'source': '来源'
+      }
+      const sourceMap = {
+        'file_upload': '文件上传',
+        'api': 'API导入',
+        'notion': 'Notion导入',
+        'web': '网页抓取'
+      }
+      metadata.forEach(item => {
+        const key = item.name
+        let value = item.value
+        // 时间戳转日期
+        if (item.type === 'time' && value) {
+          value = this.formatTimestamp(parseFloat(value))
+        }
+        // 来源转中文
+        if (key === 'source' && sourceMap[value]) {
+          value = sourceMap[value]
+        }
+        const label = labelMap[key] || key
+        result[label] = value
+      })
+      return result
+    },
+
     async fetchDocumentDetail(docId) {
       const res = await this.apiCall(2026052714301008, {
         dataset_id: String(this.activeKBId),
@@ -227,13 +291,13 @@ export default {
           this.allDocuments[idx] = {
             ...this.allDocuments[idx],
             name: d.name || d.document_name || this.allDocuments[idx].name,
-            size: this.formatSize(d.data_source_detail_dict && d.data_source_detail_dict.upload_file && d.data_source_detail_dict.upload_file.size) || this.allDocuments[idx].size,
+            size: this.formatSize(this.getFileSize(d)) || this.allDocuments[idx].size,
             type: (d.type || d.file_type || d.name || '').split('.').pop().toLowerCase(),
             version: d.version || this.allDocuments[idx].version,
             status: (d.display_status === 'available' ? 'enabled' : d.display_status) || (d.enabled === false ? 'disabled' : (d.enabled === true ? 'enabled' : 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
+            metadata: this.formatMetadata(d.doc_metadata || d.metadata || this.allDocuments[idx].metadata)
           }
         }
         this.selectedDocument = { ...this.allDocuments[idx] || this.selectedDocument }
@@ -262,6 +326,7 @@ export default {
       this.activeTab = 'docs'
       this.fetchDocumentList()
       this.fetchKBTags()
+      this.fetchMetadata()
     },
 
 
@@ -400,24 +465,14 @@ export default {
       this.selectedDocument = { ...doc }
       this.detailDrawerVisible = true
       this.fetchDocumentDetail(doc.id)
-      // 获取预览地址
-      this.previewUrl = ''
-      const res = await this.apiCall(2026052714301010, {
-        dataset_id: String(this.activeKBId),
-        document_id: String(doc.id)
-      })
-      if (res && res.code === 1 && res.data && res.data.url) {
-        const base = res.difyBaseUrl || ''
-        const previewPath = res.data.url.replace('as_attachment=true', 'as_attachment=false')
-        this.previewUrl = base + previewPath
-      }
     },
 
 
 
-    async onToggleDocStatus(docId) {
-      const doc = this.allDocuments.find(d => d.id === docId)
-      if (!doc) return
+    async onToggleDocStatus(docId, callback) {
+      const idx = this.allDocuments.findIndex(d => d.id === docId)
+      if (idx === -1) return
+      const doc = this.allDocuments[idx]
       const action = doc.status === 'enabled' ? 'disable' : 'enable'
 
       const ok = await this.apiCall(2026052714301016, {
@@ -426,12 +481,19 @@ export default {
         action
       })
       if (ok && ok.code === 1) {
-        doc.status = action === 'enable' ? 'enabled' : 'disabled'
+        const newStatus = action === 'enable' ? 'enabled' : 'disabled'
+        // 用 $set 触发 Vue 2 响应式更新
+        this.$set(this.allDocuments, idx, { ...doc, status: newStatus })
+        // 同步更新详情抽屉的数据
+        if (this.selectedDocument && this.selectedDocument.id === docId) {
+          this.selectedDocument = { ...this.selectedDocument, status: newStatus }
+        }
         this.$message.success(action === 'enable' ? '文档已启用' : '文档已禁用')
       }
+      callback && callback()
     },
 
-    async onDeleteDocument(docId) {
+    async onDeleteDocument(docId, callback) {
       const ok = await this.apiCall(2026052714301009, {
         dataset_id: String(this.activeKBId),
         document_id: String(docId)
@@ -446,6 +508,7 @@ export default {
         if (kb && kb.docCount > 0) kb.docCount--
         this.$message.success('文档已删除')
       }
+      callback && callback()
     },
 
     triggerDownload(url, filename) {
@@ -482,6 +545,127 @@ export default {
       }
     },
 
+    // ====== 元数据 API ======
+    async fetchMetadata() {
+      if (!this.activeKBId) return
+      const [sysRes, customRes] = await Promise.all([
+        this.apiCall(2026052715313903, { dataset_id: String(this.activeKBId), iscustom: false }),
+        this.apiCall(2026052715313903, { dataset_id: String(this.activeKBId), iscustom: true })
+      ])
+      // Dify 类型 → 前端显示类型
+      const difyToFrontend = (t) => {
+        const map = { string: 'text', number: 'number', time: 'date' }
+        return map[t] || 'text'
+      }
+      const fields = []
+      // 系统元数据:data.fields[]
+      if (sysRes && sysRes.code === 1 && sysRes.data) {
+        const list = sysRes.data.fields || []
+        list.forEach(item => {
+          fields.push({
+            id: item.name,
+            name: item.name,
+            key: item.name,
+            type: difyToFrontend(item.type),
+            options: [],
+            isSystem: true,
+            enabled: true
+          })
+        })
+      }
+      // 自定义元数据:data.doc_metadata[]
+      if (customRes && customRes.code === 1 && customRes.data) {
+        this.sysMetaEnabled = customRes.data.built_in_field_enabled !== false
+        const list = customRes.data.doc_metadata || []
+        list.forEach(item => {
+          fields.push({
+            id: item.id,
+            name: item.name,
+            key: item.name,
+            type: difyToFrontend(item.type),
+            options: [],
+            isSystem: false,
+            enabled: true
+          })
+        })
+      }
+      this.metadataFields = fields
+    },
+
+    async onCreateField(form, callback) {
+      // 前端类型 → Dify 类型映射
+      const typeMap = { text: 'string', date: 'time' }
+      const res = await this.apiCall(2026052715313904, {
+        dataset_id: String(this.activeKBId),
+        name: form.name,
+        type: typeMap[form.type] || 'string'
+      })
+      if (res && res.code === 1) {
+        this.$message.success('元数据创建成功')
+        this.fetchMetadata()
+      }
+      callback && callback()
+    },
+
+    async onUpdateField({ id, name }, callback) {
+      const res = await this.apiCall(2026052715313906, {
+        dataset_id: String(this.activeKBId),
+        metadata_id: String(id),
+        name
+      })
+      if (res && res.code === 1) {
+        this.$message.success('元数据更新成功')
+        this.fetchMetadata()
+      }
+      callback && callback()
+    },
+
+    async onDeleteField(id, callback) {
+      const res = await this.apiCall(2026052715313905, {
+        dataset_id: String(this.activeKBId),
+        metadata_id: String(id)
+      })
+      if (res && res.code === 1) {
+        this.$message.success('元数据删除成功')
+        this.fetchMetadata()
+      }
+      callback && callback()
+    },
+
+    async onToggleSystemMeta() {
+      const res = await this.apiCall(2026052715313907, {
+        dataset_id: String(this.activeKBId),
+        enable: !this.sysMetaEnabled
+      })
+      if (res && res.code === 1) {
+        this.$message.success(this.sysMetaEnabled ? '系统元数据已禁用' : '系统元数据已启用')
+        this.fetchMetadata()
+      }
+    },
+
+    async onSaveDocMetadata({ docId, metadata }) {
+      const metadataArr = Object.entries(metadata).map(([key, value]) => {
+        const field = this.metadataFields.find(f => f.key === key)
+        return {
+          id: field ? field.id : key,
+          name: field ? field.name : key,
+          value: Array.isArray(value) ? value.join(',') : (value || '')
+        }
+      }).filter(item => item.value)
+
+      const res = await this.apiCall(2026053015572501, {
+        dataset_id: String(this.activeKBId),
+        document_id: String(docId),
+        partial_update: true,
+        metadata: metadataArr
+      })
+      if (res && res.code === 1) {
+        this.$message.success('元数据保存成功')
+        // 重新获取文档详情,确保数据一致
+        this.fetchDocumentDetail(docId)
+      }
+    },
+
 
 
     // ====== 工具方法 ======