Browse Source

问题解决

linyang 4 tuần trước cách đây
mục cha
commit
475c4a64af

+ 60 - 2
src/views/documents/KnowledgeBase.vue

@@ -201,7 +201,22 @@
                     <el-row :gutter="15">
                         <el-col :span="6">
                             <el-form-item label-width="0" style="margin-bottom: 12px;">
-                                <el-input v-model="field.field_zh_name" placeholder="中文名称 (如: 年份)" />
+                                <el-select 
+                                    v-model="field.field_zh_name" 
+                                    placeholder="中文名称" 
+                                    filterable
+                                    allow-create
+                                    default-first-option
+                                    @change="(val) => handleFieldChange(val, index)"
+                                >
+                                    <el-option 
+                                        v-for="opt in availableFields"
+                                        :key="opt.value"
+                                        :label="opt.label" 
+                                        :value="opt.value" 
+                                        :disabled="getDisabledOptions(index).includes(opt.value)"
+                                    />
+                                </el-select>
                             </el-form-item>
                         </el-col>
                         <el-col :span="6">
@@ -242,7 +257,7 @@
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, onMounted } from 'vue'
+import { ref, reactive, onMounted, computed } from 'vue'
 import { Search, Plus, Collection, View, Edit, Delete, MoreFilled, Refresh } from '@element-plus/icons-vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import type { FormInstance, FormRules } from 'element-plus'
@@ -290,6 +305,29 @@ const formData = reactive({
   metadata_fields: [{ field_zh_name: '', field_en_name: '', field_type: 'text', remark: '' }]
 })
 
