| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409 |
- <template>
- <!--
- 「我的上传文档」弹窗:知识库卡片上的「管理文档」打开。
- 文档按上传者隔离(后端过滤):列表/下载/删除/重试只对上传者本人开放;
- 内置库普通用户打开也是空列表(管理员维护的文档清单不外露,检索不受影响)。
- -->
- <BasicModal
- :open="open"
- :title="kb ? `我的上传文档:${kb.name}` : '我的上传文档'"
- :width="880"
- wrap-class-name="kd-doc-modal"
- :show-ok-btn="false"
- cancel-text="关闭"
- :body-style="{ maxHeight: 'calc(100vh - 240px)', overflowY: 'auto' }"
- @cancel="close"
- >
- <div class="kd-modal">
- <div class="kd-modal__bar">
- <span class="kd-modal__hint">文档按上传者隔离:这里只显示我上传的文件;方案绑定知识库后,对话会检索库内全部文档。</span>
- <span class="kd-modal__sp"></span>
- <a-upload :show-upload-list="false" :before-upload="onPickFile" :accept="acceptStr" :disabled="uploading">
- <a-button type="primary" :loading="uploading">
- <template #icon><Icon icon="mdi:upload-outline" :size="14" /></template>
- 上传文档
- </a-button>
- </a-upload>
- <a-button :loading="loading" @click="load">
- <template #icon><Icon icon="mdi:refresh" :size="14" /></template>
- 刷新
- </a-button>
- </div>
- <a-table
- :columns="columns"
- :data-source="docs"
- :loading="loading"
- :pagination="false"
- size="small"
- row-key="id"
- class="kd-modal__table"
- >
- <template #bodyCell="{ column, record }">
- <template v-if="column.key === 'fileName'">
- <span class="kd-modal__file" :title="record.fileName">{{ record.fileName }}</span>
- </template>
- <template v-else-if="column.key === 'sizeBytes'">
- {{ formatSize(record.sizeBytes) }}
- </template>
- <template v-else-if="column.key === 'chunks'">
- <span v-if="record.indexStatus === 'READY'">{{ record.chunkCount }} 块</span>
- <span v-else class="kd-modal__dim">—</span>
- </template>
- <template v-else-if="column.key === 'indexStatus'">
- <a-tag :color="statusColor(record.indexStatus)">{{ statusLabel(record.indexStatus) }}</a-tag>
- <div v-if="record.failReason" class="kd-modal__fail" :title="record.failReason">
- {{ record.failReason }}
- </div>
- </template>
- <template v-else-if="column.key === 'ops'">
- <a-tooltip title="下载">
- <button class="kd-modal__iconbtn" type="button" aria-label="下载文档" @click="onDownload(record)">
- <Icon icon="mdi:download-outline" :size="15" />
- </button>
- </a-tooltip>
- <a-tooltip v-if="record.indexStatus === 'FAILED'" title="重试索引">
- <button class="kd-modal__iconbtn" type="button" aria-label="重试索引" @click="onReindex(record)">
- <Icon icon="mdi:refresh" :size="15" />
- </button>
- </a-tooltip>
- <a-tooltip title="删除">
- <button
- class="kd-modal__iconbtn is-danger"
- type="button"
- aria-label="删除文档"
- @click="onDelete(record)"
- >
- <Icon icon="mdi:trash-can-outline" :size="15" />
- </button>
- </a-tooltip>
- </template>
- </template>
- <template #emptyText>
- <div class="kd-modal__empty">
- <EmptyState
- title="还没有上传过文档"
- description="点击右上角「上传文档」添加第一个文件(支持 PDF / Word / 表格 / 文本等)。上传后自动解析并向量化。"
- />
- </div>
- </template>
- </a-table>
- </div>
- </BasicModal>
- </template>
- <script lang="ts" setup name="KnowledgeDocModal">
- /**
- * 知识库「我的上传文档」弹窗:列表(后端按上传者过滤)/ 上传 / 下载 / 重试 / 删除。
- *
- * - 上传:选择即传(before-upload 返回 false 阻止 antd 自动上传),返回 PENDING;
- * - 轮询:列表存在 PENDING/INDEXING 时每 3s 自动刷新,全部落定即停(索引是后台异步的);
- * - 下载:fetch 带 x-token 拿 blob 触发保存(直链不带登录态),后端记下载审计;
- * - 重试:仅 FAILED 出现(提取失败/嵌入服务临时不可用),重新走 PENDING → 索引。
- */
- import { onBeforeUnmount, ref, watch } from 'vue';
- import { Button, Modal, Table, Tag, Tooltip, Upload } from 'ant-design-vue';
- import { BasicModal } from '@/core/components/Modal';
- import Icon from '@/core/components/Icon/src/Icon.vue';
- import { EmptyState } from '@/core/components/EmptyState';
- import { useMessage } from '@/core/hooks/web/useMessage';
- import * as knowledgeApi from '../api/knowledgeApi';
- import type { KnowledgeBase, KnowledgeDoc } from '../api/types';
- const AButton = Button;
- const ATable = Table;
- const ATag = Tag;
- const ATooltip = Tooltip;
- const AUpload = Upload;
- const AM = Modal;
- const props = defineProps<{
- open: boolean;
- /** 目标知识库(标题与上传接口参数) */
- kb: KnowledgeBase | null;
- }>();
- const emit = defineEmits<{ 'update:open': [value: boolean] }>();
- const { showMessage } = useMessage();
- const docs = ref<KnowledgeDoc[]>([]);
- const loading = ref(false);
- const uploading = ref(false);
- let pollTimer: ReturnType<typeof setTimeout> | null = null;
- 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';
- const columns = [
- { title: '文件名', key: 'fileName', ellipsis: true },
- { title: '大小', key: 'sizeBytes', width: 90 },
- { title: '向量块', key: 'chunks', width: 80 },
- { title: '索引状态', key: 'indexStatus', width: 150 },
- { title: '下载次数', key: 'downloadCount', width: 80 },
- { title: '操作', key: 'ops', width: 110 },
- ];
- watch(
- () => props.open,
- (open) => {
- if (open) {
- void load();
- } else {
- stopPoll();
- }
- },
- );
- onBeforeUnmount(stopPoll);
- function close() {
- emit('update:open', false);
- }
- function stopPoll() {
- if (pollTimer) {
- clearTimeout(pollTimer);
- pollTimer = null;
- }
- }
- async function load() {
- if (!props.kb) return;
- loading.value = true;
- try {
- docs.value = (await knowledgeApi.listMyDocs(props.kb.id)) || [];
- schedulePoll();
- } catch (e: any) {
- showMessage(e?.message || '加载文档列表失败', 'error');
- } finally {
- loading.value = false;
- }
- }
- /** 有未落定的索引状态时自动轮询,全部 READY/FAILED 即停 */
- function schedulePoll() {
- stopPoll();
- if (!props.open) return;
- const busy = docs.value.some((d) => d.indexStatus === 'PENDING' || d.indexStatus === 'INDEXING');
- if (busy) {
- pollTimer = setTimeout(() => {
- void load();
- }, 3000);
- }
- }
- /** antd before-upload 返回 false 阻止自动上传,这里手动接管 */
- function onPickFile(file: File) {
- void doUpload(file);
- return false;
- }
- async function doUpload(file: File) {
- if (!props.kb) return;
- uploading.value = true;
- try {
- await knowledgeApi.uploadDoc(props.kb.id, file);
- showMessage('已上传,正在解析并向量化', 'success');
- await load();
- } catch (e: any) {
- showMessage(e?.message || '上传失败', 'error');
- } finally {
- uploading.value = false;
- }
- }
- async function onDownload(doc: KnowledgeDoc) {
- try {
- await knowledgeApi.downloadDoc({ kbId: doc.kbId, docId: doc.id, fileName: doc.fileName });
- } catch (e: any) {
- showMessage(e?.message || '下载失败', 'error');
- }
- }
- async function onReindex(doc: KnowledgeDoc) {
- try {
- await knowledgeApi.reindexDoc(doc.kbId, doc.id);
- showMessage('已加入索引队列', 'success');
- await load();
- } catch (e: any) {
- showMessage(e?.message || '重试失败', 'error');
- }
- }
- function onDelete(doc: KnowledgeDoc) {
- Modal.confirm({
- title: '删除文档',
- content: `确认删除「${doc.fileName}」?其向量索引与下载记录会一并清理。`,
- okText: '删除',
- okType: 'primary',
- okButtonProps: { danger: true },
- cancelText: '取消',
- onOk: async () => {
- try {
- await knowledgeApi.deleteDoc(doc.kbId, doc.id);
- showMessage('已删除', 'success');
- await load();
- } catch (err: any) {
- showMessage(err?.message || '删除失败', 'error');
- }
- },
- });
- }
- function formatSize(bytes: number): string {
- if (!bytes) return '—';
- if (bytes < 1024) return `${bytes} B`;
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
- return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
- }
- function statusColor(status: string): string {
- switch (status) {
- case 'READY':
- return 'success';
- case 'INDEXING':
- return 'processing';
- case 'FAILED':
- return 'error';
- default:
- return 'default';
- }
- }
- function statusLabel(status: string): string {
- switch (status) {
- case 'READY':
- return '已索引';
- case 'INDEXING':
- return '索引中';
- case 'FAILED':
- return '失败';
- case 'PENDING':
- return '排队中';
- default:
- return status;
- }
- }
- </script>
- <style lang="less" scoped>
- .kd-modal {
- display: flex;
- flex-direction: column;
- gap: 12px;
- &__bar {
- display: flex;
- align-items: center;
- gap: 8px;
- :deep(.ant-btn) {
- height: 30px;
- padding: 0 14px;
- color: #5b6577;
- font-size: 12px;
- font-weight: 500;
- background: #fff;
- border: 1px solid #e7ebf3;
- border-radius: 8px;
- box-shadow: none;
- }
- :deep(.ant-btn-primary) {
- color: #fff;
- background: #2a50ec;
- border-color: #2a50ec;
- &:hover,
- &:focus {
- color: #fff;
- background: #4b6bf0;
- border-color: #4b6bf0;
- }
- }
- }
- &__hint {
- flex: 1 1 auto;
- color: #98a2b3;
- font-size: 11.5px;
- line-height: 1.6;
- }
- &__sp {
- flex: 0 0 auto;
- }
- &__file {
- display: inline-block;
- max-width: 320px;
- overflow: hidden;
- color: #111827;
- font-size: 12.5px;
- font-weight: 500;
- white-space: nowrap;
- text-overflow: ellipsis;
- vertical-align: bottom;
- }
- &__dim {
- color: #98a2b3;
- }
- &__fail {
- max-width: 150px;
- overflow: hidden;
- margin-top: 4px;
- color: #e5484d;
- font-size: 11px;
- line-height: 1.4;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
- &__iconbtn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 26px;
- height: 26px;
- padding: 0;
- margin-right: 4px;
- color: #98a2b3;
- background: #fff;
- border: 1px solid #e7ebf3;
- border-radius: 6px;
- cursor: pointer;
- transition:
- color 0.15s ease,
- background-color 0.15s ease,
- border-color 0.15s ease;
- &:hover {
- color: #2a50ec;
- background: #f8faff;
- border-color: #bfcdfb;
- }
- &.is-danger:hover {
- color: #e5484d;
- background: #fff5f5;
- border-color: rgb(229 72 77 / 35%);
- }
- }
- &__empty {
- padding: 12px 0;
- }
- :deep(.ant-table-thead > tr > th) {
- color: #5b6577;
- font-size: 12px;
- font-weight: 500;
- background: #f8fafc;
- }
- :deep(.ant-table-tbody > tr > td) {
- font-size: 12.5px;
- }
- }
- </style>
|