1284 lines
36 KiB
Vue
1284 lines
36 KiB
Vue
<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>
|