KnowledgeSnippet.vue 33 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021
  1. <template>
  2. <div class="snippet-container">
  3. <div class="header-section">
  4. <div class="title-info">
  5. <h2>知识片段管理</h2>
  6. <p class="subtitle">管理所有知识片段和相关内容</p>
  7. </div>
  8. <div class="action-buttons">
  9. <el-select v-model="queryParams.status" placeholder="所有状态" clearable style="width: 120px; margin-right: 12px" @change="handleSearch">
  10. <el-option label="启用" value="normal" />
  11. <el-option label="禁用" value="disabled" />
  12. </el-select>
  13. <el-select v-model="queryParams.kb" placeholder="所有知识库" clearable style="width: 150px; margin-right: 12px" @change="handleSearch">
  14. <el-option
  15. v-for="item in kbOptions"
  16. :key="item.value"
  17. :label="item.label"
  18. :value="item.value"
  19. >
  20. <span>{{ item.label }}</span>
  21. </el-option>
  22. </el-select>
  23. <el-input
  24. v-model="queryParams.keyword"
  25. placeholder="搜索知识片段..."
  26. style="width: 240px; margin-right: 12px"
  27. clearable
  28. @keyup.enter="handleSearch"
  29. @clear="handleSearch"
  30. />
  31. <el-button type="primary" @click="handleSearch">
  32. <el-icon><Search /></el-icon> 查询
  33. </el-button>
  34. <el-button type="primary" @click="handleAdd">
  35. <el-icon><Plus /></el-icon> 新建片段
  36. </el-button>
  37. </div>
  38. </div>
  39. <div v-if="!queryParams.kb" class="empty-state">
  40. <el-empty description="请先选择一个知识库以查看内容" />
  41. </div>
  42. <el-card v-else class="table-card" shadow="never">
  43. <el-table :data="tableData" v-loading="loading" style="width: 100%" @selection-change="handleSelectionChange">
  44. <el-table-column type="selection" width="55" />
  45. <el-table-column label="文档名称" min-width="150">
  46. <template #default="{ row }">
  47. <div class="doc-name-cell">
  48. <el-icon class="doc-icon"><Document /></el-icon>
  49. <span>{{ row.doc_name }}</span>
  50. </div>
  51. </template>
  52. </el-table-column>
  53. <el-table-column prop="code" label="片段编号" width="160">
  54. <template #default="{ row }">
  55. <span>{{ row.document_id || row.code || '-' }}</span>
  56. </template>
  57. </el-table-column>
  58. <el-table-column label="片段内容" min-width="300">
  59. <template #default="{ row }">
  60. <el-tooltip
  61. effect="dark"
  62. placement="top"
  63. :show-after="500"
  64. :disabled="!row.content || row.content.length <= 200"
  65. >
  66. <template #content>
  67. <div style="max-width: 400px; max-height: 300px; overflow-y: auto; white-space: pre-wrap;">
  68. {{ row.content }}
  69. </div>
  70. </template>
  71. <div class="content-cell">
  72. {{ row.content && row.content.length > 200 ? row.content.substring(0, 200) + '...' : row.content }}
  73. </div>
  74. </el-tooltip>
  75. </template>
  76. </el-table-column>
  77. <el-table-column prop="char_count" label="字符数量" width="100" />
  78. <el-table-column label="元数据信息" min-width="200">
  79. <template #default="{ row }">
  80. <el-tooltip
  81. v-if="formatMetaInfo(row) && formatMetaInfo(row).length > 50"
  82. effect="dark"
  83. :content="formatMetaInfo(row)"
  84. placement="top"
  85. >
  86. <span class="meta-info truncate">{{ formatMetaInfo(row) }}</span>
  87. </el-tooltip>
  88. <span v-else class="meta-info">{{ formatMetaInfo(row) }}</span>
  89. </template>
  90. </el-table-column>
  91. <el-table-column label="标签" min-width="150">
  92. <template #default="{ row }">
  93. <div class="tags-container">
  94. <el-tag
  95. v-for="(tag, index) in parseTags(row.tag_list)"
  96. :key="index"
  97. size="small"
  98. class="snippet-tag"
  99. effect="plain"
  100. >
  101. {{ tag }}
  102. </el-tag>
  103. <span v-if="!row.tag_list" class="no-tags">-</span>
  104. </div>
  105. </template>
  106. </el-table-column>
  107. <el-table-column prop="status" label="状态" width="80">
  108. <template #default="{ row }">
  109. <el-tag :type="row.status === 'normal' ? 'success' : 'info'" effect="plain" class="status-tag">
  110. {{ row.status === 'normal' ? '启用' : '禁用' }}
  111. </el-tag>
  112. </template>
  113. </el-table-column>
  114. <el-table-column prop="created_at" label="创建时间" width="160" />
  115. <el-table-column prop="updated_at" label="修改时间" width="160" />
  116. <el-table-column label="操作" width="120" fixed="right">
  117. <template #default="{ row }">
  118. <el-tooltip content="查看" placement="top">
  119. <el-button link type="primary" @click="handleView(row)">
  120. <el-icon><View /></el-icon>
  121. </el-button>
  122. </el-tooltip>
  123. <el-tooltip content="编辑" placement="top">
  124. <el-button link type="primary" @click="handleEdit(row)">
  125. <el-icon><Edit /></el-icon>
  126. </el-button>
  127. </el-tooltip>
  128. <el-tooltip content="删除" placement="top">
  129. <el-button link type="danger" @click="handleDelete(row)">
  130. <el-icon><Delete /></el-icon>
  131. </el-button>
  132. </el-tooltip>
  133. <el-dropdown trigger="click">
  134. <el-button link type="info">
  135. <el-icon><MoreFilled /></el-icon>
  136. </el-button>
  137. <template #dropdown>
  138. <el-dropdown-menu>
  139. <el-dropdown-item>更多操作</el-dropdown-item>
  140. </el-dropdown-menu>
  141. </template>
  142. </el-dropdown>
  143. </template>
  144. </el-table-column>
  145. </el-table>
  146. <div v-if="queryParams.kb" class="pagination-container">
  147. <div class="pagination-info">
  148. 显示 {{ (pagination.page - 1) * pagination.pageSize + 1 }} 到
  149. {{ Math.min(pagination.page * pagination.pageSize, pagination.total) }} 条,
  150. 共 {{ pagination.total }} 条记录
  151. </div>
  152. <el-pagination
  153. v-model:current-page="pagination.page"
  154. v-model:page-size="pagination.pageSize"
  155. :total="pagination.total"
  156. :page-sizes="[10, 20, 50, 100]"
  157. layout="prev, pager, next, sizes"
  158. @size-change="handleSizeChange"
  159. @current-change="handleCurrentChange"
  160. />
  161. </div>
  162. </el-card>
  163. <el-dialog
  164. v-model="dialogVisible"
  165. :title="dialogType === 'add' ? '新建片段' : '编辑片段'"
  166. width="800px"
  167. @close="resetForm"
  168. class="snippet-dialog"
  169. >
  170. <el-form :model="formData" label-width="100px" class="snippet-form">
  171. <el-row :gutter="20">
  172. <el-col :span="12">
  173. <el-form-item label="所属知识库" required>
  174. <el-select
  175. v-model="formData.collection_name"
  176. placeholder="请选择知识库"
  177. :disabled="dialogType === 'edit'"
  178. @change="handleKbChange"
  179. style="width: 100%"
  180. >
  181. <el-option
  182. v-for="item in kbOptions"
  183. :key="item.value"
  184. :label="item.label"
  185. :value="item.value"
  186. />
  187. </el-select>
  188. </el-form-item>
  189. </el-col>
  190. <el-col :span="12">
  191. <el-form-item label="文档名称" required>
  192. <el-select
  193. v-model="formData.doc_name"
  194. placeholder="请输入文档名称进行搜索"
  195. filterable
  196. remote
  197. :remote-method="loadDocOptions"
  198. :loading="docLoading"
  199. style="width: 100%"
  200. @focus="() => loadDocOptions('')"
  201. @change="handleDocChange"
  202. >
  203. <el-option
  204. v-for="item in docOptions"
  205. :key="item.value"
  206. :label="item.label"
  207. :value="item.value"
  208. />
  209. </el-select>
  210. </el-form-item>
  211. </el-col>
  212. </el-row>
  213. <el-form-item label="父段ID">
  214. <el-input v-model="formData.parent_id" placeholder="可选: 输入父段ID" />
  215. </el-form-item>
  216. <el-form-item label="片段标签">
  217. <el-tree-select
  218. v-model="formData.tag_ids"
  219. :data="tagTreeData"
  220. multiple
  221. :render-after-expand="false"
  222. show-checkbox
  223. check-strictly
  224. node-key="id"
  225. :props="{ label: 'name', children: 'children' }"
  226. placeholder="请选择标签"
  227. style="width: 100%"
  228. />
  229. </el-form-item>
  230. <!-- 动态渲染元数据字段 -->
  231. <template v-if="currentKbSchema.length > 0">
  232. <el-divider content-position="left">元数据信息</el-divider>
  233. <el-row :gutter="20">
  234. <el-col :span="12" v-for="field in currentKbSchema" :key="field.field_en_name">
  235. <el-form-item :label="field.field_zh_name || field.field_en_name">
  236. <el-input
  237. v-if="field.field_type === 'text'"
  238. v-model="formData.custom_fields[field.field_en_name]"
  239. :placeholder="'请输入' + (field.field_zh_name || field.field_en_name)"
  240. />
  241. <el-input-number
  242. v-else-if="field.field_type === 'num'"
  243. v-model="formData.custom_fields[field.field_en_name]"
  244. :placeholder="'请输入' + (field.field_zh_name || field.field_en_name)"
  245. style="width: 100%"
  246. />
  247. <el-input
  248. v-else
  249. v-model="formData.custom_fields[field.field_en_name]"
  250. :placeholder="'请输入' + (field.field_zh_name || field.field_en_name)"
  251. />
  252. </el-form-item>
  253. </el-col>
  254. </el-row>
  255. </template>
  256. <el-form-item label="片段内容" required>
  257. <el-input
  258. v-model="formData.content"
  259. type="textarea"
  260. :rows="15"
  261. placeholder="请输入知识片段的具体内容..."
  262. resize="vertical"
  263. />
  264. </el-form-item>
  265. </el-form>
  266. <template #footer>
  267. <span class="dialog-footer">
  268. <el-button @click="dialogVisible = false" size="large">取消</el-button>
  269. <el-button type="primary" @click="handleSubmit" :loading="submitLoading" size="large">
  270. 确定
  271. </el-button>
  272. </span>
  273. </template>
  274. </el-dialog>
  275. <!-- View Dialog -->
  276. <el-dialog
  277. v-model="viewDialogVisible"
  278. title="知识片段详情"
  279. width="700px"
  280. class="view-dialog"
  281. >
  282. <el-descriptions :column="2" border>
  283. <el-descriptions-item label="片段编号">{{ viewData.code }}</el-descriptions-item>
  284. <el-descriptions-item label="文档名称">{{ viewData.doc_name }}</el-descriptions-item>
  285. <el-descriptions-item label="所属知识库">{{ viewData.collection_name }}</el-descriptions-item>
  286. <el-descriptions-item label="字符数量">{{ viewData.char_count }}</el-descriptions-item>
  287. <el-descriptions-item label="状态">
  288. <el-tag :type="viewData.status === 'normal' ? 'success' : 'info'" effect="plain">
  289. {{ viewData.status === 'normal' ? '启用' : '禁用' }}
  290. </el-tag>
  291. </el-descriptions-item>
  292. <el-descriptions-item label="标签">
  293. <div class="tags-container">
  294. <el-tag
  295. v-for="(tag, index) in parseTags(viewData.tag_list)"
  296. :key="index"
  297. size="small"
  298. class="snippet-tag"
  299. >
  300. {{ tag }}
  301. </el-tag>
  302. </div>
  303. </el-descriptions-item>
  304. <el-descriptions-item label="元数据信息">{{ formatMetaInfo(viewData) }}</el-descriptions-item>
  305. <el-descriptions-item label="创建时间">{{ viewData.created_at || '-' }}</el-descriptions-item>
  306. <el-descriptions-item label="修改时间">{{ viewData.updated_at || '-' }}</el-descriptions-item>
  307. </el-descriptions>
  308. <div class="view-content-section">
  309. <div class="section-title">片段内容</div>
  310. <div class="content-box">
  311. {{ viewData.content }}
  312. </div>
  313. </div>
  314. </el-dialog>
  315. </div>
  316. </template>
  317. <script setup lang="ts">
  318. import { ref, reactive, onMounted } from 'vue'
  319. import { Search, Plus, Document, View, Edit, Delete, MoreFilled, Download, Filter } from '@element-plus/icons-vue'
  320. import { ElMessage, ElMessageBox } from 'element-plus'
  321. import {
  322. getSnippets,
  323. createSnippet,
  324. updateSnippet,
  325. deleteSnippet,
  326. type Snippet
  327. } from '@/api/snippet'
  328. import { getKnowledgeBases, getKnowledgeBaseMetadata, type KnowledgeBase } from '@/api/knowledge-base'
  329. import { tagApi } from '@/api/tag'
  330. // Table Data
  331. const tableData = ref<Snippet[]>([])
  332. const queryParams = reactive({
  333. page: 1,
  334. pageSize: 10,
  335. keyword: '',
  336. status: '',
  337. kb: ''
  338. })
  339. const loading = ref(false)
  340. const pagination = reactive({
  341. page: 1,
  342. pageSize: 10,
  343. total: 0
  344. })
  345. // Dialog
  346. const dialogVisible = ref(false)
  347. const dialogType = ref<'add' | 'edit'>('add')
  348. const submitLoading = ref(false)
  349. const formData = reactive({
  350. id: '',
  351. collection_name: '',
  352. doc_name: '',
  353. selected_doc_id: '',
  354. selected_doc_name: '',
  355. parent_id: '',
  356. content: '',
  357. custom_fields: {} as Record<string, any>,
  358. tag_ids: [] as number[]
  359. })
  360. // 解析标签字符串
  361. const parseTags = (tagListStr: string | undefined) => {
  362. if (!tagListStr) return []
  363. if (tagListStr.includes(',')) {
  364. return tagListStr.split(',').filter(t => t)
  365. }
  366. return [tagListStr]
  367. }
  368. const getTagNamesByIds = (ids: number[]) => {
  369. const names: string[] = []
  370. const findName = (nodes: any[]) => {
  371. for (const node of nodes) {
  372. if (ids.includes(node.id)) {
  373. names.push(node.name)
  374. }
  375. if (node.children) {
  376. findName(node.children)
  377. }
  378. }
  379. }
  380. findName(tagTreeData.value)
  381. return names.join(',')
  382. }
  383. const getTagIdsByNames = (tagStr: string) => {
  384. if (!tagStr) return []
  385. const names = tagStr.split(',').filter(t => t)
  386. const ids: number[] = []
  387. const findId = (nodes: any[]) => {
  388. for (const node of nodes) {
  389. if (names.includes(node.name)) {
  390. ids.push(node.id)
  391. }
  392. if (node.children) {
  393. findId(node.children)
  394. }
  395. }
  396. }
  397. findId(tagTreeData.value)
  398. return ids
  399. }
  400. const docOptions = ref<{label: string, value: string}[]>([])
  401. const docLoading = ref(false)
  402. import { documentApi, type DocumentItem } from '@/api/document'
  403. // ...
  404. const loadDocOptions = async (query: string) => {
  405. // 只有当有输入或者初始化(空字符串)时才搜索
  406. // 这里允许空字符串查询,即显示最近的一些文档
  407. docLoading.value = true
  408. try {
  409. const res = await documentApi.getList({
  410. page: 1,
  411. size: 50, // 限制返回数量,作为搜索结果
  412. keyword: query, // 将输入作为关键字
  413. // whether_to_enter: 1 // 移除此过滤,搜索所有文档
  414. }, true)
  415. if (res.code === 0) {
  416. // 映射为 label (title) 和 value (id)
  417. docOptions.value = res.data.items.map(item => ({
  418. label: item.title,
  419. value: item.id
  420. }))
  421. }
  422. } catch (error) {
  423. console.error("加载文档列表失败", error)
  424. } finally {
  425. docLoading.value = false
  426. }
  427. }
  428. // View Dialog
  429. const viewDialogVisible = ref(false)
  430. const viewData = ref<Snippet>({} as Snippet)
  431. const currentKbSchema = ref<any[]>([]) // 当前选中知识库的自定义Schema
  432. // 当选择知识库变化时,加载对应的 Schema 字段
  433. const handleKbChange = async (collection_name: string) => {
  434. // 清空文档选择
  435. formData.doc_name = ''
  436. formData.selected_doc_id = ''
  437. formData.selected_doc_name = ''
  438. docOptions.value = []
  439. // 自动加载文档列表 (初始加载最近文档)
  440. loadDocOptions('')
  441. // 找到对应的 KB ID
  442. const kb = kbOptions.value.find(k => k.value === collection_name)
  443. if (!kb) {
  444. currentKbSchema.value = []
  445. return
  446. }
  447. // 加载知识库的元数据定义
  448. try {
  449. const res = await getKnowledgeBaseMetadata(kb.id)
  450. if (res.code === 0) {
  451. if (Array.isArray(res.data)) {
  452. currentKbSchema.value = res.data
  453. } else if (res.data && Array.isArray(res.data.metadata_fields)) {
  454. currentKbSchema.value = res.data.metadata_fields
  455. } else {
  456. currentKbSchema.value = []
  457. }
  458. }
  459. } catch (error) {
  460. console.error("加载元数据定义失败", error)
  461. currentKbSchema.value = []
  462. }
  463. // 初始化 custom_fields
  464. formData.custom_fields = {}
  465. }
  466. // Methods
  467. const loadData = async () => {
  468. // 强制检查是否选择了知识库
  469. if (!queryParams.kb) {
  470. tableData.value = []
  471. pagination.total = 0
  472. return
  473. }
  474. loading.value = true
  475. try {
  476. const res = await getSnippets({
  477. page: pagination.page,
  478. page_size: pagination.pageSize,
  479. keyword: queryParams.keyword,
  480. kb: queryParams.kb, // 知识库集合名称
  481. status: queryParams.status // 传递状态参数
  482. })
  483. if (res.code === 0) {
  484. tableData.value = res.data
  485. pagination.total = res.meta?.total || 0
  486. } else {
  487. // 如果返回需要选择知识库的提示,清空列表
  488. tableData.value = []
  489. pagination.total = 0
  490. if (res.message !== "请选择知识库") {
  491. ElMessage.warning(res.message)
  492. }
  493. }
  494. } catch (error) {
  495. console.error(error)
  496. // error handled by interceptor
  497. } finally {
  498. loading.value = false
  499. }
  500. }
  501. // 加载知识库列表供筛选
  502. const kbOptions = ref<{label: string, value: string, id: string}[]>([])
  503. const loadKbOptions = async () => {
  504. try {
  505. const res = await getKnowledgeBases({ page_size: 100 })
  506. if (res.code === 0) {
  507. kbOptions.value = res.data.map((item: any) => ({
  508. label: item.name,
  509. value: item.collection_name,
  510. id: item.id
  511. }))
  512. // 修改为默认不选中(查询所有),直接加载数据
  513. // if (!queryParams.kb) {
  514. // loadData()
  515. // }
  516. // 默认选中第一个知识库(如果存在),并加载数据
  517. if (kbOptions.value.length > 0 && !queryParams.kb) {
  518. queryParams.kb = kbOptions.value[0].value
  519. loadData()
  520. }
  521. }
  522. } catch (error) {
  523. console.error("加载知识库选项失败", error)
  524. }
  525. }
  526. // 标签树数据
  527. const tagTreeData = ref<any[]>([])
  528. // 递归处理树数据,禁用非 label 节点
  529. const processTagTree = (nodes: any[]) => {
  530. return nodes.map(node => {
  531. // 如果不是 label 类型,禁用选择
  532. if (node.type !== 'label') {
  533. node.disabled = true
  534. }
  535. if (node.children && node.children.length > 0) {
  536. node.children = processTagTree(node.children)
  537. }
  538. return node
  539. })
  540. }
  541. // 加载标签树
  542. const loadTagTree = async () => {
  543. try {
  544. const res = await tagApi.getCategoryTree(false) // 不包含禁用
  545. if (res.code === 200) {
  546. tagTreeData.value = processTagTree(res.data)
  547. }
  548. } catch (error) {
  549. console.error("加载标签树失败", error)
  550. }
  551. }
  552. onMounted(() => {
  553. loadKbOptions()
  554. loadTagTree()
  555. })
  556. // Methods
  557. const handleSearch = () => {
  558. pagination.page = 1
  559. loadData()
  560. }
  561. const handleAdd = async () => {
  562. dialogType.value = 'add'
  563. resetForm()
  564. // 如果筛选栏选中了知识库,默认填入并加载元数据
  565. if (queryParams.kb) {
  566. formData.collection_name = queryParams.kb
  567. await handleKbChange(queryParams.kb)
  568. }
  569. dialogVisible.value = true
  570. }
  571. const handleEdit = async (row: Snippet) => {
  572. dialogType.value = 'edit'
  573. formData.id = row.id
  574. formData.collection_name = row.collection_name
  575. // 触发加载 Schema (会重置 doc_name 和 custom_fields)
  576. await handleKbChange(row.collection_name)
  577. // 恢复数据
  578. formData.doc_name = row.doc_name
  579. formData.content = row.content
  580. // 恢复 parent_id
  581. formData.parent_id = row.parent_id || (row.metadata && (row.metadata as any).parent_id) || ''
  582. // 恢复自定义字段
  583. if (row.metadata && typeof row.metadata === 'object') {
  584. formData.custom_fields = { ...row.metadata }
  585. } else {
  586. formData.custom_fields = {}
  587. }
  588. // 回显标签
  589. formData.tag_ids = getTagIdsByNames(row.tag_list || (row as any).tags)
  590. dialogVisible.value = true
  591. }
  592. const handleDocChange = async (val: string) => {
  593. // val 是选中的文档ID
  594. // 找到对应的文档名称用于显示(如果需要的话,但 formData.doc_name 已经绑定了 val 即 ID)
  595. // 这里我们需要注意:formData.doc_name 存储的是 ID 还是 Name?
  596. // 根据用户需求:"新建时选择的文档的id就是对应知识库的schema中的document_id的值"
  597. // 同时也需要显示文档名称
  598. // 实际上,Snippet 结构中 doc_name 应该存名称,document_id 存 ID
  599. // 但前端 el-select v-model 绑定的是 value (ID)
  600. // 我们需要调整 formData 结构或者在提交时处理
  601. // 让我们查找选中的项
  602. const selected = docOptions.value.find(item => item.value === val)
  603. if (selected) {
  604. // 将选中的文档ID保存到 custom_fields.document_id (如果 Schema 中有) 或者单独保存
  605. // 实际上 Snippet Create 接口需要 doc_name 和 document_id
  606. // 我们可以暂时将 ID 存入 formData 的一个临时字段,或者直接修改提交逻辑
  607. formData.selected_doc_id = val
  608. formData.selected_doc_name = selected.label
  609. // 自动填充元数据 (如果当前 KB 有定义的 Schema)
  610. if (currentKbSchema.value.length > 0) {
  611. try {
  612. // 获取文档详情
  613. const res = await documentApi.getDetail(val)
  614. if (res.code === 0 && res.data) {
  615. const doc = res.data
  616. // 定义元数据字段名与文档属性的映射关系
  617. const mapping: Record<string, keyof DocumentItem> = {
  618. 'file_name': 'title',
  619. 'title': 'title',
  620. 'standard_number': 'standard_no',
  621. 'standard_no': 'standard_no',
  622. 'issuing_authority': 'issuing_authority',
  623. 'document_type': 'document_type',
  624. 'professional_field': 'professional_field',
  625. 'validity': 'validity',
  626. 'file_url': 'file_url',
  627. 'plan_type_list': 'plan_category',
  628. 'plan_category': 'plan_category'
  629. }
  630. // 遍历当前 KB 定义的所有字段
  631. currentKbSchema.value.forEach(field => {
  632. const fieldName = field.field_en_name
  633. let docValue: any = null
  634. // 1. 尝试映射匹配
  635. if (mapping[fieldName] && doc[mapping[fieldName]] !== undefined) {
  636. docValue = doc[mapping[fieldName]]
  637. }
  638. // 2. 尝试直接属性名匹配
  639. else if (fieldName in doc) {
  640. docValue = doc[fieldName as keyof DocumentItem]
  641. }
  642. // 3. 特殊处理:层级信息 (hierarchy)
  643. if (fieldName === 'hierarchy') {
  644. const levels = [
  645. doc.level_1_classification,
  646. doc.level_2_classification,
  647. doc.level_3_classification,
  648. doc.level_4_classification
  649. ].filter(l => l) // 过滤掉空值
  650. if (levels.length > 0) {
  651. docValue = levels.join('/')
  652. }
  653. }
  654. // 如果找到了对应的值,且不为空,则填充
  655. if (docValue !== undefined && docValue !== null && docValue !== '') {
  656. formData.custom_fields[fieldName] = docValue
  657. }
  658. })
  659. ElMessage.success('已自动填充文档元数据')
  660. }
  661. } catch (error) {
  662. console.error("自动填充元数据失败", error)
  663. }
  664. }
  665. }
  666. }
  667. const handleSubmit = async () => {
  668. if (!formData.collection_name || !formData.content) {
  669. ElMessage.warning('请填写完整信息')
  670. return
  671. }
  672. const tagListStr = getTagNamesByIds(formData.tag_ids)
  673. submitLoading.value = true
  674. try {
  675. if (dialogType.value === 'add') {
  676. await createSnippet({
  677. collection_name: formData.collection_name,
  678. // 如果用户选择了文档,使用选择的名称;否则(兼容旧逻辑)使用输入值
  679. doc_name: formData.selected_doc_name || formData.doc_name || '手动添加',
  680. content: formData.content,
  681. meta_info: '',
  682. custom_fields: {
  683. ...formData.custom_fields,
  684. parent_id: formData.parent_id,
  685. // 传递 document_id
  686. document_id: formData.selected_doc_id,
  687. tag_list: tagListStr
  688. }
  689. })
  690. ElMessage.success('创建成功')
  691. } else {
  692. // 编辑模式下的处理...
  693. await updateSnippet(formData.id, {
  694. collection_name: formData.collection_name,
  695. doc_name: formData.doc_name, // 编辑时通常不改文档归属,或者需要同样的逻辑
  696. content: formData.content,
  697. custom_fields: {
  698. ...formData.custom_fields,
  699. parent_id: formData.parent_id,
  700. tag_list: tagListStr
  701. }
  702. })
  703. ElMessage.success('更新成功')
  704. }
  705. dialogVisible.value = false
  706. loadData()
  707. } catch (error) {
  708. console.error(error)
  709. } finally {
  710. submitLoading.value = false
  711. }
  712. }
  713. const resetForm = () => {
  714. formData.id = ''
  715. formData.collection_name = ''
  716. formData.doc_name = ''
  717. formData.selected_doc_id = ''
  718. formData.selected_doc_name = ''
  719. formData.parent_id = ''
  720. formData.content = ''
  721. formData.custom_fields = {}
  722. formData.tag_ids = []
  723. docOptions.value = []
  724. currentKbSchema.value = []
  725. }
  726. const handleDelete = (row: Snippet) => {
  727. ElMessageBox.confirm(
  728. '确定要删除该知识片段吗?',
  729. '警告',
  730. {
  731. confirmButtonText: '确定',
  732. cancelButtonText: '取消',
  733. type: 'warning',
  734. }
  735. ).then(async () => {
  736. try {
  737. await deleteSnippet(row.id, row.collection_name)
  738. ElMessage.success('删除成功')
  739. loadData()
  740. } catch (error) {
  741. // handled
  742. }
  743. })
  744. }
  745. const handleView = (row: Snippet) => {
  746. viewData.value = { ...row }
  747. viewDialogVisible.value = true
  748. }
  749. const handleSelectionChange = (val: any) => {
  750. console.log(val)
  751. }
  752. const formatMetaInfo = (row: Snippet) => {
  753. // 优先展示 metadata 字段中的信息
  754. if (row.metadata) {
  755. // 如果是 JSON 对象,尝试格式化展示
  756. if (typeof row.metadata === 'object') {
  757. // 排除一些不需要展示的字段,如 doc_name, file_name, title (因为已有单独列)
  758. const displayParts = []
  759. for (const [key, value] of Object.entries(row.metadata)) {
  760. if (!['doc_name', 'file_name', 'title'].includes(key)) {
  761. // 简单格式化:Key: Value
  762. // 如果 value 也是对象,转字符串
  763. const valStr = typeof value === 'object' ? JSON.stringify(value) : String(value)
  764. displayParts.push(`${key}: ${valStr}`)
  765. }
  766. }
  767. if (displayParts.length > 0) return displayParts.join(' | ')
  768. } else if (typeof row.metadata === 'string') {
  769. try {
  770. // 尝试解析 JSON 字符串
  771. const metaObj = JSON.parse(row.metadata)
  772. const displayParts = []
  773. for (const [key, value] of Object.entries(metaObj)) {
  774. if (!['doc_name', 'file_name', 'title'].includes(key)) {
  775. const valStr = typeof value === 'object' ? JSON.stringify(value) : String(value)
  776. displayParts.push(`${key}: ${valStr}`)
  777. }
  778. }
  779. if (displayParts.length > 0) return displayParts.join(' | ')
  780. } catch (e) {
  781. return String(row.metadata)
  782. }
  783. }
  784. }
  785. // 其次尝试 parent_id
  786. if (row.parent_id && row.parent_id !== '0' && row.parent_id !== '') {
  787. return `ParentID: ${row.parent_id}`
  788. }
  789. // 最后尝试 meta_info (旧字段)
  790. if (row.meta_info && row.meta_info !== "ParentID: -" && row.meta_info !== "ParentID: ") {
  791. return row.meta_info
  792. }
  793. return '-'
  794. }
  795. const handleSizeChange = (val: number) => {
  796. pagination.pageSize = val
  797. loadData()
  798. }
  799. const handleCurrentChange = (val: number) => {
  800. pagination.page = val
  801. loadData()
  802. }
  803. </script>
  804. <style scoped>
  805. .snippet-container {
  806. padding: 20px;
  807. }
  808. .snippet-dialog :deep(.el-dialog__body) {
  809. padding: 20px 30px;
  810. }
  811. .header-section {
  812. display: flex;
  813. justify-content: space-between;
  814. align-items: center;
  815. margin-bottom: 20px;
  816. }
  817. .title-info h2 {
  818. margin: 0;
  819. font-size: 20px;
  820. font-weight: 600;
  821. color: #303133;
  822. }
  823. .subtitle {
  824. margin: 8px 0 0;
  825. color: #909399;
  826. font-size: 14px;
  827. }
  828. .table-card {
  829. border-radius: 8px;
  830. }
  831. .doc-name-cell {
  832. display: flex;
  833. align-items: center;
  834. color: #606266;
  835. }
  836. .doc-icon {
  837. margin-right: 8px;
  838. font-size: 16px;
  839. }
  840. .content-cell {
  841. overflow: hidden;
  842. text-overflow: ellipsis;
  843. /* display: -webkit-box; 移除多行省略,改为单行截断逻辑由JS控制,或者保留样式但内容已截断 */
  844. /* -webkit-line-clamp: 2; */
  845. /* -webkit-box-orient: vertical; */
  846. color: #303133;
  847. line-height: 1.5;
  848. white-space: normal; /* 允许换行,但内容已被截断 */
  849. }
  850. .meta-info {
  851. color: #909399;
  852. font-size: 13px;
  853. display: inline-block;
  854. max-width: 100%;
  855. }
  856. .meta-info.truncate {
  857. overflow: hidden;
  858. text-overflow: ellipsis;
  859. white-space: nowrap;
  860. }
  861. .status-tag {
  862. border-radius: 4px;
  863. padding: 0 12px;
  864. height: 24px;
  865. line-height: 22px;
  866. }
  867. .empty-state {
  868. padding: 40px;
  869. background: #fff;
  870. border-radius: 8px;
  871. }
  872. .pagination-container {
  873. display: flex;
  874. justify-content: space-between;
  875. align-items: center;
  876. margin-top: 20px;
  877. }
  878. .pagination-info {
  879. color: #909399;
  880. font-size: 13px;
  881. }
  882. .view-content-section {
  883. margin-top: 20px;
  884. }
  885. .view-content-section .section-title {
  886. font-weight: 600;
  887. margin-bottom: 10px;
  888. font-size: 15px;
  889. color: #303133;
  890. }
  891. .content-box {
  892. background-color: #f5f7fa;
  893. padding: 15px;
  894. border-radius: 4px;
  895. border: 1px solid #e4e7ed;
  896. color: #606266;
  897. line-height: 1.6;
  898. max-height: 400px;
  899. overflow-y: auto;
  900. white-space: pre-wrap;
  901. word-break: break-all;
  902. }
  903. .snippet-form {
  904. padding-right: 20px;
  905. }
  906. .tags-container {
  907. display: flex;
  908. flex-wrap: wrap;
  909. gap: 4px;
  910. }
  911. .snippet-tag {
  912. margin-right: 0;
  913. }
  914. .no-tags {
  915. color: #909399;
  916. font-size: 12px;
  917. }
  918. </style>