Files
prop-ai-hr/frontend/src/views/knowledge/processing.vue
T

1284 lines
36 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
<template>
<div class="processing-page">
<input ref="fileInput" class="hidden-input" type="file" multiple :accept="acceptTypes" @change="handlePickedFiles" />
<input ref="directoryInput" class="hidden-input" type="file" multiple webkitdirectory directory @change="handlePickedFiles" />
<section class="page-head">
<div>
<h1>资料处理中心</h1>
<p>批量导入业务资料,跟踪解析、切片、向量化与入库状态。</p>
</div>
<div class="head-actions">
<label class="auto-classify-toggle">
<span>智能归类</span>
<el-switch v-model="autoClassify" active-color="#e60012" />
</label>
<el-button type="danger" :icon="Upload" :loading="uploading" @click="fileInput?.click()">批量导入</el-button>
<el-button :icon="FolderChecked" :loading="uploading" @click="directoryInput?.click()">选择目录</el-button>
<el-button :icon="FolderOpened" :loading="serverImporting || runningImport" @click="importServerDirectory">服务端导入</el-button>
<el-button :icon="Refresh" :loading="loading" @click="loadOverview">刷新</el-button>
</div>
</section>
<section v-if="uploadBatch.length || enqueueFailures.length" class="panel upload-batch-panel">
<div class="panel-title batch-title">
<h2>本次批量上传</h2>
<div class="batch-summary">
<el-tag v-if="batchCounts.queued" size="small" type="info">排队 {{ batchCounts.queued }}</el-tag>
<el-tag v-if="batchCounts.processing" size="small">加工中 {{ batchCounts.processing }}</el-tag>
<el-tag v-if="batchCounts.done" size="small" type="success">完成 {{ batchCounts.done }}</el-tag>
<el-tag v-if="batchCounts.failed" size="small" type="danger">失败 {{ batchCounts.failed }}</el-tag>
<el-button v-if="!batchActive" link size="small" @click="dismissBatch">收起</el-button>
</div>
</div>
<el-progress
:percentage="batchPercent"
:status="batchActive ? undefined : batchCounts.failed > 0 ? 'exception' : 'success'"
:stroke-width="10"
/>
<ul v-if="enqueueFailures.length" class="enqueue-failures">
<li v-for="text in enqueueFailures" :key="text">提交失败:{{ text }}</li>
</ul>
<el-table v-if="uploadBatch.length" :data="uploadBatch" size="small" max-height="260" class="batch-table">
<el-table-column prop="fileName" label="文件名" min-width="220" show-overflow-tooltip />
<el-table-column label="状态" width="96">
<template #default="{ row }">
<el-tag size="small" :type="batchItemTagType(row.status)">{{ row.statusLabel }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="fragmentCount" label="片段" width="70" />
<el-table-column prop="error" label="说明" min-width="200" show-overflow-tooltip />
<el-table-column prop="updatedAt" label="更新时间" width="130" />
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button v-if="row.status === 3" link type="danger" size="small" :loading="retryingId === row.id" @click="retryBatchItem(row)">
重试
</el-button>
</template>
</el-table-column>
</el-table>
</section>
<section class="metric-grid">
<article v-for="metric in displayOverview.metrics" :key="metric.key" class="metric-card" :class="`metric-${metric.type}`">
<div class="metric-icon">
<el-icon><component :is="metricIcon(metric.key)" /></el-icon>
</div>
<div>
<span>{{ metric.label }}</span>
<strong>{{ metric.value }}</strong>
<small>{{ metric.note }}</small>
</div>
</article>
</section>
<section class="main-grid">
<aside class="panel category-panel">
<div class="panel-title">
<h2>资料分类</h2>
</div>
<el-input v-model="categoryKeyword" class="category-search" placeholder="搜索分类名称" :prefix-icon="Search" clearable />
<div class="category-list">
<button
v-for="category in filteredCategories"
:key="category.name"
class="category-item"
:class="{ active: category.name === selectedCategory }"
@click="selectedCategory = category.name"
>
<span class="category-icon" :class="`category-${category.type}`">{{ categoryShort(category.name) }}</span>
<span>
<strong>{{ category.name }}</strong>
<small>{{ category.files }} 个文件 {{ category.size }}</small>
</span>
</button>
</div>
</aside>
<main class="panel task-panel">
<div class="panel-title task-title">
<h2>解析任务</h2>
<el-select v-model="fileType" class="type-select" size="small">
<el-option v-for="item in fileTypes" :key="item" :label="`文件类型:${item}`" :value="item" />
</el-select>
</div>
<div class="task-tabs">
<button v-for="tab in statusTabs" :key="tab" type="button" :class="{ active: activeStatus === tab }" @click="activeStatus = tab">
{{ tab }}
</button>
</div>
<el-table v-loading="loading" :data="filteredTasks" class="task-table" size="small">
<el-table-column prop="fileName" label="文件名" min-width="210">
<template #default="{ row }">
<div class="file-cell">
<span class="file-badge" :class="fileBadgeClass(row.fileType)">{{ fileShort(row.fileType) }}</span>
<span>{{ row.fileName }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="fileType" label="类型" width="78" />
<el-table-column prop="size" label="大小" width="90" />
<el-table-column label="解析状态" width="132">
<template #default="{ row }">
<span class="status-pill" :class="`pill-${row.statusType}`">{{ row.status }}</span>
<el-progress v-if="row.status === '解析中'" :percentage="row.progress" :show-text="false" :stroke-width="5" />
</template>
</el-table-column>
<el-table-column prop="fragments" label="片段数" width="88">
<template #default="{ row }">{{ row.fragments || '-' }}</template>
</el-table-column>
<el-table-column label="向量化" width="110">
<template #default="{ row }">
<span class="status-pill" :class="`pill-${row.vectorType}`">{{ row.vectorStatus }}</span>
</template>
</el-table-column>
<el-table-column label="标签" min-width="150">
<template #default="{ row }">
<div class="tag-list">
<span v-for="tag in row.tags" :key="tag" class="doc-tag">{{ tag }}</span>
<span v-if="!row.tags?.length">-</span>
</div>
</template>
</el-table-column>
<el-table-column prop="summary" label="AI摘要" min-width="230" show-overflow-tooltip />
<el-table-column prop="updatedAt" label="更新时间" width="138" />
</el-table>
<footer class="table-footer">
<span>共 {{ filteredTasks.length }} 条</span>
<el-pagination small background layout="prev, pager, next, sizes" :total="filteredTasks.length" :page-size="10" />
</footer>
</main>
<aside class="side-stack">
<section class="panel chain-panel">
<div class="panel-title">
<h2>处理链路</h2>
</div>
<div class="chain-list">
<div v-for="step in displayOverview.pipeline" :key="step.name" class="chain-item" :class="step.status">
<span class="chain-dot"></span>
<strong>{{ step.name }}</strong>
<small>{{ step.value }}</small>
</div>
</div>
</section>
<section class="panel risk-panel">
<div class="panel-title">
<h2>规则与风险</h2>
</div>
<div class="rule-list">
<label>
<span>PDF/Word/Excel/PPT 解析</span>
<el-switch :model-value="displayOverview.rules.parserEnabled" disabled active-color="#e60012" />
</label>
<label :class="displayOverview.rules.imageOcrEnabled ? '' : 'rule-item-disabled'">
<span :class="displayOverview.rules.imageOcrEnabled ? '' : 'rule-label-muted'">
图片 OCR
<el-tag v-if="!displayOverview.rules.imageOcrEnabled" size="small" type="info" class="coming-soon-tag">即将支持</el-tag>
</span>
<el-switch
:model-value="displayOverview.rules.imageOcrEnabled"
disabled
:active-color="displayOverview.rules.imageOcrEnabled ? '#e60012' : undefined"
/>
</label>
<label>
<span>低置信回答不编造</span>
<el-switch :model-value="displayOverview.rules.lowConfidenceRefusal" disabled active-color="#e60012" />
</label>
<label>
<span>引用必须带片段来源</span>
<el-switch :model-value="displayOverview.rules.citationRequired" disabled active-color="#e60012" />
</label>
</div>
<div class="risk-tip">
<el-icon><WarningFilled /></el-icon>
<span>失败任务 {{ displayOverview.rules.failedTasks }} 个,建议先重试网络/格式异常文件</span>
</div>
</section>
</aside>
</section>
<section v-if="importTasks.length > 0 || importTaskLoading" class="panel import-panel">
<div class="panel-title compact">
<h2>导入任务</h2>
<el-button :icon="Refresh" :loading="importTaskLoading" size="small" @click="loadImportTasks">刷新</el-button>
</div>
<el-table v-loading="importTaskLoading" :data="importTasks" size="small" class="import-table">
<el-table-column prop="directory" label="目录" min-width="180" />
<el-table-column prop="category" label="分类" width="120" />
<el-table-column label="状态" width="110">
<template #default="{ row }">
<span class="status-pill" :class="`pill-${row.statusType}`">{{ row.status }}</span>
</template>
</el-table-column>
<el-table-column label="进度" min-width="180">
<template #default="{ row }">
<el-progress :percentage="row.progress" :stroke-width="6" />
</template>
</el-table-column>
<el-table-column label="文件" width="150">
<template #default="{ row }">
<span>{{ row.importedFiles }}/{{ row.totalFiles }}</span>
<span v-if="row.failedFiles > 0" class="failed-count">失败 {{ row.failedFiles }}</span>
</template>
</el-table-column>
<el-table-column prop="currentFile" label="当前文件" min-width="220" show-overflow-tooltip />
<el-table-column prop="updatedAt" label="更新时间" width="138" />
<el-table-column label="操作" width="104">
<template #default="{ row }">
<el-button v-if="row.status === '导入中'" link type="danger" @click="cancelImportTask(row)">取消</el-button>
<el-button v-if="row.status !== '导入中'" link type="primary" @click="retryImportTask(row)">重试</el-button>
</template>
</el-table-column>
</el-table>
</section>
<section class="panel event-panel">
<div class="panel-title compact">
<h2>最近事件</h2>
</div>
<el-table :data="displayOverview.events" size="small" class="event-table">
<el-table-column prop="time" label="时间" width="170" />
<el-table-column prop="taskNo" label="任务编号" width="170" />
<el-table-column prop="fileName" label="文件名" min-width="220" />
<el-table-column prop="event" label="事件" width="120" />
<el-table-column label="结果" width="90">
<template #default="{ row }">
<span class="status-pill" :class="`pill-${row.resultType}`">{{ row.result }}</span>
</template>
</el-table-column>
<el-table-column prop="detail" label="详情" min-width="260" />
</el-table>
</section>
</div>
</template>
<script setup name="KnowledgeProcessing" lang="ts">
import {
CircleCheck,
Document,
Files,
FolderChecked,
FolderOpened,
Operation,
Refresh,
Search,
Upload,
WarningFilled
} from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import {
cancelLocalKnowledgeImportTask,
getProcessingOverview,
listLocalKnowledgeImportTasks,
startLocalKnowledgeImportTask,
type LocalImportTask,
type ProcessingOverview
} from '@/api/aihr/processing';
import { uploadKnowledgeDocAsync, listUploadItems, retryUploadItem, type UploadItem } from '@/api/aihr/sop';
const acceptTypes = '.txt,.md,.markdown,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.gif,.webp,.bmp,.mp4,.mov,.avi,.mkv,.webm,.m4v';
const autoCategory = '__auto__';
const supportedPattern = /\.(txt|md|markdown|pdf|doc|docx|xls|xlsx|ppt|pptx|jpg|jpeg|png|gif|webp|bmp|mp4|mov|avi|mkv|webm|m4v)$/i;
const videoPattern = /\.(mp4|mov|avi|mkv|webm|m4v)$/i;
const maxFileSize = 100 * 1024 * 1024;
const maxVideoSize = 500 * 1024 * 1024;
const browserBatchWarnCount = 20;
const browserBatchWarnSize = 200 * 1024 * 1024;
const fileInput = ref<HTMLInputElement>();
const directoryInput = ref<HTMLInputElement>();
const loading = ref(false);
const uploading = ref(false);
const serverImporting = ref(false);
const importTaskLoading = ref(false);
const overview = ref<ProcessingOverview | null>(null);
const overviewFailed = ref(false);
const importTasks = ref<LocalImportTask[]>([]);
const categoryKeyword = ref('');
const selectedCategory = ref('住宅 SOP');
const autoClassify = ref(true);
const activeStatus = ref('全部');
const fileType = ref('全部');
const uploadBatch = ref<UploadItem[]>([]);
const uploadBatchId = ref('');
const enqueueFailures = ref<string[]>([]);
const retryingId = ref<number | null>(null);
let importPollTimer: ReturnType<typeof window.setTimeout> | undefined;
let batchPollTimer: ReturnType<typeof window.setTimeout> | undefined;
const statusTabs = ['全部', '等待解析', '解析中', '已完成', '失败'];
const fileTypes = ['全部', 'Word', 'PDF', 'PPT', 'Excel', '图片', '视频'];
const seedOverview: ProcessingOverview = {
metrics: [
{ key: 'total', label: '资料总量', value: '3.2 GB', note: '已接入 1,286 个文件', type: 'danger' },
{ key: 'completed', label: '已完成', value: '1,148', note: '较昨日 ↑128 (12.6%)', type: 'success' },
{ key: 'processing', label: '处理中', value: '36', note: '进行中任务', type: 'primary' },
{ key: 'failed', label: '失败', value: '7', note: '较昨日 ↑2 (22.2%)', type: 'warning' }
],
categories: [
{ name: '住宅 SOP', files: 368, size: '2.1 GB', icon: 'folder', type: 'danger' },
{ name: '规章制度', files: 312, size: '645 MB', icon: 'document', type: 'primary' },
{ name: '培训课件', files: 218, size: '512 MB', icon: 'ppt', type: 'warning' },
{ name: '历史案例', files: 256, size: '358 MB', icon: 'case', type: 'success' },
{ name: '图片素材', files: 132, size: '89 MB', icon: 'image', type: 'success' }
],
tasks: [
task(
1,
'住宅 SOP',
'住宅客服投诉处理 SOP.docx',
'Word',
'2.4MB',
'已完成',
'success',
100,
36,
'已入库',
'success',
'2026-07-02 14:32',
'详情',
''
),
task(2, '住宅 SOP', '物业收费催缴流程.pdf', 'PDF', '8.1MB', '解析中', 'primary', 62, 0, '待向量化', 'warning', '2026-07-02 14:28', '查看', ''),
task(3, '培训课件', '巡检标准培训课件.pptx', 'PPT', '42MB', '等待解析', 'warning', 0, 0, '待处理', 'warning', '2026-07-02 14:25', '开始', ''),
task(4, '图片素材', '工单照片归档.zip', '图片', '286MB', 'OCR排队', 'primary', 0, 0, '待处理', 'warning', '2026-07-02 14:20', '查看', ''),
task(
5,
'规章制度',
'交付验房记录.xlsx',
'Excel',
'1.8MB',
'失败',
'danger',
0,
0,
'失败原因',
'danger',
'2026-07-02 14:18',
'重试',
'格式解析异常'
)
],
pipeline: [
{ name: '上传到 MinIO', value: '1,286 / 1,286', status: 'done' },
{ name: 'Tika 解析', value: '1,182 / 1,286', status: 'done' },
{ name: '文本切片', value: '95,861 片段', status: 'done' },
{ name: 'Embedding', value: '92,314 向量', status: 'done' },
{ name: 'Qdrant 入库', value: '89,102 向量', status: 'active' },
{ name: '可引用问答', value: '-', status: 'pending' }
],
events: [
{
time: '2026-07-02 14:32',
taskNo: 'TASK-20260702-0142',
fileName: '住宅客服投诉处理 SOP.docx',
event: '向量化入库',
result: '成功',
resultType: 'success',
detail: '生成 36 个片段,入库 36 个向量'
},
{
time: '2026-07-02 14:28',
taskNo: 'TASK-20260702-0141',
fileName: '物业收费催缴流程.pdf',
event: 'Tika 解析',
result: '进行中',
resultType: 'primary',
detail: '解析中 62%,预计剩余 00:01:24'
},
{
time: '2026-07-02 14:18',
taskNo: 'TASK-20260702-0140',
fileName: '交付验房记录.xlsx',
event: '解析失败',
result: '失败',
resultType: 'danger',
detail: '格式解析异常:单元格内容无法识别'
}
],
rules: {
parserEnabled: true,
imageOcrEnabled: false,
lowConfidenceRefusal: true,
citationRequired: true,
failedTasks: 7
}
};
const emptyOverview: ProcessingOverview = {
metrics: [
{ key: 'total', label: '资料总量', value: '0', note: '暂无导入文件', type: 'danger' },
{ key: 'completed', label: '已完成', value: '0', note: '等待真实上传', type: 'success' },
{ key: 'processing', label: '处理中', value: '0', note: '暂无任务', type: 'primary' },
{ key: 'failed', label: '失败', value: '0', note: '暂无失败', type: 'warning' }
],
categories: [],
tasks: [],
pipeline: [
{ name: '上传到 MinIO', value: '0 / 0', status: 'pending' },
{ name: 'Tika 解析', value: '0 / 0', status: 'pending' },
{ name: '文本切片', value: '0 片段', status: 'pending' },
{ name: 'Embedding', value: '0 向量', status: 'pending' },
{ name: 'Qdrant 入库', value: '0 向量', status: 'pending' },
{ name: '可引用问答', value: '-', status: 'pending' }
],
events: [],
rules: {
parserEnabled: true,
imageOcrEnabled: false,
lowConfidenceRefusal: true,
citationRequired: true,
failedTasks: 0
}
};
const displayOverview = computed(() => overview.value ?? (overviewFailed.value ? seedOverview : emptyOverview));
const filteredCategories = computed(() => {
const keyword = categoryKeyword.value.trim().toLowerCase();
return displayOverview.value.categories.filter((item) => !keyword || item.name.toLowerCase().includes(keyword));
});
const filteredTasks = computed(() => displayOverview.value.tasks.filter((item) => matchStatus(item.status) && matchFileType(item.fileType)));
const runningImport = computed(() => importTasks.value.some((item) => item.status === '导入中'));
onMounted(async () => {
await Promise.all([loadOverview(), loadImportTasks()]);
scheduleImportPoll();
});
onUnmounted(() => {
clearImportPoll();
clearBatchPoll();
// 置空批次 ID,让在途的轮询请求返回后直接丢弃,不再重新挂起定时器
uploadBatchId.value = '';
});
async function loadOverview() {
loading.value = true;
try {
const { data } = await getProcessingOverview();
overview.value = data;
overviewFailed.value = false;
selectedCategory.value = data.categories[0]?.name || selectedCategory.value;
} catch {
overview.value = null;
overviewFailed.value = true;
} finally {
loading.value = false;
}
}
async function handlePickedFiles(event: Event) {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files || []);
input.value = '';
await uploadFiles(files);
}
async function uploadFiles(files: File[]) {
const readyFiles = files.filter(
(file) => supportedPattern.test(file.name) && file.size > 0 && file.size <= (videoPattern.test(file.name) ? maxVideoSize : maxFileSize)
);
if (readyFiles.length === 0) {
ElMessage.warning('仅支持非空的 txt/md/pdf/Word/Excel/PPT/图片(100MB内)或视频(500MB内)文件');
return;
}
if (readyFiles.length < files.length) {
ElMessage.warning(`已跳过 ${files.length - readyFiles.length} 个不支持、空文件或超限文件(文档/图片 100MB、视频 500MB)`);
}
const totalSize = readyFiles.reduce((sum, file) => sum + file.size, 0);
if (readyFiles.length > browserBatchWarnCount || totalSize > browserBatchWarnSize) {
ElMessage.warning('浏览器批量适合少量文件;大批量资料请放到导入根目录后用服务端导入任务');
}
uploading.value = true;
clearBatchPoll();
const batchId = `b-${window.crypto?.randomUUID?.() ?? Date.now()}`;
uploadBatchId.value = batchId;
uploadBatch.value = [];
enqueueFailures.value = [];
let queued = 0;
try {
for (const file of readyFiles) {
const form = new FormData();
form.append('file', file);
form.append('category', categoryPayload());
form.append('batchId', batchId);
try {
await uploadKnowledgeDocAsync(form);
queued++;
} catch (error) {
enqueueFailures.value.push(`${file.name}: ${uploadFailureMessage(error)}`);
}
}
if (queued > 0) {
ElMessage.success(`已提交 ${queued} 个文件,后台加工中`);
await pollUploadBatch();
} else if (enqueueFailures.value.length > 0) {
ElMessage.error(`提交失败:${enqueueFailures.value[0]}`);
}
} finally {
uploading.value = false;
}
}
async function pollUploadBatch() {
const polledBatch = uploadBatchId.value;
if (!polledBatch) return;
let items: UploadItem[] | null = null;
try {
const { data } = await listUploadItems(polledBatch);
items = data || [];
} catch {
// 单次轮询失败不清空面板,等下一轮
}
// 请求在途期间批次可能已被收起或替换,丢弃过期结果,避免重新挂起定时器
if (uploadBatchId.value !== polledBatch) return;
if (items) {
uploadBatch.value = items;
}
const active = uploadBatch.value.some((item) => item.status === 0 || item.status === 1);
clearBatchPoll();
if (active || uploadBatch.value.length === 0) {
batchPollTimer = window.setTimeout(() => void pollUploadBatch(), 2000);
} else {
await loadOverview();
}
}
function clearBatchPoll() {
if (batchPollTimer) {
window.clearTimeout(batchPollTimer);
batchPollTimer = undefined;
}
}
function dismissBatch() {
clearBatchPoll();
uploadBatch.value = [];
uploadBatchId.value = '';
enqueueFailures.value = [];
}
async function retryBatchItem(item: UploadItem) {
retryingId.value = item.id;
try {
await retryUploadItem(item.id);
ElMessage.success(`已重新排队:${item.fileName}`);
await pollUploadBatch();
} catch (error) {
ElMessage.error(uploadFailureMessage(error));
} finally {
retryingId.value = null;
}
}
const batchCounts = computed(() => ({
queued: uploadBatch.value.filter((item) => item.status === 0).length,
processing: uploadBatch.value.filter((item) => item.status === 1).length,
done: uploadBatch.value.filter((item) => item.status === 2).length,
failed: uploadBatch.value.filter((item) => item.status === 3).length
}));
const batchActive = computed(() => batchCounts.value.queued > 0 || batchCounts.value.processing > 0);
const batchPercent = computed(() => {
const total = uploadBatch.value.length;
if (total === 0) return 0;
return Math.round(((batchCounts.value.done + batchCounts.value.failed) / total) * 100);
});
function batchItemTagType(status: number) {
if (status === 2) return 'success';
if (status === 3) return 'danger';
if (status === 1) return 'primary';
return 'info';
}
function uploadFailureMessage(error: unknown) {
if (error instanceof Error && error.message) return error.message;
if (typeof error === 'string' && error) return error;
return '上传失败';
}
async function importServerDirectory() {
let directory = '.';
try {
const result = await ElMessageBox.prompt('只允许导入后端 AIHR_IMPORT_ROOT / aihr.import.root 下的相对目录。', '服务端目录导入', {
confirmButtonText: '开始导入',
cancelButtonText: '取消',
inputValue: '.',
inputPlaceholder: '例如:住宅SOP 或 .'
});
directory = String(result.value || '.').trim() || '.';
} catch {
return;
}
serverImporting.value = true;
try {
const { data } = await startLocalKnowledgeImportTask({ directory, category: categoryPayload(), limit: 500 });
ElMessage.success(`导入任务已启动:${data.totalFiles} 个文件`);
await Promise.all([loadImportTasks(), loadOverview()]);
scheduleImportPoll();
} finally {
serverImporting.value = false;
}
}
async function loadImportTasks() {
importTaskLoading.value = true;
try {
const { data } = await listLocalKnowledgeImportTasks();
importTasks.value = data || [];
} catch {
importTasks.value = [];
} finally {
importTaskLoading.value = false;
}
}
async function retryImportTask(row: LocalImportTask) {
serverImporting.value = true;
try {
await startLocalKnowledgeImportTask({ directory: row.directory, category: row.category, limit: 500 });
ElMessage.success('已重新启动导入任务');
await loadImportTasks();
scheduleImportPoll();
} finally {
serverImporting.value = false;
}
}
async function cancelImportTask(row: LocalImportTask) {
try {
await ElMessageBox.confirm(`确认取消导入任务「${row.directory}」?`, '取消导入', {
confirmButtonText: '取消任务',
cancelButtonText: '返回',
type: 'warning'
});
} catch {
return;
}
importTaskLoading.value = true;
try {
await cancelLocalKnowledgeImportTask(row.id);
ElMessage.success('已取消导入任务');
await Promise.all([loadImportTasks(), loadOverview()]);
} finally {
importTaskLoading.value = false;
}
}
function scheduleImportPoll() {
clearImportPoll();
if (!runningImport.value) return;
importPollTimer = window.setTimeout(async () => {
await Promise.all([loadImportTasks(), loadOverview()]);
scheduleImportPoll();
}, 2500);
}
function clearImportPoll() {
if (importPollTimer) {
window.clearTimeout(importPollTimer);
importPollTimer = undefined;
}
}
function matchStatus(status: string) {
if (activeStatus.value === '全部') return true;
if (activeStatus.value === '等待解析') return ['等待解析', 'OCR排队'].includes(status);
return status === activeStatus.value;
}
function matchFileType(type: string) {
return fileType.value === '全部' || type === fileType.value;
}
function task(
id: number,
category: string,
fileName: string,
fileType: string,
size: string,
status: string,
statusType: string,
progress: number,
fragments: number,
vectorStatus: string,
vectorType: string,
updatedAt: string,
action: string,
remark: string,
summary = '',
tags: string[] = [],
classifyReason = ''
): ProcessingTask {
return {
id,
category,
fileName,
fileType,
size,
status,
statusType,
progress,
fragments,
vectorStatus,
vectorType,
updatedAt,
action,
remark,
summary,
tags,
classifyReason
};
}
function categoryPayload() {
return autoClassify.value ? autoCategory : selectedCategory.value;
}
function metricIcon(key: string) {
if (key === 'completed') return CircleCheck;
if (key === 'processing') return Operation;
if (key === 'failed') return WarningFilled;
return Files;
}
function categoryShort(name: string) {
if (name.includes('SOP')) return 'S';
if (name.includes('规章')) return '规';
if (name.includes('培训')) return 'P';
if (name.includes('图片')) return '图';
return '案';
}
function fileShort(type: string) {
if (type === 'Word') return 'W';
if (type === 'PDF') return 'P';
if (type === 'Excel') return 'X';
if (type === 'PPT') return 'P';
if (type === '图片') return '图';
if (type === '视频') return '视';
return 'T';
}
function fileBadgeClass(type: string) {
if (type === 'Word') return 'word';
if (type === 'PDF') return 'pdf';
if (type === 'Excel') return 'excel';
if (type === 'PPT') return 'ppt';
if (type === '图片') return 'image';
if (type === '视频') return 'ppt';
return 'text';
}
</script>
<style scoped lang="scss">
.processing-page {
min-height: calc(100vh - 84px);
padding: 18px 22px;
background: #f4f6fa;
color: #162033;
}
.hidden-input {
display: none;
}
.page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 18px;
margin-bottom: 14px;
h1 {
margin: 0 0 6px;
font-size: 24px;
line-height: 1.2;
}
p {
margin: 0;
color: #657184;
font-size: 13px;
}
}
.head-actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.auto-classify-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 32px;
padding: 0 10px;
border: 1px solid #e3e8f1;
border-radius: 6px;
background: #fff;
color: #243248;
font-weight: 700;
white-space: nowrap;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
margin-bottom: 14px;
}
.metric-card,
.panel {
border: 1px solid #e3e8f1;
border-radius: 8px;
background: #fff;
box-shadow: 0 10px 24px rgba(20, 30, 46, 0.05);
}
.metric-card {
display: flex;
align-items: center;
gap: 18px;
min-height: 110px;
padding: 20px 24px;
span,
small {
display: block;
color: #657184;
}
strong {
display: block;
margin: 5px 0;
font-size: 30px;
line-height: 1;
color: #152033;
}
}
.metric-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 54px;
height: 54px;
border-radius: 50%;
font-size: 28px;
color: #fff;
}
.metric-danger .metric-icon {
background: linear-gradient(135deg, #e60012, #f05b62);
}
.metric-success .metric-icon {
background: linear-gradient(135deg, #21a964, #34c985);
}
.metric-primary .metric-icon {
background: linear-gradient(135deg, #1b7fea, #53a8ff);
}
.metric-warning .metric-icon {
background: linear-gradient(135deg, #ff7a00, #ffb044);
}
.main-grid {
display: grid;
grid-template-columns: 250px minmax(620px, 1fr) 340px;
gap: 14px;
align-items: start;
}
.import-panel {
margin-top: 14px;
}
.import-table {
width: 100%;
}
.failed-count {
margin-left: 8px;
color: #e60012;
}
.panel {
padding: 16px;
}
.upload-batch-panel {
margin-bottom: 14px;
.batch-title {
margin-bottom: 10px;
}
.batch-summary {
display: flex;
align-items: center;
gap: 8px;
}
.batch-table {
margin-top: 12px;
}
.enqueue-failures {
margin: 10px 0 0;
padding-left: 18px;
color: #d4380d;
font-size: 12px;
line-height: 1.8;
word-break: break-all;
}
}
.panel-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
h2 {
margin: 0;
font-size: 18px;
font-weight: 800;
}
}
.category-search {
margin-bottom: 12px;
}
.category-list {
display: grid;
gap: 8px;
}
.category-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
min-height: 68px;
border: 0;
border-left: 3px solid transparent;
border-radius: 8px;
background: #fff;
padding: 10px 12px;
color: #243248;
text-align: left;
cursor: pointer;
strong,
small {
display: block;
}
small {
margin-top: 4px;
color: #657184;
}
&.active {
border-left-color: #e60012;
background: linear-gradient(90deg, #fff0f1, #fff);
}
}
.category-icon,
.file-badge {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
border-radius: 6px;
font-weight: 800;
}
.category-icon {
width: 32px;
height: 32px;
}
.category-danger,
.file-badge.pdf {
color: #e60012;
background: #ffe8ea;
}
.category-primary,
.file-badge.word {
color: #1b73d8;
background: #e8f2ff;
}
.category-warning,
.file-badge.ppt {
color: #e56f00;
background: #fff0df;
}
.category-success,
.file-badge.excel,
.file-badge.image,
.file-badge.text {
color: #159457;
background: #e6f7ee;
}
.task-title {
align-items: center;
}
.type-select {
width: 142px;
}
.task-tabs {
display: flex;
gap: 24px;
border-bottom: 1px solid #edf0f5;
margin-bottom: 12px;
button {
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
padding: 0 0 10px;
color: #526176;
font-weight: 700;
cursor: pointer;
&.active {
border-color: #e60012;
color: #e60012;
}
}
}
.task-table,
.event-table {
border: 1px solid #edf0f5;
border-radius: 8px;
overflow: hidden;
:deep(.el-table__inner-wrapper::before) {
display: none;
}
}
.file-cell {
display: flex;
align-items: center;
gap: 8px;
}
.file-badge {
width: 22px;
height: 22px;
font-size: 12px;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.doc-tag {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 7px;
border-radius: 6px;
background: #f0f6ff;
color: #1b73d8;
font-size: 12px;
font-weight: 700;
}
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 24px;
padding: 0 8px;
border-radius: 6px;
font-size: 12px;
line-height: 24px;
font-weight: 800;
white-space: nowrap;
}
.pill-success {
color: #15844a;
background: #e5f8ee;
}
.pill-primary {
color: #1467bd;
background: #e7f2ff;
}
.pill-warning {
color: #c46b00;
background: #fff2dc;
}
.pill-danger {
color: #d71920;
background: #ffe8ea;
}
.pill-info {
color: #657184;
background: #eef2f7;
}
.table-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12px;
color: #657184;
}
.side-stack {
display: grid;
gap: 14px;
}
.chain-list {
display: grid;
gap: 8px;
}
.chain-item {
position: relative;
display: grid;
grid-template-columns: 18px 1fr auto;
align-items: center;
gap: 10px;
min-height: 30px;
strong {
font-size: 14px;
}
small {
color: #657184;
}
}
.chain-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #c8d0dc;
}
.chain-item.done .chain-dot {
background: #24b66b;
}
.chain-item.active .chain-dot {
background: #1b7fea;
}
.rule-list {
display: grid;
gap: 10px;
label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: #334155;
}
}
.rule-item-disabled {
opacity: 0.5;
cursor: not-allowed;
}
.rule-label-muted {
display: flex;
align-items: center;
gap: 6px;
color: #94a3b8;
}
.coming-soon-tag {
font-size: 11px;
padding: 0 5px;
height: 18px;
line-height: 18px;
}
.risk-tip {
display: flex;
gap: 8px;
align-items: center;
margin-top: 16px;
border-radius: 8px;
background: #fff0f1;
padding: 10px;
color: #e60012;
font-weight: 700;
}
.event-panel {
margin-top: 14px;
}
.compact {
margin-bottom: 10px;
}
@media (max-width: 1500px) {
.main-grid {
grid-template-columns: 240px minmax(540px, 1fr);
}
.side-stack {
grid-column: 1 / -1;
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 1080px) {
.metric-grid,
.main-grid,
.side-stack {
grid-template-columns: 1fr;
}
.page-head {
align-items: flex-start;
flex-direction: column;
}
}
</style>