feat: restore learning feedback and knowledge capabilities

This commit is contained in:
2026-07-18 00:50:11 +08:00
parent 876a23a123
commit 6bdc86e3fc
152 changed files with 30337 additions and 953 deletions
+55 -184
View File
@@ -1,7 +1,6 @@
<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>
@@ -9,13 +8,10 @@
<p>批量导入业务资料,跟踪解析、切片、向量化与入库状态。</p>
</div>
<div class="head-actions">
<label class="auto-classify-toggle">
<span>智能归类</span>
<el-switch v-model="autoClassify" active-color="#e60012" />
</label>
<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="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>
@@ -49,6 +45,7 @@
</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>
@@ -106,18 +103,18 @@
<main class="panel task-panel">
<div class="panel-title task-title">
<h2>解析任务</h2>
<el-select v-model="fileType" class="type-select" size="small">
<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="activeStatus = tab">
<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="filteredTasks" class="task-table" size="small">
<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">
@@ -156,7 +153,15 @@
<footer class="table-footer">
<span>共 {{ filteredTasks.length }} 条</span>
<el-pagination small background layout="prev, pager, next, sizes" :total="filteredTasks.length" :page-size="10" />
<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>
@@ -211,41 +216,6 @@
</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>
@@ -267,32 +237,14 @@
</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 { CircleCheck, Document, Files, Operation, Refresh, Search, Upload, WarningFilled } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import {
cancelLocalKnowledgeImportTask,
getProcessingOverview,
listLocalKnowledgeImportTasks,
startLocalKnowledgeImportTask,
type LocalImportTask,
type ProcessingOverview
} from '@/api/aihr/processing';
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';
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 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|zip)$/i;
const videoPattern = /\.(mp4|mov|avi|mkv|webm|m4v)$/i;
const archivePattern = /\.zip$/i;
@@ -303,25 +255,23 @@ 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 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 importPollTimer: ReturnType<typeof window.setTimeout> | undefined;
let batchPollTimer: ReturnType<typeof window.setTimeout> | undefined;
let batchPollTimer: number | undefined;
const statusTabs = ['全部', '等待解析', '解析中', '已完成', '失败'];
const fileTypes = ['全部', 'Word', 'PDF', 'PPT', 'Excel', '图片', '视频'];
@@ -456,15 +406,15 @@ const filteredCategories = computed(() => {
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 === '导入中'));
const pagedTasks = computed(() => {
const start = (taskPage.value - 1) * taskPageSize.value;
return filteredTasks.value.slice(start, start + taskPageSize.value);
});
onMounted(async () => {
await Promise.all([loadOverview(), loadImportTasks()]);
scheduleImportPoll();
await Promise.all([loadOverview(), loadManageableSpaces()]);
});
onUnmounted(() => {
clearImportPoll();
clearBatchPoll();
// 置空批次 ID,让在途的轮询请求返回后直接丢弃,不再重新挂起定时器
uploadBatchId.value = '';
@@ -477,6 +427,7 @@ async function loadOverview() {
overview.value = data;
overviewFailed.value = false;
selectedCategory.value = data.categories[0]?.name || selectedCategory.value;
resetTaskPage();
} catch {
overview.value = null;
overviewFailed.value = true;
@@ -485,6 +436,15 @@ async function loadOverview() {
}
}
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 || []);
@@ -493,6 +453,10 @@ async function handlePickedFiles(event: Event) {
}
async function uploadFiles(files: File[]) {
if (uploadSpaceCodes.value.length === 0) {
ElMessage.warning('请先选择至少一个可管理的知识空间');
return;
}
const readyFiles = files.filter(
(file) =>
supportedPattern.test(file.name) &&
@@ -509,7 +473,7 @@ async function uploadFiles(files: File[]) {
const totalSize = readyFiles.reduce((sum, file) => sum + file.size, 0);
if (readyFiles.length > browserBatchWarnCount || totalSize > browserBatchWarnSize) {
ElMessage.warning('浏览器批量适合少量文件;大批量资料请放到导入根目录后用服务端导入任务');
ElMessage.warning('文件较多,建议压缩为 ZIP 后批量导入');
}
uploading.value = true;
@@ -523,7 +487,7 @@ async function uploadFiles(files: File[]) {
for (const file of readyFiles) {
const form = new FormData();
form.append('file', file);
form.append('category', categoryPayload());
uploadSpaceCodes.value.forEach((code) => form.append('spaceCodes', code));
form.append('batchId', batchId);
try {
await uploadKnowledgeDocAsync(form);
@@ -622,91 +586,6 @@ function uploadFailureMessage(error: unknown) {
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);
@@ -717,6 +596,15 @@ 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,
@@ -757,10 +645,6 @@ function task(
};
}
function categoryPayload() {
return autoClassify.value ? autoCategory : selectedCategory.value;
}
function metricIcon(key: string) {
if (key === 'completed') return CircleCheck;
if (key === 'processing') return Operation;
@@ -921,19 +805,6 @@ function fileBadgeClass(type: string) {
align-items: start;
}
.import-panel {
margin-top: 14px;
}
.import-table {
width: 100%;
}
.failed-count {
margin-left: 8px;
color: #e60012;
}
.panel {
padding: 16px;
}