feat: restore learning feedback and knowledge capabilities
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<div class="head-actions">
|
||||
<span class="status-pill" :class="apiPill.type">{{ apiPill.label }}</span>
|
||||
<span class="status-pill" :class="flowStatus.type">{{ flowStatus.label }}</span>
|
||||
<el-select v-model="uploadSpaceCodes" multiple collapse-tags placeholder="选择上传空间" style="width: 230px">
|
||||
<el-option v-for="space in manageableSpaces" :key="space.code" :label="space.name" :value="space.code" />
|
||||
</el-select>
|
||||
<el-upload
|
||||
accept=".txt,.md,.markdown,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx"
|
||||
:auto-upload="false"
|
||||
@@ -183,6 +186,7 @@ import {
|
||||
type SopRecord,
|
||||
type SopSearchResponse
|
||||
} from '@/api/aihr/sop';
|
||||
import { listKnowledgeSpaces, type KnowledgeSpace } from '@/api/aihr/space';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -286,6 +290,8 @@ const pendingReviewStatus = ref('待复核');
|
||||
const pendingReviewLoading = ref(false);
|
||||
const reviewingFeedbackId = ref<number | null>(null);
|
||||
const pendingReviews = ref<AnswerFeedbackItem[]>([]);
|
||||
const manageableSpaces = ref<KnowledgeSpace[]>([]);
|
||||
const uploadSpaceCodes = ref<string[]>([]);
|
||||
|
||||
const activeSeed = computed(() => sopSeeds[selectedCategory.value]);
|
||||
const answerText = computed(() => apiResult.value?.answer ?? activeSeed.value.answer);
|
||||
@@ -364,6 +370,7 @@ async function runSearch() {
|
||||
const { data } = await searchKnowledgeSop({
|
||||
queryText: queryText.value,
|
||||
category: selectedCategory.value,
|
||||
spaceCodes: uploadSpaceCodes.value,
|
||||
limit: 3
|
||||
});
|
||||
apiResult.value = data;
|
||||
@@ -437,12 +444,16 @@ async function handleDocSelected(uploadFile: UploadFile) {
|
||||
ElMessage.warning('文件不能超过 100MB');
|
||||
return;
|
||||
}
|
||||
if (uploadSpaceCodes.value.length === 0) {
|
||||
ElMessage.warning('请先选择至少一个可管理的知识空间');
|
||||
return;
|
||||
}
|
||||
|
||||
uploading.value = true;
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', raw);
|
||||
form.append('category', selectedCategory.value);
|
||||
uploadSpaceCodes.value.forEach((code) => form.append('spaceCodes', code));
|
||||
const { data } = await uploadKnowledgeDoc(form);
|
||||
uploadedRecords.value.unshift({
|
||||
title: data.fileName,
|
||||
@@ -469,8 +480,14 @@ async function generateTraining() {
|
||||
ElMessage.success('已基于引用片段生成训练题');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
loadPendingReviews();
|
||||
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 = [];
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const frontendRoot = existsSync(resolve(process.cwd(), 'src')) ? process.cwd() : resolve(process.cwd(), 'frontend');
|
||||
const src = (...parts: string[]) => readFileSync(resolve(frontendRoot, 'src', ...parts), 'utf8');
|
||||
|
||||
describe('knowledge space administration contract', () => {
|
||||
it('registers the controlled route and sidebar entry', () => {
|
||||
expect(src('router', 'index.ts')).toContain("path: '/knowledge/spaces'");
|
||||
expect(src('router', 'index.ts')).toContain("title: '知识空间'");
|
||||
expect(src('layout', 'components', 'Sidebar', 'index.vue')).toContain("'知识空间'");
|
||||
});
|
||||
|
||||
it('contains spaces grants and applications tabs without exposing token hash', () => {
|
||||
const page = src('views', 'knowledge', 'spaces.vue');
|
||||
expect(page).toContain('label="知识空间"');
|
||||
expect(page).toContain('label="空间授权"');
|
||||
expect(page).toContain('label="调用应用"');
|
||||
expect(page).toContain('仅显示一次');
|
||||
expect(src('api', 'aihr', 'space.ts')).not.toContain('tokenHash');
|
||||
});
|
||||
|
||||
it('unbinds only the selected document-space membership', () => {
|
||||
const page = src('views', 'knowledge', 'spaces.vue');
|
||||
const api = src('api', 'aihr', 'space.ts');
|
||||
expect(page).toContain('文档成员');
|
||||
expect(page).toContain('解除当前空间绑定');
|
||||
expect(page).toContain('其他空间的同一文件不会删除');
|
||||
expect(api).toContain("method: 'delete'");
|
||||
expect(api).toContain('/documents/${attachId}');
|
||||
});
|
||||
|
||||
it('uploads to explicit space codes', () => {
|
||||
expect(src('views', 'knowledge', 'sop.vue')).toContain("form.append('spaceCodes', code)");
|
||||
expect(src('views', 'knowledge', 'processing.vue')).toContain("form.append('spaceCodes', code)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<div class="space-page">
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>知识空间</h1>
|
||||
<p>按租户管理知识边界、访问主体和调用应用;空间编码创建后不可修改。</p>
|
||||
</div>
|
||||
<el-button :loading="loading" @click="loadAll">刷新</el-button>
|
||||
</section>
|
||||
|
||||
<el-tabs v-model="activeTab" class="space-tabs">
|
||||
<el-tab-pane label="知识空间" name="spaces">
|
||||
<div class="toolbar"><el-button type="danger" @click="openSpace()">新建空间</el-button></div>
|
||||
<el-table :data="spaces" v-loading="loading">
|
||||
<el-table-column prop="name" label="空间名称" min-width="170" />
|
||||
<el-table-column prop="code" label="稳定编码" min-width="180" />
|
||||
<el-table-column prop="spaceType" label="类型" width="130" />
|
||||
<el-table-column prop="sensitivityLevel" label="密级" width="130" />
|
||||
<el-table-column prop="documentCount" label="文档" width="80" />
|
||||
<el-table-column prop="fragmentCount" label="片段" width="80" />
|
||||
<el-table-column prop="status" label="状态" width="100" />
|
||||
<el-table-column label="操作" width="180"><template #default="{ row }"><el-button link type="primary" @click="openSpace(row)">编辑</el-button><el-button link type="primary" @click="openDocuments(row)">文档成员</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="空间授权" name="grants">
|
||||
<div class="toolbar">
|
||||
<el-select v-model="grantSpaceId" placeholder="选择知识空间" style="width: 280px" @change="loadGrants">
|
||||
<el-option v-for="space in spaces" :key="space.id" :label="`${space.name} (${space.code})`" :value="space.id" />
|
||||
</el-select>
|
||||
<el-button :disabled="!grantSpaceId" @click="addGrant">新增授权</el-button>
|
||||
<el-button type="danger" :disabled="!grantSpaceId" :loading="saving" @click="saveGrants">保存全部授权</el-button>
|
||||
</div>
|
||||
<el-table :data="grants">
|
||||
<el-table-column label="主体类型" width="150"><template #default="{ row }"><el-select v-model="row.principalType"><el-option label="角色" value="ROLE" /><el-option label="用户" value="USER" /></el-select></template></el-table-column>
|
||||
<el-table-column label="角色编码 / 用户ID" min-width="220"><template #default="{ row }"><el-input v-model="row.principalValue" placeholder="如 employee / 10001" /></template></el-table-column>
|
||||
<el-table-column label="权限" width="150"><template #default="{ row }"><el-select v-model="row.permission"><el-option label="读取" value="READ" /><el-option label="管理" value="MANAGE" /></el-select></template></el-table-column>
|
||||
<el-table-column label="状态" width="150"><template #default="{ row }"><el-select v-model="row.status"><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select></template></el-table-column>
|
||||
<el-table-column label="操作" width="80"><template #default="{ $index }"><el-button link type="danger" @click="grants.splice($index, 1)">删除</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="调用应用" name="apps">
|
||||
<div class="toolbar"><el-button type="danger" @click="openApp()">新建应用</el-button></div>
|
||||
<el-table :data="apps" v-loading="loading">
|
||||
<el-table-column prop="appName" label="应用名称" min-width="160" />
|
||||
<el-table-column prop="appCode" label="应用编码" min-width="160" />
|
||||
<el-table-column prop="authType" label="认证方式" width="130" />
|
||||
<el-table-column label="授权空间" min-width="230"><template #default="{ row }">{{ row.spaceCodes.join('、') || '未绑定' }}</template></el-table-column>
|
||||
<el-table-column prop="rateLimitPerMinute" label="每分钟限流" width="110" />
|
||||
<el-table-column prop="status" label="状态" width="100" />
|
||||
<el-table-column label="操作" width="190"><template #default="{ row }"><el-button link type="primary" @click="openApp(row)">编辑</el-button><el-button v-if="row.authType === 'API_TOKEN'" link type="danger" @click="rotateToken(row)">轮换令牌</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-dialog v-model="spaceDialog" :title="spaceEditingId ? '编辑知识空间' : '新建知识空间'" width="560px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="稳定编码"><el-input v-model="spaceForm.code" :disabled="Boolean(spaceEditingId)" placeholder="如 yc_property_sop" /></el-form-item>
|
||||
<el-form-item label="空间名称"><el-input v-model="spaceForm.name" /></el-form-item>
|
||||
<el-form-item label="说明"><el-input v-model="spaceForm.description" type="textarea" /></el-form-item>
|
||||
<el-form-item label="类型"><el-select v-model="spaceForm.spaceType"><el-option v-for="value in spaceTypes" :key="value" :label="value" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="密级"><el-select v-model="spaceForm.sensitivityLevel"><el-option v-for="value in sensitivities" :key="value" :label="value" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="状态"><el-select v-model="spaceForm.status"><el-option label="草稿" value="DRAFT" /><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="spaceDialog = false">取消</el-button><el-button type="danger" :loading="saving" @click="saveSpace">保存</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="appDialog" :title="appEditingId ? '编辑调用应用' : '新建调用应用'" width="620px">
|
||||
<el-form label-width="110px">
|
||||
<el-form-item label="应用编码"><el-input v-model="appForm.appCode" :disabled="Boolean(appEditingId)" /></el-form-item>
|
||||
<el-form-item label="应用名称"><el-input v-model="appForm.appName" /></el-form-item>
|
||||
<el-form-item label="认证方式"><el-select v-model="appForm.authType" :disabled="Boolean(appEditingId)"><el-option label="内部登录态" value="SESSION" /><el-option label="外部令牌" value="API_TOKEN" /></el-select></el-form-item>
|
||||
<el-form-item v-if="appForm.authType === 'SESSION'" label="客户端标识"><el-input v-model="appForm.internalClientKey" placeholder="pc / app" /></el-form-item>
|
||||
<el-form-item label="授权空间"><el-select v-model="appForm.spaceCodes" multiple style="width: 100%"><el-option v-for="space in activeSpaces" :key="space.code" :label="space.name" :value="space.code" /></el-select></el-form-item>
|
||||
<el-form-item label="每分钟限流"><el-input-number v-model="appForm.rateLimitPerMinute" :min="1" :max="10000" /></el-form-item>
|
||||
<el-form-item label="状态"><el-select v-model="appForm.status"><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="closeAppDialog">取消</el-button><el-button type="danger" :loading="saving" @click="saveApp">保存</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="tokenDialog" title="访问令牌(仅显示一次)" width="620px" :close-on-click-modal="false" @closed="issuedToken = ''">
|
||||
<el-alert type="warning" :closable="false" title="令牌仅显示一次,请立即交给受信任的服务端保存。不要写入浏览器、小程序包或聊天记录。" />
|
||||
<el-input v-model="issuedToken" readonly type="textarea" :rows="4" class="token-box" />
|
||||
<template #footer><el-button type="danger" @click="tokenDialog = false">我已安全保存</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="documentDialog" :title="`${documentSpace?.name || ''} · 文档成员`" width="840px">
|
||||
<el-alert type="info" :closable="false" title="解绑只移除当前空间的文档成员和检索片段;其他空间仍可使用同一原始文件。仅当没有任何空间再引用时,系统才删除 OSS 文件。" />
|
||||
<el-table :data="documents" v-loading="documentLoading" class="document-table">
|
||||
<el-table-column prop="name" label="文件名" min-width="250" show-overflow-tooltip />
|
||||
<el-table-column prop="type" label="类型" width="100" />
|
||||
<el-table-column prop="status" label="状态" width="110" />
|
||||
<el-table-column prop="fragmentCount" label="片段" width="80" />
|
||||
<el-table-column label="操作" width="150"><template #default="{ row }"><el-button link type="danger" @click="unbindDocument(row)">解除当前空间绑定</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<template #footer><el-button @click="documentDialog = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="KnowledgeSpaces" lang="ts">
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import {
|
||||
createKnowledgeApp, createKnowledgeSpace, listKnowledgeApps, listKnowledgeSpaces, listSpaceDocuments, listSpaceGrants,
|
||||
replaceKnowledgeAppSpaces, replaceSpaceGrants, rotateKnowledgeAppToken, unbindSpaceDocument, updateKnowledgeApp, updateKnowledgeSpace,
|
||||
type AppForm, type KnowledgeApp, type KnowledgeDocument, type KnowledgeSpace, type SpaceForm, type SpaceGrant
|
||||
} from '@/api/aihr/space';
|
||||
|
||||
const activeTab = ref('spaces');
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const spaces = ref<KnowledgeSpace[]>([]);
|
||||
const apps = ref<KnowledgeApp[]>([]);
|
||||
const grants = ref<SpaceGrant[]>([]);
|
||||
const grantSpaceId = ref<number>();
|
||||
const spaceDialog = ref(false);
|
||||
const appDialog = ref(false);
|
||||
const tokenDialog = ref(false);
|
||||
const documentDialog = ref(false);
|
||||
const documentLoading = ref(false);
|
||||
const issuedToken = ref('');
|
||||
const documents = ref<KnowledgeDocument[]>([]);
|
||||
const documentSpace = ref<KnowledgeSpace>();
|
||||
const spaceEditingId = ref<number>();
|
||||
const appEditingId = ref<number>();
|
||||
const spaceTypes = ['PUBLIC', 'BUSINESS', 'MANAGEMENT', 'EXTERNAL'] as const;
|
||||
const sensitivities = ['PUBLIC', 'INTERNAL', 'CONFIDENTIAL'] as const;
|
||||
const activeSpaces = computed(() => spaces.value.filter((item) => item.status === 'ACTIVE'));
|
||||
const blankSpace = (): SpaceForm => ({ code: '', name: '', description: '', spaceType: 'BUSINESS', sensitivityLevel: 'INTERNAL', status: 'DRAFT' });
|
||||
const blankApp = (): AppForm => ({ appCode: '', appName: '', authType: 'SESSION', internalClientKey: 'pc', status: 'ACTIVE', rateLimitPerMinute: 60, expiresTime: null, spaceCodes: [] });
|
||||
const spaceForm = reactive<SpaceForm>(blankSpace());
|
||||
const appForm = reactive<AppForm>(blankApp());
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [spaceResult, appResult] = await Promise.all([listKnowledgeSpaces(), listKnowledgeApps()]);
|
||||
spaces.value = spaceResult.data || [];
|
||||
apps.value = appResult.data || [];
|
||||
} catch (error) { ElMessage.error(errorMessage(error)); } finally { loading.value = false; }
|
||||
}
|
||||
|
||||
function openSpace(row?: KnowledgeSpace) {
|
||||
spaceEditingId.value = row?.id;
|
||||
Object.assign(spaceForm, row ? { code: row.code, name: row.name, description: row.description, spaceType: row.spaceType, sensitivityLevel: row.sensitivityLevel, status: row.status } : blankSpace());
|
||||
spaceDialog.value = true;
|
||||
}
|
||||
|
||||
async function saveSpace() {
|
||||
saving.value = true;
|
||||
try {
|
||||
if (spaceEditingId.value) await updateKnowledgeSpace(spaceEditingId.value, spaceForm);
|
||||
else await createKnowledgeSpace(spaceForm);
|
||||
spaceDialog.value = false; ElMessage.success('知识空间已保存'); await loadAll();
|
||||
} catch (error) { ElMessage.error(errorMessage(error)); } finally { saving.value = false; }
|
||||
}
|
||||
|
||||
async function openDocuments(space: KnowledgeSpace) {
|
||||
documentSpace.value = space;
|
||||
documentDialog.value = true;
|
||||
await loadDocuments();
|
||||
}
|
||||
|
||||
async function loadDocuments() {
|
||||
if (!documentSpace.value) return;
|
||||
documentLoading.value = true;
|
||||
try { documents.value = (await listSpaceDocuments(documentSpace.value.id)).data || []; }
|
||||
catch (error) { ElMessage.error(errorMessage(error)); }
|
||||
finally { documentLoading.value = false; }
|
||||
}
|
||||
|
||||
async function unbindDocument(document: KnowledgeDocument) {
|
||||
if (!documentSpace.value) return;
|
||||
await ElMessageBox.confirm(
|
||||
`确认将“${document.name}”从“${documentSpace.value.name}”解绑?其他空间的同一文件不会删除。`,
|
||||
'解除当前空间绑定',
|
||||
{ type: 'warning', confirmButtonText: '确认解绑' }
|
||||
);
|
||||
try {
|
||||
await unbindSpaceDocument(documentSpace.value.id, document.attachId);
|
||||
ElMessage.success('已解除当前空间绑定');
|
||||
await Promise.all([loadDocuments(), loadAll()]);
|
||||
} catch (error) { ElMessage.error(errorMessage(error)); }
|
||||
}
|
||||
|
||||
async function loadGrants() {
|
||||
if (!grantSpaceId.value) return;
|
||||
try { grants.value = (await listSpaceGrants(grantSpaceId.value)).data || []; } catch (error) { ElMessage.error(errorMessage(error)); }
|
||||
}
|
||||
|
||||
function addGrant() { grants.value.push({ principalType: 'ROLE', principalValue: '', permission: 'READ', status: 'ACTIVE' }); }
|
||||
async function saveGrants() {
|
||||
if (!grantSpaceId.value) return;
|
||||
saving.value = true;
|
||||
try { grants.value = (await replaceSpaceGrants(grantSpaceId.value, grants.value)).data || []; ElMessage.success('空间授权已保存'); }
|
||||
catch (error) { ElMessage.error(errorMessage(error)); } finally { saving.value = false; }
|
||||
}
|
||||
|
||||
function openApp(row?: KnowledgeApp) {
|
||||
appEditingId.value = row?.id;
|
||||
Object.assign(appForm, row ? { appCode: row.appCode, appName: row.appName, authType: row.authType, internalClientKey: row.internalClientKey, status: row.status, rateLimitPerMinute: row.rateLimitPerMinute, expiresTime: row.expiresTime, spaceCodes: [...row.spaceCodes] } : blankApp());
|
||||
appDialog.value = true;
|
||||
}
|
||||
|
||||
function closeAppDialog() { appDialog.value = false; Object.assign(appForm, blankApp()); }
|
||||
async function saveApp() {
|
||||
saving.value = true;
|
||||
try {
|
||||
if (appEditingId.value) {
|
||||
await updateKnowledgeApp(appEditingId.value, appForm);
|
||||
await replaceKnowledgeAppSpaces(appEditingId.value, appForm.spaceCodes);
|
||||
} else {
|
||||
const result = await createKnowledgeApp(appForm);
|
||||
if (result.data.token) showIssuedToken(result.data.token);
|
||||
}
|
||||
closeAppDialog(); ElMessage.success('调用应用已保存'); await loadAll();
|
||||
} catch (error) { ElMessage.error(errorMessage(error)); } finally { saving.value = false; }
|
||||
}
|
||||
|
||||
async function rotateToken(row: KnowledgeApp) {
|
||||
await ElMessageBox.confirm('轮换后旧令牌立即失效,是否继续?', '轮换访问令牌', { type: 'warning' });
|
||||
try { showIssuedToken((await rotateKnowledgeAppToken(row.id, row.expiresTime)).data.token); }
|
||||
catch (error) { ElMessage.error(errorMessage(error)); }
|
||||
}
|
||||
|
||||
function showIssuedToken(token: string) { issuedToken.value = token; tokenDialog.value = true; }
|
||||
function errorMessage(error: unknown) { return error instanceof Error && error.message ? error.message : '操作失败,请检查权限或参数'; }
|
||||
onMounted(loadAll);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.space-page { min-height: calc(100vh - 84px); padding: 22px; background: #f5f7fb; color: #162033; }
|
||||
.page-head { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 16px; h1 { margin: 0 0 8px; font-size: 24px; } p { margin: 0; color: #657184; } }
|
||||
.space-tabs { padding: 16px 18px; border: 1px solid #e4e9f2; border-radius: 10px; background: #fff; }
|
||||
.toolbar { display: flex; gap: 10px; margin-bottom: 14px; }
|
||||
.token-box { margin-top: 16px; }
|
||||
.document-table { margin-top: 14px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user