KnowledgeDocModal.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  1. <template>
  2. <!--
  3. 「我的上传文档」弹窗:知识库卡片上的「管理文档」打开。
  4. 文档按上传者隔离(后端过滤):列表/下载/删除/重试只对上传者本人开放;
  5. 内置库普通用户打开也是空列表(管理员维护的文档清单不外露,检索不受影响)。
  6. -->
  7. <BasicModal
  8. :open="open"
  9. :title="kb ? `我的上传文档:${kb.name}` : '我的上传文档'"
  10. :width="880"
  11. wrap-class-name="kd-doc-modal"
  12. :show-ok-btn="false"
  13. cancel-text="关闭"
  14. :body-style="{ maxHeight: 'calc(100vh - 240px)', overflowY: 'auto' }"
  15. @cancel="close"
  16. >
  17. <div class="kd-modal">
  18. <div class="kd-modal__bar">
  19. <span class="kd-modal__hint">文档按上传者隔离:这里只显示我上传的文件;方案绑定知识库后,对话会检索库内全部文档。</span>
  20. <span class="kd-modal__sp"></span>
  21. <a-upload :show-upload-list="false" :before-upload="onPickFile" :accept="acceptStr" :disabled="uploading">
  22. <a-button type="primary" :loading="uploading">
  23. <template #icon><Icon icon="mdi:upload-outline" :size="14" /></template>
  24. 上传文档
  25. </a-button>
  26. </a-upload>
  27. <a-button :loading="loading" @click="load">
  28. <template #icon><Icon icon="mdi:refresh" :size="14" /></template>
  29. 刷新
  30. </a-button>
  31. </div>
  32. <a-table
  33. :columns="columns"
  34. :data-source="docs"
  35. :loading="loading"
  36. :pagination="false"
  37. size="small"
  38. row-key="id"
  39. class="kd-modal__table"
  40. >
  41. <template #bodyCell="{ column, record }">
  42. <template v-if="column.key === 'fileName'">
  43. <span class="kd-modal__file" :title="record.fileName">{{ record.fileName }}</span>
  44. </template>
  45. <template v-else-if="column.key === 'sizeBytes'">
  46. {{ formatSize(record.sizeBytes) }}
  47. </template>
  48. <template v-else-if="column.key === 'chunks'">
  49. <span v-if="record.indexStatus === 'READY'">{{ record.chunkCount }} 块</span>
  50. <span v-else class="kd-modal__dim">—</span>
  51. </template>
  52. <template v-else-if="column.key === 'indexStatus'">
  53. <a-tag :color="statusColor(record.indexStatus)">{{ statusLabel(record.indexStatus) }}</a-tag>
  54. <div v-if="record.failReason" class="kd-modal__fail" :title="record.failReason">
  55. {{ record.failReason }}
  56. </div>
  57. </template>
  58. <template v-else-if="column.key === 'ops'">
  59. <a-tooltip title="下载">
  60. <button class="kd-modal__iconbtn" type="button" aria-label="下载文档" @click="onDownload(record)">
  61. <Icon icon="mdi:download-outline" :size="15" />
  62. </button>
  63. </a-tooltip>
  64. <a-tooltip v-if="record.indexStatus === 'FAILED'" title="重试索引">
  65. <button class="kd-modal__iconbtn" type="button" aria-label="重试索引" @click="onReindex(record)">
  66. <Icon icon="mdi:refresh" :size="15" />
  67. </button>
  68. </a-tooltip>
  69. <a-tooltip title="删除">
  70. <button
  71. class="kd-modal__iconbtn is-danger"
  72. type="button"
  73. aria-label="删除文档"
  74. @click="onDelete(record)"
  75. >
  76. <Icon icon="mdi:trash-can-outline" :size="15" />
  77. </button>
  78. </a-tooltip>
  79. </template>
  80. </template>
  81. <template #emptyText>
  82. <div class="kd-modal__empty">
  83. <EmptyState
  84. title="还没有上传过文档"
  85. description="点击右上角「上传文档」添加第一个文件(支持 PDF / Word / 表格 / 文本等)。上传后自动解析并向量化。"
  86. />
  87. </div>
  88. </template>
  89. </a-table>
  90. </div>
  91. </BasicModal>
  92. </template>
  93. <script lang="ts" setup name="KnowledgeDocModal">
  94. /**
  95. * 知识库「我的上传文档」弹窗:列表(后端按上传者过滤)/ 上传 / 下载 / 重试 / 删除。
  96. *
  97. * - 上传:选择即传(before-upload 返回 false 阻止 antd 自动上传),返回 PENDING;
  98. * - 轮询:列表存在 PENDING/INDEXING 时每 3s 自动刷新,全部落定即停(索引是后台异步的);
  99. * - 下载:fetch 带 x-token 拿 blob 触发保存(直链不带登录态),后端记下载审计;
  100. * - 重试:仅 FAILED 出现(提取失败/嵌入服务临时不可用),重新走 PENDING → 索引。
  101. */
  102. import { onBeforeUnmount, ref, watch } from 'vue';
  103. import { Button, Modal, Table, Tag, Tooltip, Upload } from 'ant-design-vue';
  104. import { BasicModal } from '@/core/components/Modal';
  105. import Icon from '@/core/components/Icon/src/Icon.vue';
  106. import { EmptyState } from '@/core/components/EmptyState';
  107. import { useMessage } from '@/core/hooks/web/useMessage';
  108. import * as knowledgeApi from '../api/knowledgeApi';
  109. import type { KnowledgeBase, KnowledgeDoc } from '../api/types';
  110. const AButton = Button;
  111. const ATable = Table;
  112. const ATag = Tag;
  113. const ATooltip = Tooltip;
  114. const AUpload = Upload;
  115. const AM = Modal;
  116. const props = defineProps<{
  117. open: boolean;
  118. /** 目标知识库(标题与上传接口参数) */
  119. kb: KnowledgeBase | null;
  120. }>();
  121. const emit = defineEmits<{ 'update:open': [value: boolean] }>();
  122. const { showMessage } = useMessage();
  123. const docs = ref<KnowledgeDoc[]>([]);
  124. const loading = ref(false);
  125. const uploading = ref(false);
  126. let pollTimer: ReturnType<typeof setTimeout> | null = null;
  127. const acceptStr = '.txt,.md,.log,.json,.xml,.yaml,.yml,.ini,.sql,.tsv,.pdf,.doc,.docx,.rtf,.odt,.wps,.html,.htm,.eml,.csv,.xls,.xlsx,.ppt,.pptx';
  128. const columns = [
  129. { title: '文件名', key: 'fileName', ellipsis: true },
  130. { title: '大小', key: 'sizeBytes', width: 90 },
  131. { title: '向量块', key: 'chunks', width: 80 },
  132. { title: '索引状态', key: 'indexStatus', width: 150 },
  133. { title: '下载次数', key: 'downloadCount', width: 80 },
  134. { title: '操作', key: 'ops', width: 110 },
  135. ];
  136. watch(
  137. () => props.open,
  138. (open) => {
  139. if (open) {
  140. void load();
  141. } else {
  142. stopPoll();
  143. }
  144. },
  145. );
  146. onBeforeUnmount(stopPoll);
  147. function close() {
  148. emit('update:open', false);
  149. }
  150. function stopPoll() {
  151. if (pollTimer) {
  152. clearTimeout(pollTimer);
  153. pollTimer = null;
  154. }
  155. }
  156. async function load() {
  157. if (!props.kb) return;
  158. loading.value = true;
  159. try {
  160. docs.value = (await knowledgeApi.listMyDocs(props.kb.id)) || [];
  161. schedulePoll();
  162. } catch (e: any) {
  163. showMessage(e?.message || '加载文档列表失败', 'error');
  164. } finally {
  165. loading.value = false;
  166. }
  167. }
  168. /** 有未落定的索引状态时自动轮询,全部 READY/FAILED 即停 */
  169. function schedulePoll() {
  170. stopPoll();
  171. if (!props.open) return;
  172. const busy = docs.value.some((d) => d.indexStatus === 'PENDING' || d.indexStatus === 'INDEXING');
  173. if (busy) {
  174. pollTimer = setTimeout(() => {
  175. void load();
  176. }, 3000);
  177. }
  178. }
  179. /** antd before-upload 返回 false 阻止自动上传,这里手动接管 */
  180. function onPickFile(file: File) {
  181. void doUpload(file);
  182. return false;
  183. }
  184. async function doUpload(file: File) {
  185. if (!props.kb) return;
  186. uploading.value = true;
  187. try {
  188. await knowledgeApi.uploadDoc(props.kb.id, file);
  189. showMessage('已上传,正在解析并向量化', 'success');
  190. await load();
  191. } catch (e: any) {
  192. showMessage(e?.message || '上传失败', 'error');
  193. } finally {
  194. uploading.value = false;
  195. }
  196. }
  197. async function onDownload(doc: KnowledgeDoc) {
  198. try {
  199. await knowledgeApi.downloadDoc({ kbId: doc.kbId, docId: doc.id, fileName: doc.fileName });
  200. } catch (e: any) {
  201. showMessage(e?.message || '下载失败', 'error');
  202. }
  203. }
  204. async function onReindex(doc: KnowledgeDoc) {
  205. try {
  206. await knowledgeApi.reindexDoc(doc.kbId, doc.id);
  207. showMessage('已加入索引队列', 'success');
  208. await load();
  209. } catch (e: any) {
  210. showMessage(e?.message || '重试失败', 'error');
  211. }
  212. }
  213. function onDelete(doc: KnowledgeDoc) {
  214. Modal.confirm({
  215. title: '删除文档',
  216. content: `确认删除「${doc.fileName}」?其向量索引与下载记录会一并清理。`,
  217. okText: '删除',
  218. okType: 'primary',
  219. okButtonProps: { danger: true },
  220. cancelText: '取消',
  221. onOk: async () => {
  222. try {
  223. await knowledgeApi.deleteDoc(doc.kbId, doc.id);
  224. showMessage('已删除', 'success');
  225. await load();
  226. } catch (err: any) {
  227. showMessage(err?.message || '删除失败', 'error');
  228. }
  229. },
  230. });
  231. }
  232. function formatSize(bytes: number): string {
  233. if (!bytes) return '—';
  234. if (bytes < 1024) return `${bytes} B`;
  235. if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  236. return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
  237. }
  238. function statusColor(status: string): string {
  239. switch (status) {
  240. case 'READY':
  241. return 'success';
  242. case 'INDEXING':
  243. return 'processing';
  244. case 'FAILED':
  245. return 'error';
  246. default:
  247. return 'default';
  248. }
  249. }
  250. function statusLabel(status: string): string {
  251. switch (status) {
  252. case 'READY':
  253. return '已索引';
  254. case 'INDEXING':
  255. return '索引中';
  256. case 'FAILED':
  257. return '失败';
  258. case 'PENDING':
  259. return '排队中';
  260. default:
  261. return status;
  262. }
  263. }
  264. </script>
  265. <style lang="less" scoped>
  266. .kd-modal {
  267. display: flex;
  268. flex-direction: column;
  269. gap: 12px;
  270. &__bar {
  271. display: flex;
  272. align-items: center;
  273. gap: 8px;
  274. :deep(.ant-btn) {
  275. height: 30px;
  276. padding: 0 14px;
  277. color: #5b6577;
  278. font-size: 12px;
  279. font-weight: 500;
  280. background: #fff;
  281. border: 1px solid #e7ebf3;
  282. border-radius: 8px;
  283. box-shadow: none;
  284. }
  285. :deep(.ant-btn-primary) {
  286. color: #fff;
  287. background: #2a50ec;
  288. border-color: #2a50ec;
  289. &:hover,
  290. &:focus {
  291. color: #fff;
  292. background: #4b6bf0;
  293. border-color: #4b6bf0;
  294. }
  295. }
  296. }
  297. &__hint {
  298. flex: 1 1 auto;
  299. color: #98a2b3;
  300. font-size: 11.5px;
  301. line-height: 1.6;
  302. }
  303. &__sp {
  304. flex: 0 0 auto;
  305. }
  306. &__file {
  307. display: inline-block;
  308. max-width: 320px;
  309. overflow: hidden;
  310. color: #111827;
  311. font-size: 12.5px;
  312. font-weight: 500;
  313. white-space: nowrap;
  314. text-overflow: ellipsis;
  315. vertical-align: bottom;
  316. }
  317. &__dim {
  318. color: #98a2b3;
  319. }
  320. &__fail {
  321. max-width: 150px;
  322. overflow: hidden;
  323. margin-top: 4px;
  324. color: #e5484d;
  325. font-size: 11px;
  326. line-height: 1.4;
  327. white-space: nowrap;
  328. text-overflow: ellipsis;
  329. }
  330. &__iconbtn {
  331. display: inline-flex;
  332. align-items: center;
  333. justify-content: center;
  334. width: 26px;
  335. height: 26px;
  336. padding: 0;
  337. margin-right: 4px;
  338. color: #98a2b3;
  339. background: #fff;
  340. border: 1px solid #e7ebf3;
  341. border-radius: 6px;
  342. cursor: pointer;
  343. transition:
  344. color 0.15s ease,
  345. background-color 0.15s ease,
  346. border-color 0.15s ease;
  347. &:hover {
  348. color: #2a50ec;
  349. background: #f8faff;
  350. border-color: #bfcdfb;
  351. }
  352. &.is-danger:hover {
  353. color: #e5484d;
  354. background: #fff5f5;
  355. border-color: rgb(229 72 77 / 35%);
  356. }
  357. }
  358. &__empty {
  359. padding: 12px 0;
  360. }
  361. :deep(.ant-table-thead > tr > th) {
  362. color: #5b6577;
  363. font-size: 12px;
  364. font-weight: 500;
  365. background: #f8fafc;
  366. }
  367. :deep(.ant-table-tbody > tr > td) {
  368. font-size: 12.5px;
  369. }
  370. }
  371. </style>