Files
prop-ai-hr/frontend/src/views/knowledge/processing.vue
T
admin 2488febf19 Merge branch 'codex/personal-assistant-phase2' into codex/multi-tenant-knowledge-platform
# Conflicts:
#	backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java
#	backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java
#	backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java
#	backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrModelSeedServiceTest.java
#	backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java
#	docs/API_INTEGRATION.md
#	docs/个人AI助理阶段二专项TechSpec.md
#	docs/个人AI助理阶段二开发推进计划.md
#	frontend/src/views/knowledge/processing.vue
#	mobile-uni/src/pages/user/sop/index.vue
#	mobile-uni/src/services/api.ts
#	mobile-uni/src/services/personal-assistant.ts
#	mobile-uni/tests/personal-assistant.test.mjs
#	scripts/demo-check.sh
#	scripts/personal-assistant-smoke.sh
2026-07-22 08:54:04 +08:00

1215 lines
34 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" />
<section class="page-head">
<div>
<h1>资料处理中心</h1>
<p>批量导入业务资料,跟踪解析、切片、向量化与入库状态。</p>
</div>
<div class="head-actions">
<el-select v-model="uploadSpaceCodes" multiple collapse-tags placeholder="选择目标知识空间" style="width: 280px">
<el-option v-for="space in manageableSpaces" :key="space.code" :label="space.name" :value="space.code" />
</el-select>
<el-button type="danger" :icon="Upload" :loading="uploading" @click="fileInput?.click()">批量导入</el-button>
<el-button :icon="Refresh" :loading="loading" @click="loadOverview">刷新</el-button>
</div>
</section>
<el-alert
v-if="overviewFailed"
class="demo-data-alert"
title="资料处理接口暂不可用,当前显示演示数据,不代表真实库统计"
type="warning"
:closable="false"
/>
<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="目标空间" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.spaceCodes?.join('、') || '-' }}</template></el-table-column>
<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" @change="resetTaskPage">
<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="selectTaskStatus(tab)">
{{ tab }}
</button>
</div>
<el-table v-loading="loading" :data="pagedTasks" 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
v-model:current-page="taskPage"
v-model:page-size="taskPageSize"
small
background
layout="prev, pager, next, sizes"
:page-sizes="[10, 20, 50]"
:total="filteredTasks.length"
/>
</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="rule-status-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 v-if="displayOverview.rules.failedTasks > 0" class="risk-tip">
<el-icon><WarningFilled /></el-icon>
<span>失败任务 {{ displayOverview.rules.failedTasks }} 个,建议先重试网络/格式异常文件</span>
</div>
</section>
</aside>
</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>
<section class="panel event-panel">
<div class="panel-title compact">
<h2>个人资料入库审核</h2>
<el-button link :loading="publishLoading" @click="loadPublishRequests">刷新</el-button>
</div>
<el-table :data="publishRequests" size="small" empty-text="暂无待审核申请">
<el-table-column prop="title" label="资料" min-width="200" show-overflow-tooltip />
<el-table-column prop="sourceType" label="来源" width="90" />
<el-table-column prop="targetScope" label="目标范围" min-width="140" />
<el-table-column prop="reason" label="申请说明" min-width="220" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="90" />
<el-table-column label="操作" width="150">
<template #default="{ row }">
<el-button v-if="row.status === 'PENDING'" link type="success" @click="reviewPublish(row, true)">批准</el-button>
<el-button v-if="row.status === 'PENDING'" link type="danger" @click="reviewPublish(row, false)">驳回</el-button>
</template>
</el-table-column>
</el-table>
</section>
</div>
</template>
<script setup name="KnowledgeProcessing" lang="ts">
import { CircleCheck, Document, Files, Operation, Refresh, Search, Upload, WarningFilled } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { getProcessingOverview, type ProcessingOverview, type ProcessingTask } from '@/api/aihr/processing';
import { uploadKnowledgeDocAsync, listUploadItems, retryUploadItem, type UploadItem } from '@/api/aihr/sop';
import { listKnowledgeSpaces, type KnowledgeSpace } from '@/api/aihr/space';
import { listPersonalPublishRequests, reviewPersonalPublishRequest, type PersonalPublishRequestRow } from '@/api/aihr/personal';
const acceptTypes = '.txt,.md,.markdown,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.gif,.webp,.bmp,.mp4,.mov,.avi,.mkv,.webm,.m4v,.zip';
const supportedPattern = /\.(txt|md|markdown|pdf|doc|docx|xls|xlsx|ppt|pptx|jpg|jpeg|png|gif|webp|bmp|mp4|mov|avi|mkv|webm|m4v|zip)$/i;
const videoPattern = /\.(mp4|mov|avi|mkv|webm|m4v)$/i;
const archivePattern = /\.zip$/i;
const maxFileSize = 100 * 1024 * 1024;
const maxVideoSize = 500 * 1024 * 1024;
const maxArchiveSize = 500 * 1024 * 1024;
const browserBatchWarnCount = 20;
const browserBatchWarnSize = 200 * 1024 * 1024;
const fileInput = ref<HTMLInputElement>();
const loading = ref(false);
const uploading = ref(false);
const publishLoading = ref(false);
const publishRequests = ref<PersonalPublishRequestRow[]>([]);
const overview = ref<ProcessingOverview | null>(null);
const overviewFailed = ref(false);
const categoryKeyword = ref('');
const selectedCategory = ref('住宅 SOP');
const manageableSpaces = ref<KnowledgeSpace[]>([]);
const uploadSpaceCodes = ref<string[]>([]);
const activeStatus = ref('全部');
const fileType = ref('全部');
const taskPage = ref(1);
const taskPageSize = ref(10);
const uploadBatch = ref<UploadItem[]>([]);
const uploadBatchId = ref('');
const enqueueFailures = ref<string[]>([]);
const retryingId = ref<number | null>(null);
let batchPollTimer: number | 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 pagedTasks = computed(() => {
const start = (taskPage.value - 1) * taskPageSize.value;
return filteredTasks.value.slice(start, start + taskPageSize.value);
});
onMounted(async () => {
await Promise.all([loadOverview(), loadManageableSpaces(), loadPublishRequests()]);
});
async function loadPublishRequests() {
publishLoading.value = true;
try {
publishRequests.value = (await listPersonalPublishRequests()).data || [];
} catch {
publishRequests.value = [];
} finally {
publishLoading.value = false;
}
}
async function reviewPublish(row: PersonalPublishRequestRow, approved: boolean) {
try {
const { value } = await ElMessageBox.prompt(approved ? '填写批准意见' : '填写驳回原因', approved ? '批准入库' : '驳回入库', {
inputValidator: (input) => Boolean(input?.trim()) || '请填写审核意见'
});
await reviewPersonalPublishRequest(row.id, { approved, comment: value.trim() });
ElMessage.success(approved ? '已批准' : '已驳回');
await loadPublishRequests();
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error('审核失败,请重试');
}
}
onUnmounted(() => {
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;
resetTaskPage();
} catch {
overview.value = null;
overviewFailed.value = true;
} finally {
loading.value = false;
}
}
async function loadManageableSpaces() {
try {
manageableSpaces.value = ((await listKnowledgeSpaces()).data || []).filter((item) => item.status === 'ACTIVE');
uploadSpaceCodes.value = manageableSpaces.value.slice(0, 1).map((item) => item.code);
} catch {
manageableSpaces.value = [];
}
}
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[]) {
if (uploadSpaceCodes.value.length === 0) {
ElMessage.warning('请先选择至少一个可管理的知识空间');
return;
}
const readyFiles = files.filter(
(file) =>
supportedPattern.test(file.name) &&
file.size > 0 &&
file.size <= (videoPattern.test(file.name) ? maxVideoSize : archivePattern.test(file.name) ? maxArchiveSize : maxFileSize)
);
if (readyFiles.length === 0) {
ElMessage.warning('仅支持非空的 txt/md/pdf/Word/Excel/PPT/图片(100MB内)、视频或 ZIP(500MB内)文件');
return;
}
if (readyFiles.length < files.length) {
ElMessage.warning(`已跳过 ${files.length - readyFiles.length} 个不支持、空文件或超限文件(文档/图片 100MB、视频/ZIP 500MB)`);
}
const totalSize = readyFiles.reduce((sum, file) => sum + file.size, 0);
if (readyFiles.length > browserBatchWarnCount || totalSize > browserBatchWarnSize) {
ElMessage.warning('文件较多,建议压缩为 ZIP 后批量导入');
}
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);
uploadSpaceCodes.value.forEach((code) => form.append('spaceCodes', code));
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 '上传失败';
}
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 selectTaskStatus(status: string) {
activeStatus.value = status;
resetTaskPage();
}
function resetTaskPage() {
taskPage.value = 1;
}
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 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;
}
.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;
}
.rule-status-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>