SearchEngine.vue 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796
  1. <template>
  2. <div class="search-engine-container">
  3. <div class="header-section">
  4. <div class="title-info">
  5. <h2>检索引擎</h2>
  6. <p class="subtitle">检索知识库中的内容</p>
  7. </div>
  8. <div class="header-right">
  9. <!-- 可以在这里放用户信息或其他按钮 -->
  10. </div>
  11. </div>
  12. <!-- Search Form Card -->
  13. <el-card class="search-card" shadow="never">
  14. <div class="search-form-container">
  15. <!-- 顶部行:知识库 + 检索模式 + 关键字 -->
  16. <div class="search-form-row main-row">
  17. <div class="form-item kb-select">
  18. <div class="label">知识库</div>
  19. <el-select
  20. v-model="searchForm.kb_id"
  21. placeholder="选择知识库"
  22. style="width: 100%"
  23. clearable
  24. @change="handleKbChange"
  25. >
  26. <el-option
  27. v-for="kb in kbList"
  28. :key="kb.id"
  29. :label="kb.name"
  30. :value="kb.collection_name_children || kb.collection_name_parent || kb.collection_name"
  31. >
  32. <span>{{ kb.name }}</span>
  33. </el-option>
  34. </el-select>
  35. </div>
  36. <div class="form-item search-mode">
  37. <div class="label">检索模式</div>
  38. <el-select
  39. v-model="searchForm.mode"
  40. placeholder="请选择"
  41. style="width: 100%"
  42. @change="handleModeChange"
  43. >
  44. <el-option label="简单模式" value="simple" />
  45. <el-option label="高级模式" value="advanced" />
  46. </el-select>
  47. </div>
  48. <div class="form-item keyword-input">
  49. <div class="label">检索关键字</div>
  50. <el-input
  51. v-model="searchForm.query"
  52. placeholder="请输入检索内容..."
  53. clearable
  54. @keyup.enter="handleSearch"
  55. :disabled="!searchForm.kb_id"
  56. >
  57. <template #append>
  58. <el-button :icon="Search" @click="handleSearch" :disabled="!searchForm.kb_id">
  59. 检索
  60. </el-button>
  61. </template>
  62. </el-input>
  63. </div>
  64. </div>
  65. <!-- 文档范围过滤 (简单模式可见) -->
  66. <div class="search-form-row secondary-row" v-if="searchForm.kb_id">
  67. <div class="form-item doc-filter">
  68. <div class="label">文档范围过滤</div>
  69. <el-select
  70. v-model="searchForm.doc_names"
  71. placeholder="默认检索全部文档,可选择特定文档进行范围限定"
  72. multiple
  73. filterable
  74. remote
  75. clearable
  76. collapse-tags
  77. collapse-tags-tooltip
  78. :remote-method="loadDocOptions"
  79. :loading="docLoading"
  80. style="width: 100%"
  81. @focus="() => loadDocOptions('')"
  82. >
  83. <el-option
  84. v-for="item in docOptions"
  85. :key="item.value"
  86. :label="item.label"
  87. :value="item.value"
  88. />
  89. </el-select>
  90. </div>
  91. </div>
  92. <!-- 高级过滤区域 (仅在高级模式显示) -->
  93. <div v-if="searchForm.mode === 'advanced'" class="advanced-filter-area">
  94. <!-- 顶部操作栏:标题 + 添加按钮 -->
  95. <div class="filter-header">
  96. <div class="filter-section-title">元数据过滤条件</div>
  97. <el-button type="primary" link :icon="Plus" size="small" @click="addFilter">添加条件</el-button>
  98. </div>
  99. <div class="filter-list">
  100. <div v-for="(filter, index) in searchForm.filters" :key="index" class="filter-item">
  101. <el-row :gutter="10" align="middle">
  102. <el-col :span="10">
  103. <el-select
  104. v-model="filter.field"
  105. placeholder="选择字段"
  106. style="width: 100%"
  107. clearable
  108. :disabled="!searchForm.kb_id"
  109. >
  110. <el-option
  111. v-for="field in metadataFields"
  112. :key="field.id"
  113. :label="field.field_zh_name + ' (' + field.field_en_name + ')'"
  114. :value="field.field_en_name"
  115. />
  116. </el-select>
  117. </el-col>
  118. <el-col :span="1" style="text-align: center; color: #909399;">=</el-col>
  119. <el-col :span="11">
  120. <el-input
  121. v-model="filter.value"
  122. placeholder="输入值"
  123. :disabled="!filter.field"
  124. @keyup.enter="handleSearch"
  125. />
  126. </el-col>
  127. <el-col :span="2" style="text-align: center;">
  128. <el-button
  129. v-if="searchForm.filters.length > 1"
  130. type="danger"
  131. link
  132. :icon="Delete"
  133. @click="removeFilter(index)"
  134. />
  135. </el-col>
  136. </el-row>
  137. </div>
  138. </div>
  139. </div>
  140. </div>
  141. </el-card>
  142. <!-- Results Table Card -->
  143. <el-card class="result-card" shadow="never" v-if="hasSearched">
  144. <el-table :data="tableData" v-loading="loading" style="width: 100%">
  145. <el-table-column label="知识库" width="150" show-overflow-tooltip>
  146. <template #default="{ row }">
  147. <span>{{ getKbName(row.kb_name) }}</span>
  148. </template>
  149. </el-table-column>
  150. <el-table-column label="文档名称" width="200" show-overflow-tooltip>
  151. <template #default="{ row }">
  152. <span v-if="docNamesMap[row.document_id]">{{ docNamesMap[row.document_id] }}</span>
  153. <span v-else>{{ row.doc_name }}</span>
  154. </template>
  155. </el-table-column>
  156. <el-table-column prop="content" label="检索片段内容" min-width="400">
  157. <template #default="{ row }">
  158. <el-tooltip
  159. effect="light"
  160. placement="top-start"
  161. :disabled="!row.content || row.content.length <= 200"
  162. >
  163. <template #content>
  164. <div class="tooltip-content-scroll" v-html="highlightKeyword(row.content)"></div>
  165. </template>
  166. <div class="snippet-content" v-html="highlightKeyword(truncateText(row.content, 200))"></div>
  167. </el-tooltip>
  168. </template>
  169. </el-table-column>
  170. <el-table-column label="元数据信息" width="250" show-overflow-tooltip>
  171. <template #default="{ row }">
  172. <span>{{ formatMetaInfo(row) }}</span>
  173. </template>
  174. </el-table-column>
  175. <el-table-column prop="score" label="相似度" width="100">
  176. <template #default="{ row }">
  177. <el-tag :type="getScoreType(row.score)">{{ row.score }}%</el-tag>
  178. </template>
  179. </el-table-column>
  180. <el-table-column label="操作" width="100" fixed="right">
  181. <template #default="{ row }">
  182. <el-button link type="primary" @click="handleDetail(row)">
  183. 查看详情
  184. </el-button>
  185. </template>
  186. </el-table-column>
  187. </el-table>
  188. <div class="pagination-container" v-if="total > 0">
  189. <div class="pagination-info">
  190. 显示 {{ (currentPage - 1) * pageSize + 1 }} 到
  191. {{ Math.min(currentPage * pageSize, total) }} 条,
  192. 共 {{ total }} 条记录
  193. </div>
  194. <el-pagination
  195. v-model:current-page="currentPage"
  196. v-model:page-size="pageSize"
  197. :total="total"
  198. :page-sizes="[10, 20, 50]"
  199. layout="prev, pager, next, sizes"
  200. @size-change="handleSearch"
  201. @current-change="handleSearch"
  202. />
  203. </div>
  204. </el-card>
  205. <div v-else-if="!searchForm.kb_id" class="empty-placeholder">
  206. <el-empty description="请选择知识库并输入关键字进行检索" />
  207. </div>
  208. <!-- Detail Dialog -->
  209. <el-dialog v-model="detailVisible" title="片段详情" width="800px">
  210. <div class="detail-content">
  211. <div class="detail-item">
  212. <span class="label">知识库:</span>
  213. <span>{{ getKbName(currentDetail?.kb_name) }}</span>
  214. </div>
  215. <div class="detail-item">
  216. <span class="label">文档:</span>
  217. <span>{{ currentDetail?.doc_name }}</span>
  218. </div>
  219. <div class="detail-item">
  220. <span class="label">相似度:</span>
  221. <span>{{ currentDetail?.score }}%</span>
  222. </div>
  223. <div class="view-content-section" v-if="currentDetail?.parent_id && currentDetail?.parent_id !== '0'">
  224. <div class="section-title">
  225. <el-icon><Connection /></el-icon> 父段内容
  226. </div>
  227. <div v-loading="parentLoading">
  228. <el-tabs v-if="parentSegments.length > 1" v-model="activeParentTab" type="card">
  229. <el-tab-pane
  230. v-for="(seg, idx) in parentSegments"
  231. :key="seg.id || idx"
  232. :label="`父段片段 ${idx + 1}`"
  233. :name="String(idx)"
  234. >
  235. <div class="content-box parent-content">{{ seg.content || '暂无内容' }}</div>
  236. </el-tab-pane>
  237. </el-tabs>
  238. <div v-else class="content-box parent-content">{{ parentSegments[0]?.content || '暂无内容' }}</div>
  239. </div>
  240. </div>
  241. <div class="detail-item full-content">
  242. <span class="label">完整内容:</span>
  243. <div class="text-box">{{ currentDetail?.content }}</div>
  244. </div>
  245. <div class="detail-item">
  246. <span class="label">元数据:</span>
  247. <span>{{ formatMetaInfo(currentDetail) }}</span>
  248. </div>
  249. </div>
  250. </el-dialog>
  251. </div>
  252. </template>
  253. <script setup lang="ts">
  254. import { ref, reactive, onMounted } from 'vue'
  255. import { Search, Plus, Delete, Connection } from '@element-plus/icons-vue'
  256. import { ElMessage } from 'element-plus'
  257. import { getKnowledgeBases, getKnowledgeBaseMetadata, type KnowledgeBase } from '@/api/knowledge-base'
  258. import { searchKnowledgeBase, type KBSearchResultItem } from '@/api/search-engine'
  259. import { getSnippetDetail } from '@/api/snippet'
  260. import { documentApi } from '@/api/document'
  261. // Data
  262. const kbList = ref<KnowledgeBase[]>([])
  263. const metadataFields = ref<any[]>([]) // Store available metadata fields for selected KB
  264. const loading = ref(false)
  265. const hasSearched = ref(false)
  266. const tableData = ref<KBSearchResultItem[]>([])
  267. const total = ref(0)
  268. const currentPage = ref(1)
  269. const pageSize = ref(10)
  270. const searchForm = reactive({
  271. kb_id: '',
  272. mode: 'simple',
  273. filters: [{ field: '', value: '' }] as { field: string, value: string }[],
  274. query: '',
  275. doc_names: [] as string[] // 文档名称过滤
  276. })
  277. const docOptions = ref<{label: string, value: string}[]>([])
  278. const docLoading = ref(false)
  279. const loadDocOptions = async (query: string) => {
  280. docLoading.value = true
  281. try {
  282. const res = await documentApi.getList({
  283. page: 1,
  284. size: 50,
  285. keyword: query,
  286. }, true)
  287. if (res.code === 0) {
  288. // 映射为 label (title) 和 value (title) - 这里我们用 title 作为过滤值
  289. // 因为 Milvus 中存的是 doc_name/title,而不是 ID
  290. docOptions.value = res.data.items.map(item => ({
  291. label: item.title,
  292. value: item.title
  293. }))
  294. }
  295. } catch (error) {
  296. console.error("加载文档列表失败", error)
  297. } finally {
  298. docLoading.value = false
  299. }
  300. }
  301. const detailVisible = ref(false)
  302. const currentDetail = ref<KBSearchResultItem | null>(null)
  303. const parentSegments = ref<any[]>([])
  304. const parentLoading = ref(false)
  305. const activeParentTab = ref('0')
  306. // Methods
  307. const handleModeChange = () => {
  308. // Reset advanced fields when switching to simple
  309. if (searchForm.mode === 'simple') {
  310. searchForm.filters = [{ field: '', value: '' }]
  311. }
  312. }
  313. const addFilter = () => {
  314. searchForm.filters.push({ field: '', value: '' })
  315. }
  316. const removeFilter = (index: number) => {
  317. searchForm.filters.splice(index, 1)
  318. }
  319. const loadKBs = async () => {
  320. try {
  321. const res = await getKnowledgeBases({ page: 1, page_size: 100 }) // Load all KBs (simplified)
  322. kbList.value = res.data
  323. } catch (error) {
  324. console.error(error)
  325. }
  326. }
  327. const handleKbChange = async () => {
  328. // Reset search when KB changes
  329. hasSearched.value = false
  330. tableData.value = []
  331. total.value = 0
  332. // Reset metadata selection
  333. searchForm.filters = [{ field: '', value: '' }]
  334. metadataFields.value = []
  335. if (searchForm.kb_id) {
  336. // Find selected KB object to get ID (kb_id in form is collection_name)
  337. const selectedKb = kbList.value.find(k =>
  338. k.collection_name_children === searchForm.kb_id ||
  339. k.collection_name_parent === searchForm.kb_id ||
  340. k.collection_name === searchForm.kb_id
  341. )
  342. if (selectedKb) {
  343. try {
  344. const res = await getKnowledgeBaseMetadata(selectedKb.id)
  345. // 确保后端返回的数据格式正确
  346. if (res.code === 0) {
  347. if (Array.isArray(res.data)) {
  348. metadataFields.value = res.data
  349. } else if (res.data && Array.isArray(res.data.metadata_fields)) {
  350. metadataFields.value = res.data.metadata_fields
  351. } else {
  352. metadataFields.value = []
  353. }
  354. } else {
  355. console.warn("Invalid metadata response:", res)
  356. metadataFields.value = []
  357. }
  358. } catch (error) {
  359. console.error("Failed to load metadata fields", error)
  360. metadataFields.value = []
  361. }
  362. }
  363. }
  364. }
  365. const docNamesMap = ref<Record<string, string>>({})
  366. const formatMetaInfo = (row: any) => {
  367. // 优先展示 metadata 字段中的信息
  368. if (row.metadata) {
  369. // 如果是 JSON 对象,尝试格式化展示
  370. if (typeof row.metadata === 'object') {
  371. const displayParts = []
  372. for (const [key, value] of Object.entries(row.metadata)) {
  373. const valStr = typeof value === 'object' ? JSON.stringify(value) : String(value)
  374. displayParts.push(`${key}: ${valStr}`)
  375. }
  376. if (displayParts.length > 0) return displayParts.join(' | ')
  377. } else if (typeof row.metadata === 'string') {
  378. try {
  379. const metaObj = JSON.parse(row.metadata)
  380. const displayParts = []
  381. for (const [key, value] of Object.entries(metaObj)) {
  382. const valStr = typeof value === 'object' ? JSON.stringify(value) : String(value)
  383. displayParts.push(`${key}: ${valStr}`)
  384. }
  385. if (displayParts.length > 0) return displayParts.join(' | ')
  386. } catch (e) {
  387. return String(row.metadata)
  388. }
  389. }
  390. }
  391. // 如果 row 本身有 vector 属性,也不显示在 meta_info 中
  392. // 这里只处理了 metadata 字段内的
  393. return row.meta_info || '-'
  394. }
  395. const loadDocNames = async (docIds: string[]) => {
  396. if (docIds.length === 0) return
  397. // 过滤掉已经有的
  398. const missingIds = docIds.filter(id => !docNamesMap.value[id] && id)
  399. if (missingIds.length === 0) return
  400. // 批量查询文档详情比较低效,documentApi 如果有批量接口最好
  401. // 暂时循环查询,或者如果 documentApi.getList 支持 ids 参数
  402. // 假设我们只能单个查,为了性能,我们这里只查前 20 个 unique 的
  403. // 更好的方式:如果后端 search 接口能直接返回 doc_name 最好。
  404. // 但用户要求 "文档来源就根据这个片段的document_id去数据库里面查找"
  405. // 意味着我们需要前端二次查询或者后端聚合。
  406. // 为了前端响应速度,我们异步查询。
  407. for (const id of missingIds) {
  408. try {
  409. // 这里用 getDetail 查
  410. const res = await documentApi.getDetail(id)
  411. if (res.code === 0 && res.data) {
  412. docNamesMap.value[id] = res.data.title
  413. } else {
  414. docNamesMap.value[id] = '未知文档'
  415. }
  416. } catch (e) {
  417. console.error(`加载文档 ${id} 失败`, e)
  418. }
  419. }
  420. }
  421. const handleSearch = async () => {
  422. if (!searchForm.kb_id) {
  423. ElMessage.warning('请选择知识库')
  424. return
  425. }
  426. // If keyword is empty but KB is selected, user might want to see all or needs keyword?
  427. // Requirement says "search based on keyword", but usually empty keyword is allowed or blocked.
  428. // We will allow it but maybe warn if strict. Let's assume standard search behavior.
  429. loading.value = true
  430. hasSearched.value = true
  431. try {
  432. // 处理多重过滤
  433. // 后端目前可能只支持单一 metadata_field/value,或者我们需要修改后端接口支持 filters 数组
  434. // 这里我们先转换成后端能理解的格式,或者假设后端已更新
  435. // 假设后端接口 searchKnowledgeBase 支持 filters 参数: { field: string, value: string }[]
  436. // 过滤掉空的条件
  437. const validFilters = searchForm.mode === 'advanced'
  438. ? searchForm.filters.filter(f => f.field && f.value)
  439. : []
  440. // 将文档过滤也作为一种特殊的 filter 加入
  441. // 注意:即便是简单模式,如果用户选了文档,也应该生效
  442. if (searchForm.doc_names.length > 0) {
  443. // 对于多选文档,我们需要构建 OR 逻辑,但目前的 filters 结构是 AND
  444. // 我们可以约定 field 为 "doc_name_in",value 为逗号分隔的字符串
  445. validFilters.push({
  446. field: 'doc_name_in',
  447. value: JSON.stringify(searchForm.doc_names)
  448. })
  449. }
  450. const res = await searchKnowledgeBase({
  451. kb_id: searchForm.kb_id,
  452. query: searchForm.query || '',
  453. // 传递 filters 数组 (需要后端支持,或者我们在前端做兼容处理)
  454. // 为了兼容旧接口,如果只有一个 filter,传旧参数;如果有多个,传新参数 filters
  455. metadata_field: validFilters.length === 1 && validFilters[0].field !== 'doc_name_in' ? validFilters[0].field : undefined,
  456. metadata_value: validFilters.length === 1 && validFilters[0].field !== 'doc_name_in' ? validFilters[0].value : undefined,
  457. filters: validFilters.length > 0 ? validFilters : undefined,
  458. top_k: pageSize.value,
  459. page: currentPage.value,
  460. page_size: pageSize.value,
  461. metric_type: 'hybrid',
  462. })
  463. tableData.value = res.data.results
  464. total.value = res.data.total
  465. // 异步加载文档名称
  466. const docIds = res.data.results.map((r: any) => r.document_id).filter((id: string) => id)
  467. // 不阻塞 UI
  468. loadDocNames(docIds)
  469. } catch (error: any) {
  470. console.error(error)
  471. ElMessage.error(error.message || '检索失败,请检查配置或稍后重试')
  472. } finally {
  473. loading.value = false
  474. }
  475. }
  476. const handleDetail = (row: KBSearchResultItem) => {
  477. currentDetail.value = row
  478. parentSegments.value = []
  479. parentLoading.value = false
  480. activeParentTab.value = '0'
  481. if (row.parent_id) {
  482. parentLoading.value = true
  483. getSnippetDetail(row.kb_name, row.parent_id)
  484. .then((res: any) => {
  485. if (res.code === 0 && res.data) {
  486. if (Array.isArray(res.data.parent_segments) && res.data.parent_segments.length > 0) {
  487. parentSegments.value = res.data.parent_segments
  488. } else if (res.data.parent_content) {
  489. parentSegments.value = [{ id: '', content: res.data.parent_content }]
  490. }
  491. }
  492. })
  493. .finally(() => {
  494. parentLoading.value = false
  495. })
  496. }
  497. detailVisible.value = true
  498. }
  499. const getKbName = (collectionName?: string) => {
  500. if (!collectionName) return '-'
  501. const matched = kbList.value.find(k =>
  502. k.collection_name_children === collectionName ||
  503. k.collection_name_parent === collectionName ||
  504. (k as any).collection_name === collectionName
  505. )
  506. return matched?.name || collectionName
  507. }
  508. const getScoreType = (score: number) => {
  509. if (score >= 90) return 'success'
  510. if (score >= 70) return 'warning'
  511. return 'info'
  512. }
  513. const highlightKeyword = (text: string) => {
  514. if (!searchForm.query) return text
  515. const keyword = searchForm.query
  516. const regex = new RegExp(keyword, 'gi')
  517. return text.replace(regex, `<span style="color: #409eff; font-weight: bold;">$&</span>`)
  518. }
  519. const truncateText = (text: string, length: number) => {
  520. if (!text) return ''
  521. if (text.length <= length) return text
  522. return text.slice(0, length) + '...'
  523. }
  524. onMounted(() => {
  525. loadKBs()
  526. })
  527. </script>
  528. <style scoped>
  529. .search-engine-container {
  530. padding: 20px;
  531. background-color: #f5f7fa;
  532. min-height: calc(100vh - 84px);
  533. }
  534. .view-content-section {
  535. margin-top: 20px;
  536. }
  537. .view-content-section .section-title {
  538. display: flex;
  539. align-items: center;
  540. gap: 8px;
  541. font-weight: 600;
  542. margin-bottom: 12px;
  543. font-size: 16px;
  544. color: #303133;
  545. border-left: 4px solid #409EFF;
  546. padding-left: 10px;
  547. }
  548. .content-box {
  549. background-color: #f5f7fa;
  550. padding: 15px;
  551. border-radius: 4px;
  552. border: 1px solid #e4e7ed;
  553. color: #606266;
  554. line-height: 1.6;
  555. max-height: 400px;
  556. overflow-y: auto;
  557. white-space: pre-wrap;
  558. word-break: break-all;
  559. }
  560. .parent-content {
  561. background-color: #fcf6ec;
  562. border-color: #f3d19e;
  563. color: #e6a23c;
  564. }
  565. .header-section {
  566. margin-bottom: 20px;
  567. display: flex;
  568. justify-content: space-between;
  569. align-items: center;
  570. }
  571. .title-info h2 {
  572. font-size: 20px;
  573. font-weight: 600;
  574. color: #303133;
  575. margin: 0;
  576. display: inline-block;
  577. margin-right: 12px;
  578. }
  579. .subtitle {
  580. display: inline-block;
  581. color: #909399;
  582. font-size: 14px;
  583. margin: 0;
  584. }
  585. .search-card {
  586. margin-bottom: 20px;
  587. border-radius: 4px;
  588. }
  589. .search-form-row {
  590. display: flex;
  591. gap: 20px;
  592. align-items: flex-end;
  593. }
  594. .search-form-row.main-row {
  595. align-items: flex-end;
  596. margin-bottom: 0;
  597. }
  598. .kb-select {
  599. flex: 0 0 250px;
  600. }
  601. .search-mode {
  602. flex: 0 0 150px;
  603. }
  604. .keyword-input {
  605. flex: 1;
  606. }
  607. .form-item .label {
  608. font-size: 13px;
  609. color: #606266;
  610. margin-bottom: 8px;
  611. font-weight: 500;
  612. }
  613. .advanced-filter-area {
  614. margin-top: 20px;
  615. padding: 20px;
  616. border-top: 1px solid #ebeef5;
  617. background-color: #fafafa;
  618. border-radius: 4px;
  619. }
  620. .filter-header {
  621. display: flex;
  622. justify-content: space-between;
  623. align-items: center;
  624. margin-bottom: 12px;
  625. }
  626. .filter-title {
  627. font-size: 13px;
  628. font-weight: 600;
  629. color: #606266;
  630. }
  631. .filter-list {
  632. display: flex;
  633. flex-wrap: wrap;
  634. gap: 12px;
  635. }
  636. .filter-item {
  637. width: calc(33.33% - 8px); /* 一行三个 */
  638. background-color: #f5f7fa;
  639. padding: 10px;
  640. border-radius: 4px;
  641. }
  642. @media screen and (max-width: 1200px) {
  643. .filter-item {
  644. width: calc(50% - 6px); /* 一行两个 */
  645. }
  646. }
  647. @media screen and (max-width: 768px) {
  648. .filter-item {
  649. width: 100%; /* 一行一个 */
  650. }
  651. }
  652. .result-card {
  653. border-radius: 4px;
  654. }
  655. .snippet-content {
  656. line-height: 1.5;
  657. color: #606266;
  658. word-break: break-all;
  659. }
  660. .tooltip-content-scroll {
  661. max-width: 500px;
  662. max-height: 400px;
  663. overflow-y: auto;
  664. line-height: 1.6;
  665. word-break: break-all;
  666. }
  667. .pagination-container {
  668. display: flex;
  669. justify-content: space-between;
  670. align-items: center;
  671. margin-top: 20px;
  672. }
  673. .pagination-info {
  674. color: #909399;
  675. font-size: 13px;
  676. }
  677. .empty-placeholder {
  678. padding: 40px;
  679. background: #fff;
  680. border-radius: 4px;
  681. display: flex;
  682. justify-content: center;
  683. align-items: center;
  684. }
  685. .detail-content {
  686. padding: 10px;
  687. }
  688. .detail-item {
  689. margin-bottom: 16px;
  690. display: flex;
  691. }
  692. .detail-item .label {
  693. font-weight: bold;
  694. width: 80px;
  695. flex-shrink: 0;
  696. color: #606266;
  697. }
  698. .detail-item.full-content {
  699. flex-direction: column;
  700. }
  701. .detail-item.full-content .label {
  702. margin-bottom: 8px;
  703. }
  704. .text-box {
  705. background: #f5f7fa;
  706. padding: 12px;
  707. border-radius: 4px;
  708. line-height: 1.6;
  709. max-height: 300px;
  710. overflow-y: auto;
  711. white-space: pre-wrap;
  712. }
  713. </style>