# 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
1215 lines
34 KiB
Vue
1215 lines
34 KiB
Vue
<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>
|