Files
EasyFlow/easyflow-ui-admin/app/src/views/ai/documentCollection/DocumentTable.vue
陈子默 4130381658 feat: 支持知识库导入 PPTX 与 XLSX 文档
- 打通 Office 文档桥接解析、解析进度承接与图片引用改写

- 落地 PPTX 按页分块、XLSX 行窗口分块以及预览与检索渲染闭环
2026-04-18 13:01:17 +08:00

839 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import type { Component } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { $t } from '@easyflow/locales';
import { downloadFileFromBlob } from '@easyflow/utils';
import {
CloseBold,
Delete,
Download,
Files,
Loading,
Opportunity,
Select,
UploadFilled,
} from '@element-plus/icons-vue';
import {
ElButton,
ElIcon,
ElImage,
ElMessage,
ElMessageBox,
ElProgress,
ElTable,
ElTableColumn,
ElTooltip,
} from 'element-plus';
import { buildKnowledgeShareUrl } from '#/api/knowledge-share';
import { api, SseClient } from '#/api/request';
import documentIcon from '#/assets/ai/knowledge/document.svg';
import PageData from '#/components/page/PageData.vue';
import { buildKnowledgePath } from '#/views/ai/documentCollection/share-path';
interface DocumentStatusPayload {
completedChunks?: number;
documentId?: number | string;
failedChunks?: number;
knowledgeId?: number | string;
lastTaskError?: string;
parseCurrentStage?: string;
parseStatusMessage?: string;
processStatus?: string;
progressPercent?: number;
taskModifiedAt?: string;
totalChunks?: number;
type?: string;
}
interface DocumentTablePermissions {
canCreateContent?: boolean;
canDeleteContent?: boolean;
canDownloadContent?: boolean;
}
type PermissionKey = keyof DocumentTablePermissions;
const props = defineProps({
knowledgeId: {
required: true,
type: String,
},
requestClient: {
type: Object as any,
default: () => api,
},
endpointPrefix: {
type: String,
default: '',
},
permissions: {
type: Object as () => DocumentTablePermissions,
default: () => ({
canCreateContent: true,
canDeleteContent: true,
canDownloadContent: true,
}),
},
});
const emits = defineEmits(['viewDoc', 'continueProcess']);
const STREAM_RECONNECT_DELAY = 1500;
const STREAM_RELOAD_DELAY = 250;
const pageDataRef = ref();
const taskStatusStreamClient = new SseClient();
let reconnectTimer: null | ReturnType<typeof setTimeout> = null;
let reloadTimer: null | ReturnType<typeof setTimeout> = null;
let disposed = false;
defineExpose({
reload() {
pageDataRef.value?.reload?.();
},
search(searchText: string) {
pageDataRef.value?.setQuery?.({
title: searchText,
});
},
});
const processingStatuses = new Set(['INDEXING', 'PARSING']);
const isProcessingStatus = (status?: string) =>
processingStatuses.has(status || '');
const resolvedPermissions = computed(() => ({
canCreateContent: props.permissions?.canCreateContent ?? true,
canDeleteContent: props.permissions?.canDeleteContent ?? true,
canDownloadContent: props.permissions?.canDownloadContent ?? true,
}));
const hasPermission = (key: PermissionKey) => Boolean(resolvedPermissions.value[key]);
const statusMetaMap: Record<
string,
{
icon: Component;
toneClass: string;
}
> = {
COMPLETED: {
icon: Select,
toneClass: 'status-pill--success',
},
INDEX_FAILED: {
icon: CloseBold,
toneClass: 'status-pill--danger',
},
INDEXING: {
icon: Loading,
toneClass: 'status-pill--warning',
},
PARSE_FAILED: {
icon: CloseBold,
toneClass: 'status-pill--danger',
},
PARSING: {
icon: Loading,
toneClass: 'status-pill--warning',
},
READY_FOR_INDEX: {
icon: Opportunity,
toneClass: 'status-pill--primary',
},
READY_FOR_SEGMENT: {
icon: Files,
toneClass: 'status-pill--primary',
},
UPLOADED: {
icon: UploadFilled,
toneClass: 'status-pill--info',
},
};
const defaultStatusMeta: {
icon: Component;
toneClass: string;
} = statusMetaMap.UPLOADED!;
const getStatusLabel = (status?: string) =>
$t(`documentCollection.taskStatus.${status || 'UPLOADED'}`);
const getStatusMeta = (
status?: string,
): {
icon: Component;
toneClass: string;
} => statusMetaMap[status || 'UPLOADED'] ?? defaultStatusMeta;
const getStatusToneClass = (status?: string) => getStatusMeta(status).toneClass;
const getStatusIcon = (status?: string) => getStatusMeta(status).icon;
const getChunkCount = (row: any) => {
const totalChunks = Number(row.totalChunks || 0);
if (totalChunks > 0) {
return totalChunks;
}
return Number(row.chunkCount || 0);
};
const getProgressText = (row: any) => {
const completed = Number(row.completedChunks || 0);
const total = Number(row.totalChunks || 0);
if (row.processStatus === 'PARSING') {
return `${Number(row.progressPercent || 0)}%`;
}
if (total <= 0) {
return `${Number(row.progressPercent || 0)}%`;
}
return `${Number(row.progressPercent || 0)}% · ${completed}/${total}`;
};
const parseStageLabels: Record<string, string> = {
assembling: '汇总中',
extracting: '提取中',
ocr: 'OCR 中',
preparing: '准备中',
queued: '排队中',
};
const getProcessingHint = (row: any) =>
row.parseStatusMessage ||
parseStageLabels[row.parseCurrentStage || ''] ||
'';
const clearReconnectTimer = () => {
if (!reconnectTimer) {
return;
}
clearTimeout(reconnectTimer);
reconnectTimer = null;
};
const clearReloadTimer = () => {
if (!reloadTimer) {
return;
}
clearTimeout(reloadTimer);
reloadTimer = null;
};
const scheduleReload = () => {
if (reloadTimer) {
return;
}
reloadTimer = setTimeout(() => {
reloadTimer = null;
pageDataRef.value?.reload?.();
}, STREAM_RELOAD_DELAY);
};
const patchDocumentRow = (payload: DocumentStatusPayload) => {
if (!payload.documentId) {
return false;
}
const nextPatch: Record<string, any> = {
completedChunks: payload.completedChunks,
failedChunks: payload.failedChunks,
lastTaskError: payload.lastTaskError,
parseCurrentStage: payload.parseCurrentStage,
parseStatusMessage: payload.parseStatusMessage,
processStatus: payload.processStatus,
progressPercent: payload.progressPercent,
taskModifiedAt: payload.taskModifiedAt,
totalChunks: payload.totalChunks,
};
if (payload.taskModifiedAt) {
nextPatch.modified = payload.taskModifiedAt;
}
return (
pageDataRef.value?.patchRowById?.(payload.documentId, nextPatch) ?? false
);
};
const buildTaskStreamUrl = () => {
const path = buildKnowledgePath(
props.endpointPrefix,
'/api/v1/document/import/task/stream',
);
return props.endpointPrefix ? buildKnowledgeShareUrl(path) : path;
};
const scheduleStreamReconnect = () => {
if (disposed || reconnectTimer) {
return;
}
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
openTaskStatusStream();
}, STREAM_RECONNECT_DELAY);
};
const handleTaskStatusMessage = (message: {
data?: string;
event?: string;
}) => {
if (!message?.data) {
return;
}
if (message.event && message.event !== 'document-status') {
return;
}
try {
const payload = JSON.parse(message.data) as DocumentStatusPayload;
if (payload?.type !== 'document-status') {
return;
}
if (String(payload.knowledgeId || '') !== String(props.knowledgeId || '')) {
return;
}
if (patchDocumentRow(payload)) {
return;
}
} catch {
// 文档状态流约定始终返回 JSON异常负载直接忽略避免误触发列表刷新。
return;
}
scheduleReload();
};
const openTaskStatusStream = async () => {
if (!props.knowledgeId) {
return;
}
taskStatusStreamClient.abort();
clearReconnectTimer();
await taskStatusStreamClient.post(
buildTaskStreamUrl(),
{
knowledgeId: props.knowledgeId,
},
{
onMessage: handleTaskStatusMessage,
onError: () => {
if (!disposed) {
scheduleStreamReconnect();
}
},
onFinished: () => {
if (!disposed) {
scheduleStreamReconnect();
}
},
},
);
};
const ensurePermission = (key: PermissionKey) => {
if (hasPermission(key)) {
return true;
}
ElMessage.warning($t('documentCollection.managePermissionHint'));
return false;
};
const requestTaskAction = async (
path: string,
payload: Record<string, any>,
successMessage: string,
) => {
if (!ensurePermission('canCreateContent')) {
return;
}
const res = await props.requestClient.post(
buildKnowledgePath(props.endpointPrefix, path),
payload,
);
if (res.errorCode === 0) {
ElMessage.success(successMessage);
pageDataRef.value?.reload?.();
}
};
const handleContinue = (row: any) => {
if (!ensurePermission('canCreateContent')) {
return;
}
emits('continueProcess', row);
};
const handleRetryParse = async (row: any) => {
await requestTaskAction(
'/api/v1/document/import/task/retryParse',
{
knowledgeId: props.knowledgeId,
documentId: row.id,
},
getStatusLabel('PARSING'),
);
};
const handleView = (row: any) => {
emits('viewDoc', row.id);
};
const handleDownload = async (row: any) => {
const blob = await props.requestClient.download(
buildKnowledgePath(
props.endpointPrefix,
`/api/v1/document/download?documentId=${row.id}`,
),
);
downloadFileFromBlob({
fileName: row.title || 'document',
source: blob,
});
};
const handleDelete = (row: any) => {
if (!ensurePermission('canDeleteContent')) {
return;
}
if (processingStatuses.has(row.processStatus)) {
ElMessage.warning($t('documentCollection.processingDeleteBlocked'));
return;
}
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
confirmButtonText: $t('button.confirm'),
cancelButtonText: $t('button.cancel'),
type: 'warning',
}).then(async () => {
const res = await props.requestClient.post(
buildKnowledgePath(props.endpointPrefix, '/api/v1/document/removeDoc'),
{ id: row.id },
);
if (res.errorCode === 0) {
ElMessage.success($t('message.deleteOkMessage'));
pageDataRef.value?.reload?.();
}
});
};
const primaryActionConfigs: Record<
string,
{
handler: (row: any) => void;
label: () => string;
permission?: PermissionKey;
}
> = {
COMPLETED: {
handler: handleView,
label: () => $t('button.viewSegmentation'),
},
INDEX_FAILED: {
handler: handleContinue,
label: () => $t('button.continueProcess'),
permission: 'canCreateContent',
},
PARSE_FAILED: {
handler: handleRetryParse,
label: () => $t('button.retryParse'),
permission: 'canCreateContent',
},
READY_FOR_INDEX: {
handler: handleContinue,
label: () => $t('button.continueProcess'),
permission: 'canCreateContent',
},
READY_FOR_SEGMENT: {
handler: handleContinue,
label: () => $t('button.continueProcess'),
permission: 'canCreateContent',
},
};
const getPrimaryActionLabel = (row: any) => {
const config = primaryActionConfigs[row.processStatus || ''];
if (!config) {
return '';
}
if (config.permission && !hasPermission(config.permission)) {
return '';
}
return config.label();
};
const handlePrimaryAction = (row: any) => {
const config = primaryActionConfigs[row.processStatus || ''];
if (!config) {
return;
}
if (config.permission && !hasPermission(config.permission)) {
return;
}
config.handler(row);
};
onMounted(() => {
disposed = false;
openTaskStatusStream();
});
onBeforeUnmount(() => {
disposed = true;
clearReconnectTimer();
clearReloadTimer();
taskStatusStreamClient.abort();
});
watch(
() => `${props.endpointPrefix}:${props.knowledgeId}`,
() => {
if (disposed) {
return;
}
openTaskStatusStream();
},
);
</script>
<template>
<PageData
:page-url="
buildKnowledgePath(props.endpointPrefix, '/api/v1/document/documentList')
"
ref="pageDataRef"
:page-size="10"
:request-client="props.requestClient"
:extra-query-params="{
id: props.knowledgeId,
sort: 'desc',
sortKey: 'created',
}"
>
<template #default="{ pageList }">
<ElTable :data="pageList" style="width: 100%" size="large">
<ElTableColumn
prop="fileName"
:label="$t('documentCollection.fileName')"
min-width="220"
align="left"
header-align="left"
>
<template #default="{ row }">
<span class="file-name-container">
<ElImage :src="documentIcon" class="mr-1" />
<span class="title">
{{ row.title }}
</span>
</span>
</template>
</ElTableColumn>
<ElTableColumn
:label="$t('documentCollection.chunkCount')"
width="96"
align="left"
header-align="left"
>
<template #default="{ row }">
{{ getChunkCount(row) }}
</template>
</ElTableColumn>
<ElTableColumn
:label="$t('documentCollection.createdModifyTime')"
width="176"
align="left"
header-align="left"
>
<template #default="{ row }">
<div class="time-container">
<span>{{ row.created }}</span>
<span>{{ row.modified }}</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn
:label="$t('documentCollection.processStatus')"
min-width="156"
align="left"
header-align="left"
>
<template #default="{ row }">
<div class="status-cell">
<div
class="status-pill"
:class="getStatusToneClass(row.processStatus)"
>
<span class="status-pill__icon-shell">
<ElIcon
class="status-pill__icon"
:class="
isProcessingStatus(row.processStatus)
? 'status-pill__icon--spinning'
: ''
"
>
<component :is="getStatusIcon(row.processStatus)" />
</ElIcon>
</span>
<span class="status-pill__label">
{{ getStatusLabel(row.processStatus) }}
</span>
</div>
<div
v-if="
row.processStatus === 'INDEXING' ||
row.processStatus === 'PARSING'
"
class="status-progress"
>
<ElProgress
:percentage="Number(row.progressPercent || 0)"
:stroke-width="8"
/>
<span class="status-progress__text">
{{ getProgressText(row) }}
</span>
<span
v-if="row.processStatus === 'PARSING' && getProcessingHint(row)"
class="status-progress__hint"
>
{{ getProcessingHint(row) }}
</span>
</div>
<div
v-else-if="row.lastTaskError"
class="status-error"
:title="row.lastTaskError"
>
{{ row.lastTaskError }}
</div>
</div>
</template>
</ElTableColumn>
<ElTableColumn
:label="$t('common.handle')"
width="148"
align="left"
header-align="left"
>
<template #default="{ row }">
<div class="action-cell">
<ElButton
v-if="getPrimaryActionLabel(row)"
link
type="primary"
@click="handlePrimaryAction(row)"
>
{{ getPrimaryActionLabel(row) }}
</ElButton>
<ElTooltip
v-if="hasPermission('canDownloadContent')"
:content="$t('button.download')"
placement="top"
>
<ElButton
link
:icon="Download"
:aria-label="$t('button.download')"
@click="handleDownload(row)"
/>
</ElTooltip>
<ElTooltip
v-if="hasPermission('canDeleteContent')"
:content="$t('button.delete')"
placement="top"
>
<ElButton
link
type="danger"
:icon="Delete"
:aria-label="$t('button.delete')"
@click="handleDelete(row)"
/>
</ElTooltip>
</div>
</template>
</ElTableColumn>
</ElTable>
</template>
</PageData>
</template>
<style scoped>
.time-container {
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-start;
}
.file-name-container {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.title {
font-size: 14px;
font-weight: 500;
line-height: 20px;
color: var(--el-text-color-primary);
}
.status-cell {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-start;
}
.status-progress {
display: flex;
flex-direction: column;
gap: 6px;
width: min(168px, 100%);
}
.status-progress__text {
font-size: 12px;
color: var(--el-text-color-secondary);
text-align: left;
}
.status-progress__hint {
font-size: 12px;
color: var(--el-text-color-secondary);
text-align: left;
}
.status-error {
max-width: 176px;
font-size: 12px;
line-height: 1.5;
color: var(--el-color-danger);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 8px;
width: fit-content;
max-width: 100%;
min-height: 30px;
padding: 4px 12px 4px 8px;
border: 1px solid var(--status-pill-border);
border-radius: 999px;
background: var(--status-pill-bg);
color: var(--status-pill-text);
font-size: 12px;
font-weight: 600;
line-height: 18px;
white-space: nowrap;
transition:
border-color 0.2s ease,
background-color 0.2s ease,
color 0.2s ease;
}
.status-pill__icon-shell {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 999px;
background: var(--status-pill-icon-bg);
}
.status-pill__icon {
display: inline-flex;
flex-shrink: 0;
font-size: 12px;
color: var(--status-pill-icon-color);
}
.status-pill__label {
overflow: hidden;
text-overflow: ellipsis;
}
.status-pill__icon--spinning {
animation: status-spin 1.15s linear infinite;
}
.status-pill--primary {
--status-pill-bg: var(--el-color-primary-light-9);
--status-pill-border: var(--el-color-primary-light-7);
--status-pill-icon-bg: var(--el-color-primary-light-8);
--status-pill-icon-color: var(--el-color-primary);
--status-pill-text: var(--el-color-primary);
}
.status-pill--success {
--status-pill-bg: var(--el-color-success-light-9);
--status-pill-border: var(--el-color-success-light-7);
--status-pill-icon-bg: var(--el-color-success-light-8);
--status-pill-icon-color: var(--el-color-success);
--status-pill-text: var(--el-color-success);
}
.status-pill--warning {
--status-pill-bg: var(--el-color-warning-light-9);
--status-pill-border: var(--el-color-warning-light-7);
--status-pill-icon-bg: var(--el-color-warning-light-8);
--status-pill-icon-color: var(--el-color-warning);
--status-pill-text: var(--el-color-warning);
}
.status-pill--danger {
--status-pill-bg: var(--el-color-danger-light-9);
--status-pill-border: var(--el-color-danger-light-7);
--status-pill-icon-bg: var(--el-color-danger-light-8);
--status-pill-icon-color: var(--el-color-danger);
--status-pill-text: var(--el-color-danger);
}
.status-pill--info {
--status-pill-bg: var(--el-fill-color-light);
--status-pill-border: var(--el-border-color-light);
--status-pill-icon-bg: var(--el-fill-color);
--status-pill-icon-color: var(--el-text-color-secondary);
--status-pill-text: var(--el-text-color-secondary);
}
.action-cell {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.action-cell :deep(.el-button) {
white-space: nowrap;
}
.action-cell :deep(.el-button + .el-button) {
margin-left: 0;
}
@keyframes status-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>