QaManage.vue 26 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097
  1. <template>
  2. <div class="qa-manage">
  3. <div class="action-bar">
  4. <div class="action-bar__left">
  5. <el-input
  6. v-model="keyword"
  7. clearable
  8. placeholder="搜索问题"
  9. style="width: 280px"
  10. @keyup.enter="refreshFaqList"
  11. @clear="refreshFaqList"
  12. />
  13. <el-button @click="refreshFaqList">
  14. <el-icon><Search /></el-icon>
  15. 搜索
  16. </el-button>
  17. </div>
  18. <div class="flex">
  19. <el-button @click="openImportModal">
  20. <el-icon><Upload /></el-icon>
  21. 模版导入
  22. </el-button>
  23. <el-button type="primary" @click="openCreateDrawer">
  24. <el-icon><Plus /></el-icon>
  25. 新增问答
  26. </el-button>
  27. </div>
  28. </div>
  29. <el-card>
  30. <template #header>
  31. <div class="card-header">
  32. <span>问答列表</span>
  33. <!-- <span class="card-header__meta">面向 FAQ 召回和问答命中的问答管理</span> -->
  34. </div>
  35. </template>
  36. <el-table v-if="faqList.length || loading" :data="faqList" v-loading="loading" border>
  37. <el-table-column prop="standard_question" label="标准问题" min-width="260" />
  38. <el-table-column label="答案" min-width="260">
  39. <template #default="{ row }">
  40. <span class="answers-preview">{{ formatAnswers(row.answers) }}</span>
  41. </template>
  42. </el-table-column>
  43. <el-table-column label="相似问数量" width="110">
  44. <template #default="{ row }">
  45. {{ row.similar_questions?.length || 0 }}
  46. </template>
  47. </el-table-column>
  48. <el-table-column label="反例数量" width="110">
  49. <template #default="{ row }">
  50. {{ row.negative_questions?.length || 0 }}
  51. </template>
  52. </el-table-column>
  53. <el-table-column label="启用" width="90">
  54. <template #default="{ row }">
  55. <el-switch :model-value="!!row.is_enabled" @change="toggleFaqStatus(row, $event)" />
  56. </template>
  57. </el-table-column>
  58. <el-table-column prop="creationTime" label="创建时间" width="180" />
  59. <el-table-column label="操作" width="160" fixed="right">
  60. <template #default="{ row }">
  61. <el-button link type="primary" @click="openDetailDialog(row.id)">详情</el-button>
  62. <el-button link type="primary" @click="openEditDrawer(row)">编辑</el-button>
  63. <el-button link type="danger" @click="removeFaq(row.id)">删除</el-button>
  64. </template>
  65. </el-table-column>
  66. <template #empty>暂无问答条目</template>
  67. </el-table>
  68. <el-empty v-else description="暂无问答条目" class="page-empty" />
  69. <div v-if="faqList.length || pagination.totalCount" class="pagination-wrap">
  70. <el-pagination
  71. background
  72. layout="total, prev, pager, next"
  73. :current-page="pagination.pageIndex"
  74. :page-size="pagination.pageSize"
  75. :total="pagination.totalCount"
  76. @current-change="handlePageChange"
  77. />
  78. </div>
  79. </el-card>
  80. <!-- 编辑抽屉 -->
  81. <el-drawer
  82. v-model="drawerVisible"
  83. :title="form.id ? '编辑问答' : '新增问答'"
  84. direction="rtl"
  85. size="680px"
  86. >
  87. <el-form ref="formRef" :model="form" :rules="rules" label-position="top" label-width="100%">
  88. <el-form-item label="标准问法" prop="standard_question">
  89. <el-input v-model="form.standard_question" placeholder="请输入标准问题" />
  90. </el-form-item>
  91. <el-form-item>
  92. <template #label>
  93. <div class="array-field__header">
  94. <span>相似问法</span>
  95. <el-button link type="primary" @click="addArrayItem('similar_questions')">
  96. <el-icon><Plus /></el-icon>添加
  97. </el-button>
  98. </div>
  99. <div class="field-tip">
  100. 添加与标准问意思相同但表述不同的问题,帮助系统更好地匹配用户查询。
  101. </div>
  102. </template>
  103. <div class="array-field">
  104. <div
  105. v-for="(_item, index) in form.similar_questions"
  106. :key="`similar-${index}`"
  107. class="array-field__row"
  108. >
  109. <el-input
  110. v-model="form.similar_questions[index]"
  111. placeholder="请输入与标准问语义相同但表述不同的问题"
  112. />
  113. <el-button
  114. link
  115. type="danger"
  116. :disabled="form.similar_questions.length <= 1"
  117. @click="removeArrayItem('similar_questions', index)"
  118. >
  119. 删除
  120. </el-button>
  121. </div>
  122. </div>
  123. </el-form-item>
  124. <el-form-item>
  125. <template #label>
  126. <div class="array-field__header">
  127. <span>反例</span>
  128. <el-button link type="primary" @click="addArrayItem('negative_questions')">
  129. <el-icon><Plus /></el-icon>添加
  130. </el-button>
  131. </div>
  132. <div class="field-tip">添加不应匹配此答案的问题,用于排除误匹配。</div>
  133. </template>
  134. <div class="array-field">
  135. <div
  136. v-for="(_item, index) in form.negative_questions"
  137. :key="`negative-${index}`"
  138. class="array-field__row"
  139. >
  140. <el-input
  141. v-model="form.negative_questions[index]"
  142. placeholder="请输入不应匹配此答案的问题"
  143. />
  144. <el-button
  145. link
  146. type="danger"
  147. :disabled="form.negative_questions.length <= 1"
  148. @click="removeArrayItem('negative_questions', index)"
  149. >
  150. 删除
  151. </el-button>
  152. </div>
  153. </div>
  154. </el-form-item>
  155. <el-form-item prop="answers">
  156. <template #label>
  157. <div class="array-field__header">
  158. <span>答案</span>
  159. <el-button link type="primary" @click="addArrayItem('answers')">
  160. <el-icon><Plus /></el-icon>添加
  161. </el-button>
  162. </div>
  163. <div class="field-tip">提供完整准确的答案内容,可添加多个答案以覆盖不同场景。</div>
  164. </template>
  165. <div class="array-field">
  166. <div
  167. v-for="(_item, index) in form.answers"
  168. :key="`answer-${index}`"
  169. class="array-field__row"
  170. >
  171. <el-input
  172. v-model="form.answers[index]"
  173. type="textarea"
  174. :rows="3"
  175. placeholder="请输入答案内容"
  176. />
  177. <el-button
  178. link
  179. type="danger"
  180. :disabled="form.answers.length <= 1"
  181. @click="removeArrayItem('answers', index)"
  182. >
  183. 删除
  184. </el-button>
  185. </div>
  186. </div>
  187. </el-form-item>
  188. <el-form-item label="启用">
  189. <el-switch v-model="form.is_enabled" />
  190. </el-form-item>
  191. </el-form>
  192. <template #footer>
  193. <div class="drawer-footer">
  194. <el-button @click="drawerVisible = false">取消</el-button>
  195. <el-button type="primary" :loading="submitLoading" @click="submitForm">保存</el-button>
  196. </div>
  197. </template>
  198. </el-drawer>
  199. <!-- 详情弹窗 -->
  200. <el-dialog v-model="detailVisible" title="问答详情" width="720px">
  201. <div v-loading="detailLoading" class="detail-panel">
  202. <template v-if="detailData">
  203. <div class="detail-item">
  204. <div class="detail-item__label">标准问题</div>
  205. <div class="detail-item__value">
  206. <el-tag type="primary" effect="plain">
  207. {{ detailData.standard_question || '-' }}
  208. </el-tag>
  209. </div>
  210. </div>
  211. <div class="detail-item">
  212. <div class="detail-item__label">相似问</div>
  213. <div class="detail-tag-list">
  214. <el-tag
  215. v-for="item in detailData.similar_questions || []"
  216. :key="`similar-${item}`"
  217. effect="plain"
  218. >
  219. {{ item }}
  220. </el-tag>
  221. <span v-if="!(detailData.similar_questions || []).length" class="detail-empty"
  222. >-</span
  223. >
  224. </div>
  225. </div>
  226. <div class="detail-item">
  227. <div class="detail-item__label">反例</div>
  228. <div class="detail-tag-list">
  229. <el-tag
  230. v-for="item in detailData.negative_questions || []"
  231. :key="`negative-${item}`"
  232. effect="plain"
  233. type="info"
  234. >
  235. {{ item }}
  236. </el-tag>
  237. <span v-if="!(detailData.negative_questions || []).length" class="detail-empty"
  238. >-</span
  239. >
  240. </div>
  241. </div>
  242. <div class="detail-item">
  243. <div class="detail-item__label">答案</div>
  244. <div class="detail-answer-list">
  245. <div
  246. v-for="(item, index) in detailData.answers || []"
  247. :key="`answer-${index}`"
  248. class="detail-answer-item"
  249. >
  250. {{ item }}
  251. </div>
  252. <span v-if="!(detailData.answers || []).length" class="detail-empty">-</span>
  253. </div>
  254. </div>
  255. <div class="detail-grid">
  256. <div class="detail-item">
  257. <div class="detail-item__label">启用状态</div>
  258. <div class="detail-item__value">
  259. <el-tag :type="detailData.is_enabled ? 'success' : 'warning'">{{
  260. detailData.is_enabled ? '启用' : '停用'
  261. }}</el-tag>
  262. </div>
  263. </div>
  264. <div class="detail-item">
  265. <div class="detail-item__label">索引方式</div>
  266. <div class="detail-item__value">
  267. {{ detailData.index_mode }}
  268. </div>
  269. </div>
  270. <div class="detail-item">
  271. <div class="detail-item__label">创建时间</div>
  272. <div class="detail-item__value">{{ detailData.creationTime || '-' }}</div>
  273. </div>
  274. </div>
  275. </template>
  276. </div>
  277. </el-dialog>
  278. <!-- 导入弹窗 -->
  279. <el-dialog v-model="importDialogVisible" title="模版导入" width="500px">
  280. <el-form> </el-form>
  281. <div class="import-modal-content">
  282. <div class="import-step">
  283. <div class="step-title">上传文件</div>
  284. <div class="step-desc">上传填写好的 Excel 文件。</div>
  285. <FileUploadInput
  286. v-model="importFile"
  287. :fileExtensions="['.xlsx', '.xls']"
  288. placeholder="点击或拖拽上传 Excel 文件"
  289. />
  290. </div>
  291. <div class="import-step">
  292. <div class="step-title">导入模式</div>
  293. <el-select v-model="importFormData.mode" placeholder="请选择">
  294. <el-option label="追加" value="append"></el-option>
  295. <el-option label="覆盖" value="replace"></el-option>
  296. </el-select>
  297. </div>
  298. <div class="import-step">
  299. <div class="step-title">下载模版</div>
  300. <div class="step-desc">请下载标准模版,并按照格式填写问答数据。</div>
  301. <div>
  302. <el-button type="primary" link @click="downloadTemplate">
  303. <el-icon><Download /></el-icon>
  304. 下载 faq_example.xlsx
  305. </el-button>
  306. </div>
  307. </div>
  308. </div>
  309. <template #footer>
  310. <div class="drawer-footer">
  311. <el-button @click="importDialogVisible = false">取消</el-button>
  312. <el-button
  313. type="primary"
  314. :loading="importLoading"
  315. :disabled="!importFile"
  316. @click="submitImport"
  317. >
  318. 开始导入
  319. </el-button>
  320. </div>
  321. </template>
  322. </el-dialog>
  323. <el-dialog
  324. v-model="importTaskDialogVisible"
  325. title="导入任务状态"
  326. width="560px"
  327. :close-on-click-modal="false"
  328. >
  329. <div class="import-task">
  330. <div v-if="importTaskInfo" class="import-task__meta">
  331. <div class="import-task__row">
  332. <span class="import-task__label">任务编号</span>
  333. <span class="import-task__value">{{ importTaskInfo.code || '-' }}</span>
  334. </div>
  335. <div class="import-task__row">
  336. <span class="import-task__label">创建时间</span>
  337. <span class="import-task__value">{{ importTaskInfo.creationTime || '-' }}</span>
  338. </div>
  339. </div>
  340. <div class="import-task__status">
  341. <el-tag :type="getImportTaskStatusTag(importTaskInfo?.status)">
  342. {{ getImportTaskStatusText(importTaskInfo?.status) }}
  343. </el-tag>
  344. <span class="import-task__progress"> 进度:{{ importTaskInfo?.progress || '-' }} </span>
  345. </div>
  346. </div>
  347. <template #footer>
  348. <div class="drawer-footer">
  349. <el-button @click="handleImportTaskDialogClose">关闭</el-button>
  350. </div>
  351. </template>
  352. </el-dialog>
  353. </div>
  354. </template>
  355. <script setup lang="ts">
  356. import { onBeforeUnmount, reactive, ref, watch } from 'vue'
  357. import { ElMessage, ElMessageBox } from 'element-plus'
  358. import { Plus, Search, Upload, Download } from '@element-plus/icons-vue'
  359. import { knowledge } from '@repo/api-service'
  360. import FileUploadInput from '@/features/fileUpload/FileUploadInput.vue'
  361. import type { FaqForm, FaqItem } from './types'
  362. import type { WorkflowUploadFile } from '@/features/fileUpload/shared'
  363. interface FaqDetail extends FaqItem {
  364. answer_strategy?: string
  365. chunk_id?: string
  366. chunk_type?: string
  367. index_mode?: string
  368. is_recommended?: boolean
  369. knowledge_base_id?: string
  370. knowledge_id?: string
  371. tag_id?: string
  372. }
  373. interface AsyncTaskInfo {
  374. code?: string
  375. creationTime?: string
  376. htmlStatusInfo?: string
  377. id: string
  378. progress?: string
  379. status?: number
  380. updateTime?: string
  381. }
  382. const props = defineProps<{ currentBaseId: string }>()
  383. const loading = ref(false)
  384. const submitLoading = ref(false)
  385. const drawerVisible = ref(false)
  386. const detailVisible = ref(false)
  387. const detailLoading = ref(false)
  388. const keyword = ref('')
  389. const faqList = ref<FaqItem[]>([])
  390. const detailData = ref<FaqDetail | null>(null)
  391. const formRef = ref()
  392. const pagination = reactive({
  393. pageIndex: 1,
  394. pageSize: 20,
  395. totalCount: 0
  396. })
  397. const importDialogVisible = ref(false)
  398. const importLoading = ref(false)
  399. const importTaskDialogVisible = ref(false)
  400. const importTaskLoading = ref(false)
  401. const importTaskInfo = ref<AsyncTaskInfo | null>(null)
  402. const importFormData = reactive<{
  403. knowledge_base_id: string
  404. fileId: string
  405. mode: 'replace' | 'append'
  406. }>({
  407. knowledge_base_id: '',
  408. fileId: '',
  409. mode: 'append' // 默认追加,可根据需求调整
  410. })
  411. const importFile = ref<WorkflowUploadFile>()
  412. let importTaskTimer: number | null = null
  413. type ArrayFieldKey = 'similar_questions' | 'negative_questions' | 'answers'
  414. function normalizeStringArray(value?: string[]) {
  415. return (value || []).map((item) => item.trim()).filter(Boolean)
  416. }
  417. const createDefaultForm = (): FaqForm => ({
  418. id: '',
  419. standard_question: '',
  420. similar_questions: [''],
  421. negative_questions: [''],
  422. answers: [''],
  423. is_enabled: true
  424. })
  425. const form = reactive<FaqForm>(createDefaultForm())
  426. const rules = {
  427. standard_question: [{ required: true, message: '请输入标准问题', trigger: 'blur' }],
  428. answers: [
  429. {
  430. validator: (_rule: unknown, value: string[], callback: (error?: Error) => void) => {
  431. if (normalizeStringArray(value).length) {
  432. callback()
  433. return
  434. }
  435. callback(new Error('请至少填写一个答案'))
  436. },
  437. trigger: 'change'
  438. }
  439. ]
  440. }
  441. function addArrayItem(field: ArrayFieldKey) {
  442. form[field].push('')
  443. }
  444. function removeArrayItem(field: ArrayFieldKey, index: number) {
  445. if (form[field].length <= 1) return
  446. form[field].splice(index, 1)
  447. }
  448. function formatAnswers(value?: string[]) {
  449. const list = normalizeStringArray(value)
  450. return list.length ? list.join(' / ') : '-'
  451. }
  452. function resetForm() {
  453. Object.assign(form, createDefaultForm())
  454. }
  455. function applyFaqFormData(data: Partial<FaqDetail>) {
  456. const answers = normalizeStringArray(data.answers)
  457. Object.assign(form, {
  458. id: data.id || '',
  459. standard_question: data.standard_question || '',
  460. similar_questions: normalizeStringArray(data.similar_questions).length
  461. ? normalizeStringArray(data.similar_questions)
  462. : [''],
  463. negative_questions: normalizeStringArray(data.negative_questions).length
  464. ? normalizeStringArray(data.negative_questions)
  465. : [''],
  466. answers: answers.length ? answers : [''],
  467. is_enabled: data.is_enabled ?? true
  468. })
  469. }
  470. async function fetchFaqDetail(id?: string) {
  471. if (!id) return null
  472. const res = await knowledge.postAiFaqInfo({ id })
  473. if (!res?.isSuccess || !res.result) return null
  474. return (res.result || null) as FaqDetail | null
  475. }
  476. async function fetchFaqList() {
  477. if (!props.currentBaseId) return
  478. loading.value = true
  479. try {
  480. const res = await knowledge.postAiFaqPageList({
  481. knowledge_base_id: props.currentBaseId,
  482. pageIndex: pagination.pageIndex,
  483. pageSize: pagination.pageSize,
  484. keyword: keyword.value
  485. })
  486. if (res?.isSuccess) {
  487. faqList.value = (res.result?.model || []) as FaqItem[]
  488. pagination.totalCount = res.result?.totalCount || 0
  489. }
  490. } finally {
  491. loading.value = false
  492. }
  493. }
  494. async function refreshFaqList() {
  495. pagination.pageIndex = 1
  496. await fetchFaqList()
  497. }
  498. async function handlePageChange(page: number) {
  499. pagination.pageIndex = page
  500. await fetchFaqList()
  501. }
  502. function openCreateDrawer() {
  503. resetForm()
  504. drawerVisible.value = true
  505. }
  506. async function openDetailDialog(id?: string) {
  507. if (!id) return
  508. detailVisible.value = true
  509. detailLoading.value = true
  510. detailData.value = null
  511. try {
  512. detailData.value = await fetchFaqDetail(id)
  513. } finally {
  514. detailLoading.value = false
  515. }
  516. }
  517. async function openEditDrawer(row: FaqItem) {
  518. const detail = await fetchFaqDetail(row.id)
  519. if (!detail) {
  520. ElMessage.error('问答详情加载失败')
  521. return
  522. }
  523. applyFaqFormData(detail)
  524. drawerVisible.value = true
  525. }
  526. async function submitForm() {
  527. form.similar_questions = normalizeStringArray(form.similar_questions)
  528. form.negative_questions = normalizeStringArray(form.negative_questions)
  529. form.answers = normalizeStringArray(form.answers)
  530. const valid = await formValidate(formRef.value)
  531. if (!valid) {
  532. if (!form.similar_questions.length) form.similar_questions = ['']
  533. if (!form.negative_questions.length) form.negative_questions = ['']
  534. if (!form.answers.length) form.answers = ['']
  535. return
  536. }
  537. submitLoading.value = true
  538. try {
  539. const payload = {
  540. standard_question: form.standard_question.trim(),
  541. similar_questions: normalizeStringArray(form.similar_questions),
  542. negative_questions: normalizeStringArray(form.negative_questions),
  543. answers: normalizeStringArray(form.answers),
  544. is_enabled: form.is_enabled
  545. }
  546. if (form.id) {
  547. await knowledge.postAiFaqUpdate({ id: form.id, ...payload })
  548. ElMessage.success('问答已更新')
  549. } else {
  550. await knowledge.postAiFaqCreate({
  551. knowledge_base_id: props.currentBaseId,
  552. ...payload
  553. })
  554. ElMessage.success('问答已创建')
  555. }
  556. drawerVisible.value = false
  557. await refreshFaqList()
  558. } catch {
  559. ElMessage.error('保存失败')
  560. } finally {
  561. if (!form.similar_questions.length) form.similar_questions = ['']
  562. if (!form.negative_questions.length) form.negative_questions = ['']
  563. if (!form.answers.length) form.answers = ['']
  564. submitLoading.value = false
  565. }
  566. }
  567. async function toggleFaqStatus(row: FaqItem, value: string | number | boolean) {
  568. if (!row.id) return
  569. const detail = await fetchFaqDetail(row.id)
  570. if (!detail) {
  571. ElMessage.error('问答详情加载失败')
  572. return
  573. }
  574. await knowledge.postAiFaqUpdate({
  575. id: row.id,
  576. standard_question: detail.standard_question || '',
  577. similar_questions: normalizeStringArray(detail.similar_questions),
  578. negative_questions: normalizeStringArray(detail.negative_questions),
  579. answers: normalizeStringArray(detail.answers),
  580. is_enabled: Boolean(value)
  581. })
  582. ElMessage.success('状态已更新')
  583. await fetchFaqList()
  584. }
  585. async function removeFaq(id?: string) {
  586. if (!id) return
  587. const confirmed = await ElMessageBox.confirm('确定删除该问答吗?删除后不可恢复。', '删除确认', {
  588. type: 'warning'
  589. })
  590. .then(() => true)
  591. .catch(() => false)
  592. if (!confirmed) return
  593. await knowledge.postAiFaqOpenApiDelete({ id })
  594. ElMessage.success('问答已删除')
  595. await refreshFaqList()
  596. }
  597. async function formValidate(formInstance: any) {
  598. return formInstance
  599. ?.validate()
  600. .then(() => true)
  601. .catch(() => false)
  602. }
  603. // --- 导入相关逻辑 ---
  604. function openImportModal() {
  605. importFormData.fileId = ''
  606. importFormData.knowledge_base_id = props.currentBaseId
  607. importDialogVisible.value = true
  608. }
  609. function resetImportForm() {
  610. importFile.value = undefined
  611. importFormData.fileId = ''
  612. importFormData.mode = 'append'
  613. }
  614. function clearImportTaskTimer() {
  615. if (importTaskTimer) {
  616. window.clearTimeout(importTaskTimer)
  617. importTaskTimer = null
  618. }
  619. }
  620. function getImportTaskStatusText(status?: number) {
  621. switch (status) {
  622. case 0:
  623. return '已创建'
  624. case 1:
  625. return '运行中'
  626. case 2:
  627. return '成功'
  628. case 3:
  629. return '失败'
  630. case 4:
  631. return '挂起'
  632. default:
  633. return '处理中'
  634. }
  635. }
  636. function getImportTaskStatusTag(status?: number) {
  637. switch (status) {
  638. case 2:
  639. return 'success'
  640. case 3:
  641. return 'danger'
  642. case 4:
  643. return 'warning'
  644. default:
  645. return 'primary'
  646. }
  647. }
  648. function extractAsyncTaskId(payload: any) {
  649. if (!payload) return ''
  650. return (
  651. payload.id ||
  652. payload.result?.id ||
  653. payload.appAsynTaskId ||
  654. payload.result?.appAsynTaskId ||
  655. payload.taskId ||
  656. payload.result?.taskId ||
  657. ''
  658. )
  659. }
  660. async function fetchImportTaskInfo(taskId: string) {
  661. const res = await knowledge.postBpmGetAsynTaskInfo({ id: taskId })
  662. if (!res?.isSuccess || !res.result) {
  663. throw new Error((res as any)?.error || '异步任务状态查询失败')
  664. }
  665. importTaskInfo.value = res.result as AsyncTaskInfo
  666. return importTaskInfo.value
  667. }
  668. async function pollImportTask(taskId: string) {
  669. importTaskLoading.value = true
  670. try {
  671. const taskInfo = await fetchImportTaskInfo(taskId)
  672. const status = taskInfo?.status
  673. if (status === 2) {
  674. clearImportTaskTimer()
  675. ElMessage.success('导入成功')
  676. await refreshFaqList()
  677. importTaskDialogVisible.value = false
  678. importTaskInfo.value = null
  679. return
  680. }
  681. if (status === 3) {
  682. clearImportTaskTimer()
  683. ElMessage.error('导入失败,请查看任务状态信息')
  684. return
  685. }
  686. clearImportTaskTimer()
  687. importTaskTimer = window.setTimeout(() => {
  688. void pollImportTask(taskId)
  689. }, 1000)
  690. } catch (error) {
  691. clearImportTaskTimer()
  692. console.error('Fetch import task failed:', error)
  693. ElMessage.error('异步任务状态查询失败,请稍后重试')
  694. } finally {
  695. importTaskLoading.value = false
  696. }
  697. }
  698. function handleImportTaskDialogClose() {
  699. clearImportTaskTimer()
  700. importTaskDialogVisible.value = false
  701. importTaskInfo.value = null
  702. }
  703. function downloadTemplate() {
  704. window.open('/Content/Template/faq_example.xlsx', '_blank')
  705. }
  706. async function submitImport() {
  707. if (!importFile.value) {
  708. ElMessage.warning('请先上传文件')
  709. return
  710. }
  711. if (!props.currentBaseId) {
  712. ElMessage.error('知识库ID缺失')
  713. return
  714. }
  715. importLoading.value = true
  716. try {
  717. const payload: any = {
  718. knowledge_base_id: props.currentBaseId,
  719. fileId: importFile.value.id,
  720. mode: importFormData.mode
  721. }
  722. const res = await knowledge.postAiFaqBatchImport(payload)
  723. if (!res.isSuccess) {
  724. ElMessage.error(res.error)
  725. return
  726. }
  727. const taskId = extractAsyncTaskId(res)
  728. importDialogVisible.value = false
  729. resetImportForm()
  730. if (!taskId) {
  731. ElMessage.success('导入成功')
  732. await refreshFaqList()
  733. return
  734. }
  735. importTaskInfo.value = {
  736. id: taskId,
  737. status: 0,
  738. progress: '0%'
  739. }
  740. importTaskDialogVisible.value = true
  741. await pollImportTask(taskId)
  742. } catch (error) {
  743. console.error('Import failed:', error)
  744. ElMessage.error('导入失败,请检查文件格式或联系管理员')
  745. } finally {
  746. importLoading.value = false
  747. }
  748. }
  749. watch(
  750. () => props.currentBaseId,
  751. () => {
  752. clearImportTaskTimer()
  753. keyword.value = ''
  754. faqList.value = []
  755. pagination.pageIndex = 1
  756. pagination.totalCount = 0
  757. detailVisible.value = false
  758. detailData.value = null
  759. importTaskDialogVisible.value = false
  760. importTaskInfo.value = null
  761. resetImportForm()
  762. if (props.currentBaseId) {
  763. void fetchFaqList()
  764. }
  765. },
  766. { immediate: true }
  767. )
  768. onBeforeUnmount(() => {
  769. clearImportTaskTimer()
  770. })
  771. </script>
  772. <style scoped lang="less">
  773. .qa-manage {
  774. display: flex;
  775. flex-direction: column;
  776. gap: 16px;
  777. }
  778. .action-bar {
  779. display: flex;
  780. align-items: center;
  781. justify-content: space-between;
  782. gap: 12px;
  783. flex-wrap: wrap;
  784. }
  785. .action-bar__left {
  786. display: flex;
  787. align-items: center;
  788. gap: 12px;
  789. flex-wrap: wrap;
  790. }
  791. .card-header {
  792. display: flex;
  793. align-items: center;
  794. justify-content: space-between;
  795. gap: 12px;
  796. font-weight: 600;
  797. }
  798. .card-header__meta {
  799. font-size: 12px;
  800. color: #6b7280;
  801. }
  802. .answers-preview {
  803. display: inline-block;
  804. max-width: 320px;
  805. overflow: hidden;
  806. white-space: nowrap;
  807. text-overflow: ellipsis;
  808. }
  809. .pagination-wrap {
  810. margin-top: 16px;
  811. display: flex;
  812. justify-content: flex-end;
  813. }
  814. .page-empty {
  815. min-height: calc(100vh - 360px);
  816. display: flex;
  817. align-items: center;
  818. justify-content: center;
  819. }
  820. .array-field__header {
  821. width: 100%;
  822. display: flex;
  823. align-items: center;
  824. justify-content: space-between;
  825. gap: 12px;
  826. }
  827. .array-field {
  828. width: 100%;
  829. display: flex;
  830. flex-direction: column;
  831. gap: 10px;
  832. border: 1px solid #eee;
  833. border-radius: 4px;
  834. padding: 12px;
  835. box-sizing: border-box;
  836. }
  837. .array-field__row {
  838. display: grid;
  839. grid-template-columns: minmax(0, 1fr) auto;
  840. gap: 12px;
  841. align-items: start;
  842. }
  843. .array-field__empty {
  844. padding: 12px;
  845. border: 1px dashed #d1d5db;
  846. border-radius: 8px;
  847. font-size: 12px;
  848. color: #9ca3af;
  849. background: #f9fafb;
  850. }
  851. .field-tip {
  852. margin-top: 8px;
  853. font-size: 12px;
  854. line-height: 1.5;
  855. color: #a6aab3;
  856. font-weight: 400;
  857. }
  858. .drawer-footer {
  859. display: flex;
  860. justify-content: flex-end;
  861. gap: 8px;
  862. }
  863. .detail-panel {
  864. min-height: 180px;
  865. }
  866. .detail-grid {
  867. display: grid;
  868. grid-template-columns: repeat(2, minmax(0, 1fr));
  869. gap: 16px;
  870. margin-bottom: 16px;
  871. }
  872. .detail-item {
  873. margin-bottom: 16px;
  874. }
  875. .detail-item__label {
  876. margin-bottom: 8px;
  877. font-size: 13px;
  878. font-weight: 600;
  879. color: #374151;
  880. }
  881. .detail-item__value {
  882. line-height: 1.6;
  883. color: #111827;
  884. word-break: break-all;
  885. }
  886. .detail-tag-list {
  887. display: flex;
  888. flex-wrap: wrap;
  889. gap: 8px;
  890. }
  891. .detail-answer-list {
  892. display: flex;
  893. flex-direction: column;
  894. gap: 8px;
  895. }
  896. .detail-answer-item {
  897. padding: 10px 12px;
  898. border-radius: 8px;
  899. background: #f8fafc;
  900. color: #111827;
  901. line-height: 1.6;
  902. }
  903. .detail-empty {
  904. color: #9ca3af;
  905. }
  906. :deep(.el-form-item__label) {
  907. width: 100%;
  908. color: #333;
  909. font-weight: 600;
  910. }
  911. .import-modal-content {
  912. display: flex;
  913. flex-direction: column;
  914. gap: 24px;
  915. padding: 10px 0;
  916. }
  917. .import-step {
  918. display: flex;
  919. flex-direction: column;
  920. gap: 8px;
  921. }
  922. .step-title {
  923. font-weight: 600;
  924. font-size: 14px;
  925. color: #303133;
  926. }
  927. .step-desc {
  928. font-size: 13px;
  929. color: #909399;
  930. line-height: 1.4;
  931. }
  932. .import-task {
  933. display: flex;
  934. flex-direction: column;
  935. gap: 16px;
  936. min-height: 180px;
  937. }
  938. .import-task__status {
  939. display: flex;
  940. align-items: center;
  941. gap: 12px;
  942. }
  943. .import-task__progress {
  944. font-size: 13px;
  945. color: #6b7280;
  946. }
  947. .import-task__meta {
  948. display: grid;
  949. gap: 10px;
  950. padding: 14px;
  951. border-radius: 10px;
  952. background: #f8fafc;
  953. }
  954. .import-task__row {
  955. display: grid;
  956. grid-template-columns: 72px minmax(0, 1fr);
  957. gap: 10px;
  958. font-size: 13px;
  959. }
  960. .import-task__label {
  961. color: #6b7280;
  962. }
  963. .import-task__value {
  964. color: #111827;
  965. word-break: break-all;
  966. }
  967. .import-task__detail {
  968. padding: 14px;
  969. border: 1px solid #e5e7eb;
  970. border-radius: 10px;
  971. background: #fff;
  972. }
  973. .import-task__detail-title {
  974. margin-bottom: 8px;
  975. font-size: 13px;
  976. font-weight: 600;
  977. color: #111827;
  978. }
  979. .import-task__detail-body {
  980. font-size: 13px;
  981. line-height: 1.7;
  982. color: #111827;
  983. word-break: break-word;
  984. }
  985. .import-task__detail-body :deep(*) {
  986. max-width: 100%;
  987. }
  988. </style>