+// 可选字段列表
+const availableFields = [
+    { label: "文件名称", value: "文件名称" },
+    { label: "标准编号", value: "标准编号" },
+    { label: "发布单位", value: "发布单位" },
+    { label: "文件类型", value: "文件类型" },
+    { label: "专业领域", value: "专业领域" },
+    { label: "时效性", value: "时效性" },
+    { label: "文档层级信息", value: "文档层级信息" },
+    { label: "文件URL", value: "文件URL" },
+    { label: "施工方案类型适配", value: "施工方案类型适配" }
+]
+
+// 计算每个字段下拉框的禁用选项
+const getDisabledOptions = (currentIndex: number) => {
+    // 获取所有已被选中的字段名
+    const selectedValues = formData.metadata_fields
+        .map((f, idx) => idx !== currentIndex ? f.field_zh_name : null)
+        .filter(v => v !== null && v !== '')
+    
+    return selectedValues
+}
+
 const rules: FormRules = {
   name: [{ required: true, message: '请输入知识库名称', trigger: 'blur' }],
   collection_name: [
@@ -337,6 +375,26 @@ const handleSelectionChange = (val: KnowledgeBase[]) => {
   // console.log(val)
 }
 
+const handleFieldChange = (val: string, index: number) => {
+    // 自动映射中英文名称和类型
+    const map: Record<string, any> = {
+        '文件名称': { en: 'file_name', type: 'text' },
+        '标准编号': { en: 'standard_number', type: 'text' },
+        '发布单位': { en: 'issuing_authority', type: 'text' },
+        '文件类型': { en: 'document_type', type: 'text' },
+        '专业领域': { en: 'professional_field', type: 'text' },
+        '时效性': { en: 'validity', type: 'text' },
+        '文档层级信息': { en: 'hierarchy', type: 'text' },
+        '文件URL': { en: 'file_url', type: 'text' },
+        '施工方案类型适配': { en: 'plan_type_list', type: 'text' }
+    }
+    
+    if (map[val]) {
+        formData.metadata_fields[index].field_en_name = map[val].en
+        formData.metadata_fields[index].field_type = map[val].type
+    }
+}
+
 const handleAdd = () => {
   dialogType.value = 'create'
   formData.id = ''

+ 381 - 60
src/views/documents/KnowledgeSnippet.vue

@@ -37,12 +37,7 @@
       </div>
     </div>
 
-    <!-- 搜索栏下方工具条 -->
-    <div class="toolbar-section">
-        <el-button type="primary" @click="handleAdd">
-            <el-icon><Plus /></el-icon> 新建片段
-        </el-button>
-    </div>
+
 
         <div v-if="!queryParams.kb" class="empty-state">
             <el-empty description="请先选择一个知识库以查看内容" />
@@ -61,17 +56,25 @@
           </template>
         </el-table-column>
 
-        <el-table-column prop="code" label="片段编号" width="160" />
+        <el-table-column prop="code" label="片段编号" width="160">
+            <template #default="{ row }">
+                <span>{{ row.document_id || row.code || '-' }}</span>
+            </template>
+        </el-table-column>
 
         <el-table-column label="片段内容" min-width="300">
             <template #default="{ row }">
                 <el-tooltip
                     effect="dark"
-                    :content="row.content"
                     placement="top"
                     :show-after="500"
                     :disabled="!row.content || row.content.length <= 200"
                 >
+                    <template #content>
+                        <div style="max-width: 400px; max-height: 300px; overflow-y: auto; white-space: pre-wrap;">
+                            {{ row.content }}
+                        </div>
+                    </template>
                     <div class="content-cell">
                         {{ row.content && row.content.length > 200 ? row.content.substring(0, 200) + '...' : row.content }}
                     </div>
@@ -83,7 +86,32 @@
 
         <el-table-column label="元数据信息" min-width="200">
             <template #default="{ row }">
-                <span class="meta-info">{{ formatMetaInfo(row) }}</span>
+                <el-tooltip
+                    v-if="formatMetaInfo(row) && formatMetaInfo(row).length > 50"
+                    effect="dark"
+                    :content="formatMetaInfo(row)"
+                    placement="top"
+                >
+                    <span class="meta-info truncate">{{ formatMetaInfo(row) }}</span>
+                </el-tooltip>
+                <span v-else class="meta-info">{{ formatMetaInfo(row) }}</span>
+            </template>
+        </el-table-column>
+
+        <el-table-column label="标签" min-width="150">
+            <template #default="{ row }">
+                <div class="tags-container">
+                    <el-tag 
+                        v-for="(tag, index) in parseTags(row.tag_list)" 
+                        :key="index"
+                        size="small"
+                        class="snippet-tag"
+                        effect="plain"
+                    >
+                        {{ tag }}
+                    </el-tag>
+                    <span v-if="!row.tag_list" class="no-tags">-</span>
+                </div>
             </template>
         </el-table-column>
 
@@ -177,19 +205,20 @@
                 <el-form-item label="文档名称" required>
                   <el-select 
                     v-model="formData.doc_name" 
-                    placeholder="请选择或输入文档名称" 
+                    placeholder="请输入文档名称进行搜索" 
                     filterable 
-                    allow-create 
-                    default-first-option
+                    remote
+                    :remote-method="loadDocOptions"
                     :loading="docLoading"
                     style="width: 100%"
-                    @focus="loadDocOptions"
+                    @focus="() => loadDocOptions('')"
+                    @change="handleDocChange"
                   >
                     <el-option
                       v-for="item in docOptions"
-                      :key="item"
-                      :label="item"
-                      :value="item"
+                      :key="item.value"
+                      :label="item.label"
+                      :value="item.value"
                     />
                   </el-select>
                 </el-form-item>
@@ -200,6 +229,48 @@
           <el-input v-model="formData.parent_id" placeholder="可选: 输入父段ID" />
         </el-form-item>
 
+        <el-form-item label="片段标签">
+          <el-tree-select
+            v-model="formData.tag_ids"
+            :data="tagTreeData"
+            multiple
+            :render-after-expand="false"
+            show-checkbox
+            check-strictly
+            node-key="id"
+            :props="{ label: 'name', children: 'children' }"
+            placeholder="请选择标签"
+            style="width: 100%"
+          />
+        </el-form-item>
+
+        <!-- 动态渲染元数据字段 -->
+        <template v-if="currentKbSchema.length > 0">
+            <el-divider content-position="left">元数据信息</el-divider>
+            <el-row :gutter="20">
+                <el-col :span="12" v-for="field in currentKbSchema" :key="field.field_en_name">
+                    <el-form-item :label="field.field_zh_name || field.field_en_name">
+                        <el-input 
+                            v-if="field.field_type === 'text'"
+                            v-model="formData.custom_fields[field.field_en_name]" 
+                            :placeholder="'请输入' + (field.field_zh_name || field.field_en_name)"
+                        />
+                        <el-input-number 
+                            v-else-if="field.field_type === 'num'"
+                            v-model="formData.custom_fields[field.field_en_name]" 
+                            :placeholder="'请输入' + (field.field_zh_name || field.field_en_name)"
+                            style="width: 100%"
+                        />
+                        <el-input 
+                            v-else
+                            v-model="formData.custom_fields[field.field_en_name]" 
+                            :placeholder="'请输入' + (field.field_zh_name || field.field_en_name)"
+                        />
+                    </el-form-item>
+                </el-col>
+            </el-row>
+        </template>
+
         <el-form-item label="片段内容" required>
           <el-input
             v-model="formData.content"
@@ -237,6 +308,18 @@
                 {{ viewData.status === 'normal' ? '启用' : '禁用' }}
             </el-tag>
         </el-descriptions-item>
+        <el-descriptions-item label="标签">
+            <div class="tags-container">
+                <el-tag 
+                    v-for="(tag, index) in parseTags(viewData.tag_list)" 
+                    :key="index"
+                    size="small"
+                    class="snippet-tag"
+                >
+                    {{ tag }}
+                </el-tag>
+            </div>
+        </el-descriptions-item>
         <el-descriptions-item label="元数据信息">{{ formatMetaInfo(viewData) }}</el-descriptions-item>
         <el-descriptions-item label="创建时间">{{ viewData.created_at || '-' }}</el-descriptions-item>
         <el-descriptions-item label="修改时间">{{ viewData.updated_at || '-' }}</el-descriptions-item>
@@ -264,6 +347,7 @@ import {
     type Snippet 
 } from '@/api/snippet'
 import { getKnowledgeBases, getKnowledgeBaseMetadata, type KnowledgeBase } from '@/api/knowledge-base'
+import { tagApi } from '@/api/tag'
 
 // Table Data
 const tableData = ref<Snippet[]>([])
@@ -291,35 +375,86 @@ const formData = reactive({
     id: '',
     collection_name: '',
     doc_name: '',
+    selected_doc_id: '',
+    selected_doc_name: '',
     parent_id: '',
     content: '',
-    custom_fields: {} as Record<string, any>
+    custom_fields: {} as Record<string, any>,
+    tag_ids: [] as number[]
 })
 
-const docOptions = ref<string[]>([])
+// 解析标签字符串
+const parseTags = (tagListStr: string | undefined) => {
+    if (!tagListStr) return []
+    if (tagListStr.includes(',')) {
+        return tagListStr.split(',').filter(t => t)
+    }
+    return [tagListStr]
+}
+
+const getTagNamesByIds = (ids: number[]) => {
+    const names: string[] = []
+    const findName = (nodes: any[]) => {
+        for (const node of nodes) {
+            if (ids.includes(node.id)) {
+                names.push(node.name)
+            }
+            if (node.children) {
+                findName(node.children)
+            }
+        }
+    }
+    findName(tagTreeData.value)
+    return names.join(',')
+}
+
+const getTagIdsByNames = (tagStr: string) => {
+    if (!tagStr) return []
+    const names = tagStr.split(',').filter(t => t)
+    const ids: number[] = []
+    
+    const findId = (nodes: any[]) => {
+        for (const node of nodes) {
+            if (names.includes(node.name)) {
+                ids.push(node.id)
+            }
+            if (node.children) {
+                findId(node.children)
+            }
+        }
+    }
+    findId(tagTreeData.value)
+    return ids
+}
+
+const docOptions = ref<{label: string, value: string}[]>([])
 const docLoading = ref(false)
 
-const loadDocOptions = async () => {
-    if (!formData.collection_name) return
+import { documentApi, type DocumentItem } from '@/api/document'
+
+// ...
+
+const loadDocOptions = async (query: string) => {
+    // 只有当有输入或者初始化(空字符串)时才搜索
+    // 这里允许空字符串查询,即显示最近的一些文档
     docLoading.value = true
     try {
-        // 使用一个特殊的查询来获取该知识库下的所有文档名(去重)
-        // 这里暂时使用 getSnippets 模拟,实际上应该有一个 getDocList 接口
-        // 为了简单,我们查询最近的 100 条记录并提取文档名
-        const res = await getSnippets({
+        const res = await documentApi.getList({
             page: 1,
-            page_size: 100,
-            kb: formData.collection_name
-        })
+            size: 50, // 限制返回数量,作为搜索结果
+            keyword: query, // 将输入作为关键字
+            // whether_to_enter: 1 // 移除此过滤,搜索所有文档
+        }, true)
+
         if (res.code === 0) {
-            const names = new Set<string>()
-            res.data.forEach(item => {
-                if (item.doc_name) names.add(item.doc_name)
-            })
-            docOptions.value = Array.from(names)
+            // 映射为 label (title) 和 value (id)
+            docOptions.value = res.data.items.map(item => ({
+                label: item.title,
+                value: item.id
+            }))
         }
     } catch (error) {
-        console.error(error)
+        console.error("加载文档列表失败", error)
     } finally {
         docLoading.value = false
     }
@@ -335,10 +470,12 @@ const currentKbSchema = ref<any[]>([]) // 当前选中知识库的自定义Schem
 const handleKbChange = async (collection_name: string) => {
     // 清空文档选择
     formData.doc_name = ''
+    formData.selected_doc_id = ''
+    formData.selected_doc_name = ''
     docOptions.value = []
     
-    // 自动加载文档列表
-    loadDocOptions()
+    // 自动加载文档列表 (初始加载最近文档)
+    loadDocOptions('')
 
     // 找到对应的 KB ID
     const kb = kbOptions.value.find(k => k.value === collection_name)
@@ -347,10 +484,24 @@ const handleKbChange = async (collection_name: string) => {
         return
     }
     
-    // 由于后端 Schema 已固定,不再动态加载自定义字段
-    currentKbSchema.value = []
+    // 加载知识库的元数据定义
+    try {
+        const res = await getKnowledgeBaseMetadata(kb.id)
+        if (res.code === 0) {
+             if (Array.isArray(res.data)) {
+                 currentKbSchema.value = res.data
+             } else if (res.data && Array.isArray(res.data.metadata_fields)) {
+                 currentKbSchema.value = res.data.metadata_fields
+             } else {
+                 currentKbSchema.value = []
+             }
+        }
+    } catch (error) {
+        console.error("加载元数据定义失败", error)
+        currentKbSchema.value = []
+    }
     
-    // 初始化 custom_fields (如果需要的话,但现在没有动态字段了)
+    // 初始化 custom_fields
     formData.custom_fields = {}
 }
 
@@ -418,8 +569,39 @@ const loadKbOptions = async () => {
     }
 }
 
+// 标签树数据
+const tagTreeData = ref<any[]>([])
+
+// 递归处理树数据,禁用非 label 节点
+const processTagTree = (nodes: any[]) => {
+    return nodes.map(node => {
+        // 如果不是 label 类型,禁用选择
+        if (node.type !== 'label') {
+            node.disabled = true
+        }
+        
+        if (node.children && node.children.length > 0) {
+            node.children = processTagTree(node.children)
+        }
+        return node
+    })
+}
+
+// 加载标签树
+const loadTagTree = async () => {
+    try {
+        const res = await tagApi.getCategoryTree(false) // 不包含禁用
+        if (res.code === 200) {
+            tagTreeData.value = processTagTree(res.data)
+        }
+    } catch (error) {
+        console.error("加载标签树失败", error)
+    }
+}
+
 onMounted(() => {
     loadKbOptions()
+    loadTagTree()
 })
 
 // Methods
@@ -428,62 +610,166 @@ const handleSearch = () => {
     loadData()
 }
 
-const handleAdd = () => {
+const handleAdd = async () => {
     dialogType.value = 'add'
     resetForm()
-    // 如果筛选栏选中了知识库,默认填入
+    // 如果筛选栏选中了知识库,默认填入并加载元数据
     if (queryParams.kb) {
         formData.collection_name = queryParams.kb
+        await handleKbChange(queryParams.kb)
     }
     dialogVisible.value = true
 }
 
-const handleEdit = (row: Snippet) => {
+const handleEdit = async (row: Snippet) => {
     dialogType.value = 'edit'
     formData.id = row.id
     formData.collection_name = row.collection_name
+    
+    // 触发加载 Schema (会重置 doc_name 和 custom_fields)
+    await handleKbChange(row.collection_name)
+
+    // 恢复数据
     formData.doc_name = row.doc_name
     formData.content = row.content
-    formData.parent_id = '' // 列表中没有返回 parent_id,暂时留空或需要额外请求获取
-    formData.custom_fields = {} 
     
-    // 触发加载文档列表
-    loadDocOptions()
+    // 恢复 parent_id
+    formData.parent_id = row.parent_id || (row.metadata && (row.metadata as any).parent_id) || ''
     
-    // 触发加载 Schema
-    handleKbChange(row.collection_name)
+    // 恢复自定义字段
+    if (row.metadata && typeof row.metadata === 'object') {
+        formData.custom_fields = { ...row.metadata }
+    } else {
+        formData.custom_fields = {}
+    }
+
+    // 回显标签
+    formData.tag_ids = getTagIdsByNames(row.tag_list || (row as any).tags)
     
     dialogVisible.value = true
 }
 
+const handleDocChange = async (val: string) => {
+    // val 是选中的文档ID
+    // 找到对应的文档名称用于显示(如果需要的话,但 formData.doc_name 已经绑定了 val 即 ID)
+    // 这里我们需要注意:formData.doc_name 存储的是 ID 还是 Name?
+    // 根据用户需求:"新建时选择的文档的id就是对应知识库的schema中的document_id的值"
+    // 同时也需要显示文档名称
+    
+    // 实际上,Snippet 结构中 doc_name 应该存名称,document_id 存 ID
+    // 但前端 el-select v-model 绑定的是 value (ID)
+    
+    // 我们需要调整 formData 结构或者在提交时处理
+    // 让我们查找选中的项
+    const selected = docOptions.value.find(item => item.value === val)
+    if (selected) {
+        // 将选中的文档ID保存到 custom_fields.document_id (如果 Schema 中有) 或者单独保存
+        // 实际上 Snippet Create 接口需要 doc_name 和 document_id
+        // 我们可以暂时将 ID 存入 formData 的一个临时字段,或者直接修改提交逻辑
+        formData.selected_doc_id = val
+        formData.selected_doc_name = selected.label
+        
+        // 自动填充元数据 (如果当前 KB 有定义的 Schema)
+        if (currentKbSchema.value.length > 0) {
+            try {
+                // 获取文档详情
+                const res = await documentApi.getDetail(val)
+                if (res.code === 0 && res.data) {
+                    const doc = res.data
+                    
+                    // 定义元数据字段名与文档属性的映射关系
+                    const mapping: Record<string, keyof DocumentItem> = {
+                        'file_name': 'title',
+                        'title': 'title',
+                        'standard_number': 'standard_no',
+                        'standard_no': 'standard_no',
+                        'issuing_authority': 'issuing_authority',
+                        'document_type': 'document_type',
+                        'professional_field': 'professional_field',
+                        'validity': 'validity',
+                        'file_url': 'file_url',
+                        'plan_type_list': 'plan_category',
+                        'plan_category': 'plan_category'
+                    }
+                    
+                    // 遍历当前 KB 定义的所有字段
+                    currentKbSchema.value.forEach(field => {
+                        const fieldName = field.field_en_name
+                        let docValue: any = null
+                        
+                        // 1. 尝试映射匹配
+                        if (mapping[fieldName] && doc[mapping[fieldName]] !== undefined) {
+                            docValue = doc[mapping[fieldName]]
+                        } 
+                        // 2. 尝试直接属性名匹配
+                        else if (fieldName in doc) {
+                            docValue = doc[fieldName as keyof DocumentItem]
+                        }
+                        
+                        // 3. 特殊处理:层级信息 (hierarchy)
+                        if (fieldName === 'hierarchy') {
+                            const levels = [
+                                doc.level_1_classification, 
+                                doc.level_2_classification, 
+                                doc.level_3_classification, 
+                                doc.level_4_classification
+                            ].filter(l => l) // 过滤掉空值
+                            
+                            if (levels.length > 0) {
+                                docValue = levels.join('/')
+                            }
+                        }
+
+                        // 如果找到了对应的值,且不为空,则填充
+                        if (docValue !== undefined && docValue !== null && docValue !== '') {
+                            formData.custom_fields[fieldName] = docValue
+                        }
+                    })
+                    ElMessage.success('已自动填充文档元数据')
+                }
+            } catch (error) {
+                console.error("自动填充元数据失败", error)
+            }
+        }
+    }
+}
+
 const handleSubmit = async () => {
     if (!formData.collection_name || !formData.content) {
         ElMessage.warning('请填写完整信息')
         return
     }
     
+    const tagListStr = getTagNamesByIds(formData.tag_ids)
+
     submitLoading.value = true
     try {
         if (dialogType.value === 'add') {
             await createSnippet({
                 collection_name: formData.collection_name,
-                doc_name: formData.doc_name || '手动添加',
+                // 如果用户选择了文档,使用选择的名称;否则(兼容旧逻辑)使用输入值
+                doc_name: formData.selected_doc_name || formData.doc_name || '手动添加',
                 content: formData.content,
                 meta_info: '',
                 custom_fields: {
                     ...formData.custom_fields,
-                    parent_id: formData.parent_id // 传递父段ID
+                    parent_id: formData.parent_id,
+                    // 传递 document_id
+                    document_id: formData.selected_doc_id,
+                    tag_list: tagListStr
                 }
             })
             ElMessage.success('创建成功')
         } else {
+             // 编辑模式下的处理...
             await updateSnippet(formData.id, {
                 collection_name: formData.collection_name,
-                doc_name: formData.doc_name,
+                doc_name: formData.doc_name, // 编辑时通常不改文档归属,或者需要同样的逻辑
                 content: formData.content,
                 custom_fields: {
                     ...formData.custom_fields,
-                    parent_id: formData.parent_id
+                    parent_id: formData.parent_id,
+                    tag_list: tagListStr
                 }
             })
             ElMessage.success('更新成功')
@@ -501,9 +787,12 @@ const resetForm = () => {
     formData.id = ''
     formData.collection_name = ''
     formData.doc_name = ''
+    formData.selected_doc_id = ''
+    formData.selected_doc_name = ''
     formData.parent_id = ''
     formData.content = ''
     formData.custom_fields = {}
+    formData.tag_ids = []
     docOptions.value = []
     currentKbSchema.value = []
 }
@@ -546,12 +835,28 @@ const formatMetaInfo = (row: Snippet) => {
              const displayParts = []
              for (const [key, value] of Object.entries(row.metadata)) {
                  if (!['doc_name', 'file_name', 'title'].includes(key)) {
-                     displayParts.push(`${key}: ${value}`)
+                     // 简单格式化:Key: Value
+                     // 如果 value 也是对象,转字符串
+                     const valStr = typeof value === 'object' ? JSON.stringify(value) : String(value)
+                     displayParts.push(`${key}: ${valStr}`)
                  }
              }
              if (displayParts.length > 0) return displayParts.join(' | ')
-        } else {
-            return String(row.metadata)
+        } else if (typeof row.metadata === 'string') {
+            try {
+                // 尝试解析 JSON 字符串
+                const metaObj = JSON.parse(row.metadata)
+                const displayParts = []
+                for (const [key, value] of Object.entries(metaObj)) {
+                    if (!['doc_name', 'file_name', 'title'].includes(key)) {
+                        const valStr = typeof value === 'object' ? JSON.stringify(value) : String(value)
+                        displayParts.push(`${key}: ${valStr}`)
+                    }
+                }
+                if (displayParts.length > 0) return displayParts.join(' | ')
+            } catch (e) {
+                return String(row.metadata)
+            }
         }
     }
 
@@ -608,12 +913,7 @@ const handleCurrentChange = (val: number) => {
   font-size: 14px;
 }
 
-.toolbar-section {
-    display: flex;
-    align-items: center;
-    margin-bottom: 16px;
-    gap: 12px;
-}
+
 
 .table-card {
   border-radius: 8px;
@@ -644,6 +944,14 @@ const handleCurrentChange = (val: number) => {
 .meta-info {
     color: #909399;
     font-size: 13px;
+    display: inline-block;
+    max-width: 100%;
+}
+
+.meta-info.truncate {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
 }
 
 .status-tag {
@@ -697,4 +1005,17 @@ const handleCurrentChange = (val: number) => {
 .snippet-form {
     padding-right: 20px;
 }
+
+.tags-container {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 4px;
+}
+.snippet-tag {
+    margin-right: 0;
+}
+.no-tags {
+    color: #909399;
+    font-size: 12px;
+}
 </style>

+ 78 - 7
src/views/documents/SearchEngine.vue

@@ -69,8 +69,9 @@
 
         <!-- 高级过滤区域 (仅在高级模式显示) -->
         <div v-if="searchForm.mode === 'advanced'" class="advanced-filter-area">
+            <!-- 顶部操作栏:标题 + 添加按钮 -->
             <div class="filter-header">
-                <span class="filter-title">元数据过滤条件</span>
+                <div class="filter-section-title">元数据过滤条件</div>
                 <el-button type="primary" link :icon="Plus" size="small" @click="addFilter">添加条件</el-button>
             </div>
             
@@ -114,6 +115,31 @@
                     </el-row>
                 </div>
             </div>
+
+            <!-- 文档名称过滤 -->
+            <div class="filter-header" style="margin-top: 20px;">
+                <div class="filter-section-title">文档名称过滤</div>
+            </div>
+            <div class="doc-filter-row">
+                <el-select 
+                    v-model="searchForm.doc_names" 
+                    placeholder="请选择文档进行过滤(可多选)" 
+                    multiple
+                    filterable
+                    remote
+                    :remote-method="loadDocOptions"
+                    :loading="docLoading"
+                    style="width: 100%"
+                    @focus="() => loadDocOptions('')"
+                >
+                    <el-option
+                        v-for="item in docOptions"
+                        :key="item.value"
+                        :label="item.label"
+                        :value="item.value"
+                    />
+                </el-select>
+            </div>
         </div>
       </div>
     </el-card>
@@ -199,6 +225,7 @@ import { Search, Plus, Delete } from '@element-plus/icons-vue'
 import { ElMessage } from 'element-plus'
 import { getKnowledgeBases, getKnowledgeBaseMetadata, type KnowledgeBase } from '@/api/knowledge-base'
 import { searchKnowledgeBase, type KBSearchResultItem } from '@/api/search-engine'
+import { documentApi } from '@/api/document'
 
 // Data
 const kbList = ref<KnowledgeBase[]>([])
@@ -214,9 +241,37 @@ const searchForm = reactive({
   kb_id: '',
   mode: 'simple',
   filters: [{ field: '', value: '' }] as { field: string, value: string }[],
-  query: ''
+  query: '',
+  doc_names: [] as string[] // 文档名称过滤
 })
 
+const docOptions = ref<{label: string, value: string}[]>([])
+const docLoading = ref(false)
+
+const loadDocOptions = async (query: string) => {
+    docLoading.value = true
+    try {
+        const res = await documentApi.getList({
+            page: 1,
+            size: 50, 
+            keyword: query,
+        }, true)
+
+        if (res.code === 0) {
+            // 映射为 label (title) 和 value (title) - 这里我们用 title 作为过滤值
+            // 因为 Milvus 中存的是 doc_name/title,而不是 ID
+            docOptions.value = res.data.items.map(item => ({
+                label: item.title,
+                value: item.title 
+            }))
+        }
+    } catch (error) {
+        console.error("加载文档列表失败", error)
+    } finally {
+        docLoading.value = false
+    }
+}
+
 const detailVisible = ref(false)
 const currentDetail = ref<KBSearchResultItem | null>(null)
 
@@ -262,8 +317,14 @@ const handleKbChange = async () => {
             try {
                 const res = await getKnowledgeBaseMetadata(selectedKb.id)
                 // 确保后端返回的数据格式正确
-                if (res.code === 0 && Array.isArray(res.data)) {
-                    metadataFields.value = res.data
+                if (res.code === 0) {
+                    if (Array.isArray(res.data)) {
+                        metadataFields.value = res.data
+                    } else if (res.data && Array.isArray(res.data.metadata_fields)) {
+                        metadataFields.value = res.data.metadata_fields
+                    } else {
+                        metadataFields.value = []
+                    }
                 } else {
                     console.warn("Invalid metadata response:", res)
                     metadataFields.value = []
@@ -299,15 +360,25 @@ const handleSearch = async () => {
     const validFilters = searchForm.mode === 'advanced' 
         ? searchForm.filters.filter(f => f.field && f.value)
         : []
+    
+    // 将文档过滤也作为一种特殊的 filter 加入
+    if (searchForm.mode === 'advanced' && searchForm.doc_names.length > 0) {
+        // 对于多选文档,我们需要构建 OR 逻辑,但目前的 filters 结构是 AND
+        // 我们可以约定 field 为 "doc_name_in",value 为逗号分隔的字符串
+        validFilters.push({
+            field: 'doc_name_in',
+            value: JSON.stringify(searchForm.doc_names)
+        })
+    }
         
     const res = await searchKnowledgeBase({
         kb_id: searchForm.kb_id,
         query: searchForm.query || '', 
         // 传递 filters 数组 (需要后端支持,或者我们在前端做兼容处理)
         // 为了兼容旧接口,如果只有一个 filter,传旧参数;如果有多个,传新参数 filters
-        metadata_field: validFilters.length === 1 ? validFilters[0].field : undefined,
-        metadata_value: validFilters.length === 1 ? validFilters[0].value : undefined,
-        filters: validFilters.length > 1 ? validFilters : undefined,
+        metadata_field: validFilters.length === 1 && validFilters[0].field !== 'doc_name_in' ? validFilters[0].field : undefined,
+        metadata_value: validFilters.length === 1 && validFilters[0].field !== 'doc_name_in' ? validFilters[0].value : undefined,
+        filters: validFilters.length > 0 ? validFilters : undefined,
         
         top_k: pageSize.value, 
         page: currentPage.value,