feat(frontend): add rag document parse controls
This commit is contained in:
@@ -47,6 +47,31 @@ export interface RagDocumentBatchUploadRequest {
|
|||||||
remark?: string;
|
remark?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RagChunkStrategy =
|
||||||
|
| 'FIXED_LENGTH'
|
||||||
|
| 'PARAGRAPH'
|
||||||
|
| 'HEADING'
|
||||||
|
| 'TABLE_ROW'
|
||||||
|
| 'DELIMITER'
|
||||||
|
| 'SEMANTIC';
|
||||||
|
|
||||||
|
export interface RagDocumentParseRequest {
|
||||||
|
documentIds: string[];
|
||||||
|
chunkStrategy: RagChunkStrategy;
|
||||||
|
chunkSize?: number;
|
||||||
|
chunkOverlap?: number;
|
||||||
|
delimiter?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RagDocumentParseResponse {
|
||||||
|
documentId: string;
|
||||||
|
parseStatus: string;
|
||||||
|
textLength?: number | null;
|
||||||
|
pageCount?: number | null;
|
||||||
|
sheetCount?: number | null;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
export function listRagDocuments() {
|
export function listRagDocuments() {
|
||||||
return post<RagDocument[]>('/rag/documents/list');
|
return post<RagDocument[]>('/rag/documents/list');
|
||||||
}
|
}
|
||||||
@@ -86,3 +111,7 @@ export function batchUploadRagDocuments(data: RagDocumentBatchUploadRequest) {
|
|||||||
}
|
}
|
||||||
return post<RagDocument[], FormData>('/rag/documents/batchUpload', formData);
|
return post<RagDocument[], FormData>('/rag/documents/batchUpload', formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseRagDocuments(data: RagDocumentParseRequest) {
|
||||||
|
return post<RagDocumentParseResponse[], RagDocumentParseRequest>('/rag/documents/parse', data);
|
||||||
|
}
|
||||||
|
|||||||
184
frontend/src/components/rag/RagDocumentBatchUploadDialog.vue
Normal file
184
frontend/src/components/rag/RagDocumentBatchUploadDialog.vue
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { UploadFilled } from '@element-plus/icons-vue';
|
||||||
|
import { ElMessage, type UploadFile, type UploadUserFile } from 'element-plus';
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
batchUploadRagDocuments,
|
||||||
|
SOURCE_TYPE_RAG,
|
||||||
|
} from '@/api/ragDocuments';
|
||||||
|
import type { RagStore } from '@/api/ragStores';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean;
|
||||||
|
stores: RagStore[];
|
||||||
|
lockedStoreId?: string | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:modelValue', value: boolean): void;
|
||||||
|
(event: 'uploaded'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const submitting = ref(false);
|
||||||
|
const uploadStoreId = ref('');
|
||||||
|
const uploadSummary = ref('');
|
||||||
|
const uploadRemark = ref('');
|
||||||
|
const uploadFiles = ref<File[]>([]);
|
||||||
|
const uploadFileList = ref<UploadUserFile[]>([]);
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value: boolean) => emit('update:modelValue', value),
|
||||||
|
});
|
||||||
|
|
||||||
|
const storeLocked = computed(() => Boolean(props.lockedStoreId));
|
||||||
|
const lockedStoreName = computed(() => {
|
||||||
|
const store = props.stores.find((item) => String(item.id) === props.lockedStoreId);
|
||||||
|
return store?.storeName ?? '-';
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(value) => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const firstStore = props.stores[0];
|
||||||
|
uploadStoreId.value = props.lockedStoreId || (firstStore ? String(firstStore.id) : '');
|
||||||
|
uploadSummary.value = '';
|
||||||
|
uploadRemark.value = '';
|
||||||
|
uploadFiles.value = [];
|
||||||
|
uploadFileList.value = [];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function syncUploadFiles(fileList: UploadFile[]) {
|
||||||
|
const files: File[] = [];
|
||||||
|
fileList.forEach((file) => {
|
||||||
|
if (file.raw) {
|
||||||
|
files.push(file.raw as File);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
uploadFiles.value = files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUploadChange(_file: UploadFile, fileList: UploadFile[]) {
|
||||||
|
syncUploadFiles(fileList);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUploadRemove(_file: UploadFile, fileList: UploadFile[]) {
|
||||||
|
syncUploadFiles(fileList);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitUpload() {
|
||||||
|
if (!uploadStoreId.value) {
|
||||||
|
ElMessage.warning('请选择知识库');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (uploadFiles.value.length === 0) {
|
||||||
|
ElMessage.warning('请选择要上传的文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
await batchUploadRagDocuments({
|
||||||
|
storeId: uploadStoreId.value,
|
||||||
|
sourceType: SOURCE_TYPE_RAG,
|
||||||
|
files: uploadFiles.value,
|
||||||
|
documentSummary: uploadSummary.value || undefined,
|
||||||
|
remark: uploadRemark.value || undefined,
|
||||||
|
});
|
||||||
|
visible.value = false;
|
||||||
|
ElMessage.success('文档已上传');
|
||||||
|
emit('uploaded');
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" title="批量上传文档" width="560px">
|
||||||
|
<el-form label-width="96px">
|
||||||
|
<el-form-item label="知识库" required>
|
||||||
|
<el-input
|
||||||
|
v-if="storeLocked"
|
||||||
|
:model-value="lockedStoreName"
|
||||||
|
data-test="batch-upload-locked-store"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<el-select
|
||||||
|
v-else
|
||||||
|
v-model="uploadStoreId"
|
||||||
|
data-test="batch-upload-store-select"
|
||||||
|
placeholder="请选择知识库"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="store in stores"
|
||||||
|
:key="String(store.id)"
|
||||||
|
:label="store.storeName"
|
||||||
|
:value="String(store.id)"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="选择文件" required>
|
||||||
|
<el-upload
|
||||||
|
v-model:file-list="uploadFileList"
|
||||||
|
class="batch-upload-dropzone"
|
||||||
|
drag
|
||||||
|
multiple
|
||||||
|
:auto-upload="false"
|
||||||
|
accept=".pdf,.doc,.docx,.txt,.md"
|
||||||
|
:on-change="handleUploadChange"
|
||||||
|
:on-remove="handleUploadRemove"
|
||||||
|
>
|
||||||
|
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
|
||||||
|
<div class="el-upload__text">
|
||||||
|
拖拽文件到此处,或 <em>点击选择</em>
|
||||||
|
</div>
|
||||||
|
<template #tip>
|
||||||
|
<div class="el-upload__tip">支持 PDF、Word、TXT、Markdown 等格式</div>
|
||||||
|
</template>
|
||||||
|
</el-upload>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="文档摘要">
|
||||||
|
<el-input
|
||||||
|
v-model="uploadSummary"
|
||||||
|
type="textarea"
|
||||||
|
:rows="2"
|
||||||
|
placeholder="可选,将统一设置到所有上传文档"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注">
|
||||||
|
<el-input
|
||||||
|
v-model="uploadRemark"
|
||||||
|
type="textarea"
|
||||||
|
:rows="2"
|
||||||
|
placeholder="可选"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="submitUpload">上传</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.batch-upload-dropzone {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-upload-dropzone :deep(.el-upload) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-upload-dropzone :deep(.el-upload-dragger) {
|
||||||
|
width: 100%;
|
||||||
|
padding: 28px 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,478 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { Search, UploadFilled } from '@element-plus/icons-vue';
|
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
|
||||||
import { computed, onMounted, reactive, ref } from 'vue';
|
|
||||||
|
|
||||||
import {
|
|
||||||
batchUploadRagDocuments,
|
|
||||||
deleteRagDocument,
|
|
||||||
getRagDocumentById,
|
|
||||||
listRagDocuments,
|
|
||||||
saveRagDocument,
|
|
||||||
SOURCE_TYPE_RAG,
|
|
||||||
type RagDocument,
|
|
||||||
} from '@/api/ragDocuments';
|
|
||||||
import { queryRagStores, type RagStore } from '@/api/ragStores';
|
|
||||||
|
|
||||||
const loading = ref(false);
|
|
||||||
const submitting = ref(false);
|
|
||||||
|
|
||||||
const storeOptions = ref<RagStore[]>([]);
|
|
||||||
const docRows = ref<RagDocument[]>([]);
|
|
||||||
|
|
||||||
const queryForm = reactive({
|
|
||||||
storeId: '',
|
|
||||||
parseStatus: '',
|
|
||||||
indexStatus: '',
|
|
||||||
enabled: '' as string,
|
|
||||||
keyword: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const editDialogVisible = ref(false);
|
|
||||||
const uploadDialogVisible = ref(false);
|
|
||||||
|
|
||||||
const uploadStoreId = ref('');
|
|
||||||
const uploadSummary = ref('');
|
|
||||||
const uploadRemark = ref('');
|
|
||||||
|
|
||||||
const editForm = reactive({
|
|
||||||
id: '',
|
|
||||||
storeId: '',
|
|
||||||
attachmentId: '',
|
|
||||||
documentTitle: '',
|
|
||||||
documentSummary: '',
|
|
||||||
enabled: true,
|
|
||||||
remark: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredRows = computed(() => {
|
|
||||||
const kw = queryForm.keyword.trim().toLowerCase();
|
|
||||||
return docRows.value.filter(
|
|
||||||
(row) => {
|
|
||||||
const matchStore = !queryForm.storeId || row.storeId === queryForm.storeId;
|
|
||||||
const matchParseStatus = !queryForm.parseStatus || row.parseStatus === queryForm.parseStatus;
|
|
||||||
const matchIndexStatus = !queryForm.indexStatus || row.indexStatus === queryForm.indexStatus;
|
|
||||||
const matchEnabled = !queryForm.enabled || String(row.enabled ?? false) === queryForm.enabled;
|
|
||||||
const matchKeyword =
|
|
||||||
!kw ||
|
|
||||||
(row.documentTitle && row.documentTitle.toLowerCase().includes(kw)) ||
|
|
||||||
(row.documentSummary && row.documentSummary.toLowerCase().includes(kw)) ||
|
|
||||||
(row.remark && row.remark.toLowerCase().includes(kw));
|
|
||||||
return matchStore && matchParseStatus && matchIndexStatus && matchEnabled && matchKeyword;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadStores() {
|
|
||||||
try {
|
|
||||||
const response = await queryRagStores();
|
|
||||||
storeOptions.value = response.data ?? [];
|
|
||||||
} catch {
|
|
||||||
storeOptions.value = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDocs() {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const response = await listRagDocuments();
|
|
||||||
docRows.value = response.data ?? [];
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSearch() {
|
|
||||||
loadDocs();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleReset() {
|
|
||||||
queryForm.storeId = '';
|
|
||||||
queryForm.parseStatus = '';
|
|
||||||
queryForm.indexStatus = '';
|
|
||||||
queryForm.enabled = '';
|
|
||||||
queryForm.keyword = '';
|
|
||||||
loadDocs();
|
|
||||||
}
|
|
||||||
|
|
||||||
function openUploadDialog() {
|
|
||||||
if (storeOptions.value.length === 0) {
|
|
||||||
ElMessage.warning('请先创建知识库');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const firstStore = storeOptions.value[0];
|
|
||||||
uploadStoreId.value = queryForm.storeId || (firstStore ? String(firstStore.id) : '');
|
|
||||||
uploadSummary.value = '';
|
|
||||||
uploadRemark.value = '';
|
|
||||||
uploadDialogVisible.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitUpload() {
|
|
||||||
if (!uploadStoreId.value) {
|
|
||||||
ElMessage.warning('请选择知识库');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const files = (document.getElementById('rag-file-input') as HTMLInputElement)?.files;
|
|
||||||
if (!files || files.length === 0) {
|
|
||||||
ElMessage.warning('请选择要上传的文件');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
submitting.value = true;
|
|
||||||
try {
|
|
||||||
await batchUploadRagDocuments({
|
|
||||||
storeId: uploadStoreId.value,
|
|
||||||
sourceType: SOURCE_TYPE_RAG,
|
|
||||||
files: Array.from(files),
|
|
||||||
documentSummary: uploadSummary.value || undefined,
|
|
||||||
remark: uploadRemark.value || undefined,
|
|
||||||
});
|
|
||||||
uploadDialogVisible.value = false;
|
|
||||||
ElMessage.success('文档已上传');
|
|
||||||
await loadDocs();
|
|
||||||
} finally {
|
|
||||||
submitting.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openEditDialog(row: RagDocument) {
|
|
||||||
const detail = row.id ? (await getRagDocumentById(String(row.id))).data : row;
|
|
||||||
|
|
||||||
editForm.id = String(detail.id ?? '');
|
|
||||||
editForm.storeId = detail.storeId;
|
|
||||||
editForm.attachmentId = detail.attachmentId ?? '';
|
|
||||||
editForm.documentTitle = detail.documentTitle ?? '';
|
|
||||||
editForm.documentSummary = detail.documentSummary ?? '';
|
|
||||||
editForm.enabled = detail.enabled ?? true;
|
|
||||||
editForm.remark = detail.remark ?? '';
|
|
||||||
editDialogVisible.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitEdit() {
|
|
||||||
if (!editForm.id || !editForm.storeId || !editForm.documentTitle) {
|
|
||||||
ElMessage.warning('请填写文档标题');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
submitting.value = true;
|
|
||||||
try {
|
|
||||||
await saveRagDocument({
|
|
||||||
id: editForm.id,
|
|
||||||
storeId: editForm.storeId,
|
|
||||||
attachmentId: editForm.attachmentId || undefined,
|
|
||||||
documentTitle: editForm.documentTitle,
|
|
||||||
documentSummary: editForm.documentSummary || undefined,
|
|
||||||
enabled: editForm.enabled,
|
|
||||||
remark: editForm.remark || undefined,
|
|
||||||
});
|
|
||||||
editDialogVisible.value = false;
|
|
||||||
ElMessage.success('文档信息已更新');
|
|
||||||
await loadDocs();
|
|
||||||
} finally {
|
|
||||||
submitting.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeDoc(row: RagDocument) {
|
|
||||||
if (!row.id) return;
|
|
||||||
|
|
||||||
await ElMessageBox.confirm(
|
|
||||||
`确认删除文档「${row.documentTitle || '未命名'}」?`,
|
|
||||||
'删除确认',
|
|
||||||
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
|
|
||||||
);
|
|
||||||
|
|
||||||
await deleteRagDocument(String(row.id));
|
|
||||||
ElMessage.success('文档已删除');
|
|
||||||
await loadDocs();
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleEnabled(row: RagDocument) {
|
|
||||||
if (!row.id) return;
|
|
||||||
const newEnabled = !row.enabled;
|
|
||||||
saveRagDocument({
|
|
||||||
id: String(row.id),
|
|
||||||
storeId: row.storeId,
|
|
||||||
documentTitle: row.documentTitle ?? '',
|
|
||||||
enabled: newEnabled,
|
|
||||||
}).then(() => {
|
|
||||||
row.enabled = newEnabled;
|
|
||||||
ElMessage.success(`已${newEnabled ? '启用' : '停用'}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStoreName(storeId: string) {
|
|
||||||
const store = storeOptions.value.find((s) => String(s.id) === storeId);
|
|
||||||
return store ? store.storeName : '-';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusLabel(status?: string | null) {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
UPLOADED: '已上传',
|
|
||||||
PARSING: '解析中',
|
|
||||||
PARSED: '已解析',
|
|
||||||
FAILED: '解析失败',
|
|
||||||
PENDING: '待索引',
|
|
||||||
INDEXING: '索引中',
|
|
||||||
INDEXED: '已索引',
|
|
||||||
};
|
|
||||||
return status ? (map[status] ?? status) : '-';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getParseStatusType(status?: string | null) {
|
|
||||||
const success = ['PARSED'];
|
|
||||||
const warning = ['UPLOADED', 'PARSING'];
|
|
||||||
const danger = ['FAILED'];
|
|
||||||
if (!status) return 'info';
|
|
||||||
if (success.includes(status)) return 'success';
|
|
||||||
if (warning.includes(status)) return 'warning';
|
|
||||||
if (danger.includes(status)) return 'danger';
|
|
||||||
return 'info';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getIndexStatusType(status?: string | null) {
|
|
||||||
const success = ['INDEXED'];
|
|
||||||
const warning = ['PENDING', 'INDEXING'];
|
|
||||||
const danger = ['FAILED'];
|
|
||||||
if (!status) return 'info';
|
|
||||||
if (success.includes(status)) return 'success';
|
|
||||||
if (warning.includes(status)) return 'warning';
|
|
||||||
if (danger.includes(status)) return 'danger';
|
|
||||||
return 'info';
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadStores();
|
|
||||||
loadDocs();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<section class="page-panel rag-doc-page">
|
|
||||||
<div class="page-panel__header">
|
|
||||||
<h2>知识文档</h2>
|
|
||||||
<span>Documents</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="toolbar">
|
|
||||||
<div class="toolbar__filters">
|
|
||||||
<el-select
|
|
||||||
v-model="queryForm.storeId"
|
|
||||||
data-test="doc-store-filter"
|
|
||||||
placeholder="选择知识库"
|
|
||||||
clearable
|
|
||||||
style="width: 180px"
|
|
||||||
>
|
|
||||||
<el-option
|
|
||||||
v-for="store in storeOptions"
|
|
||||||
:key="String(store.id)"
|
|
||||||
:label="store.storeName"
|
|
||||||
:value="String(store.id)"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
<el-select
|
|
||||||
v-model="queryForm.parseStatus"
|
|
||||||
data-test="doc-parse-filter"
|
|
||||||
placeholder="解析状态"
|
|
||||||
clearable
|
|
||||||
style="width: 130px"
|
|
||||||
>
|
|
||||||
<el-option label="已上传" value="UPLOADED" />
|
|
||||||
<el-option label="解析中" value="PARSING" />
|
|
||||||
<el-option label="已解析" value="PARSED" />
|
|
||||||
<el-option label="解析失败" value="FAILED" />
|
|
||||||
</el-select>
|
|
||||||
<el-select
|
|
||||||
v-model="queryForm.indexStatus"
|
|
||||||
data-test="doc-index-filter"
|
|
||||||
placeholder="索引状态"
|
|
||||||
clearable
|
|
||||||
style="width: 130px"
|
|
||||||
>
|
|
||||||
<el-option label="待索引" value="PENDING" />
|
|
||||||
<el-option label="索引中" value="INDEXING" />
|
|
||||||
<el-option label="已索引" value="INDEXED" />
|
|
||||||
<el-option label="索引失败" value="FAILED" />
|
|
||||||
</el-select>
|
|
||||||
<el-select
|
|
||||||
v-model="queryForm.enabled"
|
|
||||||
data-test="doc-enabled-filter"
|
|
||||||
placeholder="启用状态"
|
|
||||||
clearable
|
|
||||||
style="width: 120px"
|
|
||||||
>
|
|
||||||
<el-option label="启用" value="true" />
|
|
||||||
<el-option label="停用" value="false" />
|
|
||||||
</el-select>
|
|
||||||
<el-input
|
|
||||||
v-model="queryForm.keyword"
|
|
||||||
data-test="doc-keyword-input"
|
|
||||||
placeholder="搜索标题/摘要/备注"
|
|
||||||
clearable
|
|
||||||
style="width: 180px"
|
|
||||||
@keyup.enter="handleSearch"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="toolbar__actions">
|
|
||||||
<el-button type="primary" :icon="UploadFilled" @click="openUploadDialog">批量上传</el-button>
|
|
||||||
<el-button data-test="doc-search" type="primary" :icon="Search" @click="handleSearch">查询</el-button>
|
|
||||||
<el-button @click="handleReset">重置</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="filteredRows" border stripe style="width: 100%">
|
|
||||||
<el-table-column prop="documentTitle" label="文档标题" min-width="180" show-overflow-tooltip />
|
|
||||||
<el-table-column label="所属知识库" width="150">
|
|
||||||
<template #default="{ row }">
|
|
||||||
{{ getStoreName(row.storeId) }}
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="解析状态" width="110">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-tag :type="getParseStatusType(row.parseStatus)" size="small">
|
|
||||||
{{ getStatusLabel(row.parseStatus) }}
|
|
||||||
</el-tag>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="索引状态" width="110">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-tag :type="getIndexStatusType(row.indexStatus)" size="small">
|
|
||||||
{{ getStatusLabel(row.indexStatus) }}
|
|
||||||
</el-tag>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="启用" width="80" align="center">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-switch
|
|
||||||
:model-value="row.enabled ?? false"
|
|
||||||
size="small"
|
|
||||||
@change="toggleEnabled(row)"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="documentSummary" label="摘要" min-width="160" show-overflow-tooltip />
|
|
||||||
<el-table-column prop="createTime" label="创建时间" width="170" />
|
|
||||||
<el-table-column label="操作" width="160" fixed="right">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-button :data-test="`doc-edit-${row.id}`" link type="primary" @click="openEditDialog(row)">编辑</el-button>
|
|
||||||
<el-button link type="danger" @click="removeDoc(row)">删除</el-button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
|
|
||||||
<el-empty v-if="!loading && filteredRows.length === 0" description="暂无知识文档" />
|
|
||||||
|
|
||||||
<!-- 上传对话框 -->
|
|
||||||
<el-dialog v-model="uploadDialogVisible" title="批量上传文档" width="560px">
|
|
||||||
<el-form label-width="96px">
|
|
||||||
<el-form-item label="知识库" required>
|
|
||||||
<el-select v-model="uploadStoreId" placeholder="请选择知识库" style="width: 100%">
|
|
||||||
<el-option
|
|
||||||
v-for="store in storeOptions"
|
|
||||||
:key="String(store.id)"
|
|
||||||
:label="store.storeName"
|
|
||||||
:value="String(store.id)"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="选择文件" required>
|
|
||||||
<input id="rag-file-input" type="file" multiple accept=".pdf,.doc,.docx,.txt,.md" />
|
|
||||||
<p class="form-hint">支持 PDF、Word、TXT、Markdown 等格式</p>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="文档摘要">
|
|
||||||
<el-input
|
|
||||||
v-model="uploadSummary"
|
|
||||||
type="textarea"
|
|
||||||
:rows="2"
|
|
||||||
placeholder="可选,将统一设置到所有上传文档"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="备注">
|
|
||||||
<el-input
|
|
||||||
v-model="uploadRemark"
|
|
||||||
type="textarea"
|
|
||||||
:rows="2"
|
|
||||||
placeholder="可选"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
<template #footer>
|
|
||||||
<el-button @click="uploadDialogVisible = false">取消</el-button>
|
|
||||||
<el-button type="primary" :loading="submitting" @click="submitUpload">上传</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
|
|
||||||
<!-- 编辑对话框 -->
|
|
||||||
<el-dialog v-model="editDialogVisible" title="编辑文档" width="560px">
|
|
||||||
<el-form :model="editForm" label-width="96px">
|
|
||||||
<el-form-item label="文档标题" required>
|
|
||||||
<el-input v-model="editForm.documentTitle" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="文档摘要">
|
|
||||||
<el-input v-model="editForm.documentSummary" type="textarea" :rows="3" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="启用">
|
|
||||||
<el-switch v-model="editForm.enabled" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="备注">
|
|
||||||
<el-input v-model="editForm.remark" type="textarea" :rows="2" />
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
<template #footer>
|
|
||||||
<el-button @click="editDialogVisible = false">取消</el-button>
|
|
||||||
<el-button type="primary" :loading="submitting" @click="submitEdit">保存</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.rag-doc-page {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 16px 22px;
|
|
||||||
border-bottom: 1px solid #eef2f7;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar__filters {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar__actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-hint {
|
|
||||||
margin: 4px 0 0;
|
|
||||||
color: #98a2b3;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.toolbar {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar__filters {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar__actions {
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import { flushPromises, mount } from '@vue/test-utils';
|
|
||||||
import ElementPlus from 'element-plus';
|
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import RagDocumentsPage from '../RagDocumentsPage.vue';
|
|
||||||
import { getRagDocumentById, listRagDocuments, queryRagDocuments } from '@/api/ragDocuments';
|
|
||||||
import { queryRagStores } from '@/api/ragStores';
|
|
||||||
|
|
||||||
vi.mock('@/api/ragStores', () => ({
|
|
||||||
queryRagStores: vi.fn(() =>
|
|
||||||
Promise.resolve({
|
|
||||||
resultcode: '0',
|
|
||||||
message: null,
|
|
||||||
data: [
|
|
||||||
{ id: '1', storeCode: 'PROD_DOC', storeName: '产品制度库', status: '启用' },
|
|
||||||
{ id: '2', storeCode: 'FAQ', storeName: 'FAQ知识库', status: '停用' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@/api/ragDocuments', () => ({
|
|
||||||
SOURCE_TYPE_RAG: 'RAG',
|
|
||||||
listRagDocuments: vi.fn(() =>
|
|
||||||
Promise.resolve({
|
|
||||||
resultcode: '0',
|
|
||||||
message: null,
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
id: '11',
|
|
||||||
storeId: '1',
|
|
||||||
attachmentId: '101',
|
|
||||||
documentTitle: '产品制度总则',
|
|
||||||
documentSummary: '制度摘要',
|
|
||||||
parseStatus: 'UPLOADED',
|
|
||||||
indexStatus: 'PENDING',
|
|
||||||
enabled: true,
|
|
||||||
remark: '制度文档',
|
|
||||||
createTime: '2026-05-21 10:00:00',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '22',
|
|
||||||
storeId: '2',
|
|
||||||
attachmentId: '202',
|
|
||||||
documentTitle: 'FAQ 手册',
|
|
||||||
documentSummary: 'FAQ 摘要',
|
|
||||||
parseStatus: 'PARSED',
|
|
||||||
indexStatus: 'INDEXED',
|
|
||||||
enabled: false,
|
|
||||||
remark: '常见问题',
|
|
||||||
createTime: '2026-05-21 11:00:00',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
queryRagDocuments: vi.fn(),
|
|
||||||
getRagDocumentById: vi.fn((id: string) =>
|
|
||||||
Promise.resolve({
|
|
||||||
resultcode: '0',
|
|
||||||
message: null,
|
|
||||||
data: {
|
|
||||||
id,
|
|
||||||
storeId: '2',
|
|
||||||
attachmentId: '202',
|
|
||||||
documentTitle: 'FAQ 手册',
|
|
||||||
documentSummary: 'FAQ 摘要',
|
|
||||||
enabled: false,
|
|
||||||
remark: '常见问题',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
saveRagDocument: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: true })),
|
|
||||||
deleteRagDocument: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: true })),
|
|
||||||
batchUploadRagDocuments: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: [] })),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('RagDocumentsPage', () => {
|
|
||||||
it('loads documents from list api instead of broken query api', async () => {
|
|
||||||
const wrapper = mount(RagDocumentsPage, {
|
|
||||||
global: {
|
|
||||||
plugins: [ElementPlus],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await flushPromises();
|
|
||||||
|
|
||||||
expect(queryRagStores).toHaveBeenCalled();
|
|
||||||
expect(listRagDocuments).toHaveBeenCalled();
|
|
||||||
expect(queryRagDocuments).not.toHaveBeenCalled();
|
|
||||||
expect(wrapper.text()).toContain('产品制度总则');
|
|
||||||
expect(wrapper.text()).toContain('FAQ 手册');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('filters rows locally and still avoids query api on search', async () => {
|
|
||||||
const wrapper = mount(RagDocumentsPage, {
|
|
||||||
global: {
|
|
||||||
plugins: [ElementPlus],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await flushPromises();
|
|
||||||
await wrapper.get('[data-test="doc-keyword-input"]').setValue('FAQ');
|
|
||||||
await wrapper.get('[data-test="doc-search"]').trigger('click');
|
|
||||||
await flushPromises();
|
|
||||||
|
|
||||||
expect(listRagDocuments).toHaveBeenCalled();
|
|
||||||
expect(queryRagDocuments).not.toHaveBeenCalled();
|
|
||||||
expect(wrapper.text()).toContain('FAQ 手册');
|
|
||||||
expect(wrapper.text()).not.toContain('产品制度总则');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('loads backend detail when editing a row', async () => {
|
|
||||||
const wrapper = mount(RagDocumentsPage, {
|
|
||||||
global: {
|
|
||||||
plugins: [ElementPlus],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await flushPromises();
|
|
||||||
await wrapper.get('[data-test="doc-edit-22"]').trigger('click');
|
|
||||||
await flushPromises();
|
|
||||||
|
|
||||||
expect(getRagDocumentById).toHaveBeenCalledWith('22');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
638
frontend/src/pages/rag/RagDocumentsPage.vue
Normal file
638
frontend/src/pages/rag/RagDocumentsPage.vue
Normal file
@@ -0,0 +1,638 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Operation, Search, UploadFilled } from '@element-plus/icons-vue';
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
|
import {
|
||||||
|
deleteRagDocument,
|
||||||
|
getRagDocumentById,
|
||||||
|
parseRagDocuments,
|
||||||
|
queryRagDocuments,
|
||||||
|
saveRagDocument,
|
||||||
|
type RagChunkStrategy,
|
||||||
|
type RagDocument,
|
||||||
|
} from '@/api/ragDocuments';
|
||||||
|
import { queryRagStores, type RagStore } from '@/api/ragStores';
|
||||||
|
import RagDocumentBatchUploadDialog from '@/components/rag/RagDocumentBatchUploadDialog.vue';
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const submitting = ref(false);
|
||||||
|
const parseSubmitting = ref(false);
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const storeOptions = ref<RagStore[]>([]);
|
||||||
|
const docRows = ref<RagDocument[]>([]);
|
||||||
|
|
||||||
|
const queryForm = reactive({
|
||||||
|
storeId: '',
|
||||||
|
parseStatus: '',
|
||||||
|
indexStatus: '',
|
||||||
|
enabled: '' as string,
|
||||||
|
keyword: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const editDialogVisible = ref(false);
|
||||||
|
const uploadDialogVisible = ref(false);
|
||||||
|
const parseDialogVisible = ref(false);
|
||||||
|
const selectedDocuments = ref<RagDocument[]>([]);
|
||||||
|
|
||||||
|
const editForm = reactive({
|
||||||
|
id: '',
|
||||||
|
storeId: '',
|
||||||
|
attachmentId: '',
|
||||||
|
documentTitle: '',
|
||||||
|
documentSummary: '',
|
||||||
|
enabled: true,
|
||||||
|
remark: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const parseForm = reactive({
|
||||||
|
documentIds: [] as string[],
|
||||||
|
chunkStrategy: 'FIXED_LENGTH' as RagChunkStrategy,
|
||||||
|
chunkSize: 800,
|
||||||
|
chunkOverlap: 120,
|
||||||
|
delimiter: '。',
|
||||||
|
});
|
||||||
|
|
||||||
|
const chunkStrategyOptions: Array<{ label: string; value: RagChunkStrategy; description: string }> = [
|
||||||
|
{ label: '固定长度切片', value: 'FIXED_LENGTH', description: '按指定长度和重叠长度切分通用文本' },
|
||||||
|
{ label: '按段落切片', value: 'PARAGRAPH', description: '按空行、自然段落边界切分' },
|
||||||
|
{ label: '按标题层级切片', value: 'HEADING', description: '按标题和章节层级组织内容' },
|
||||||
|
{ label: '按表格行切片', value: 'TABLE_ROW', description: '适合 Excel 表格和结构化明细数据' },
|
||||||
|
{ label: '按分隔符切片', value: 'DELIMITER', description: '按句号、换行符或自定义分隔符切分' },
|
||||||
|
{ label: '语义切片', value: 'SEMANTIC', description: '后续结合语义边界或模型能力切分' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const filteredRows = computed(() => {
|
||||||
|
const kw = queryForm.keyword.trim().toLowerCase();
|
||||||
|
return docRows.value.filter(
|
||||||
|
(row) => {
|
||||||
|
const matchStore = !queryForm.storeId || row.storeId === queryForm.storeId;
|
||||||
|
const matchParseStatus = !queryForm.parseStatus || row.parseStatus === queryForm.parseStatus;
|
||||||
|
const matchIndexStatus = !queryForm.indexStatus || row.indexStatus === queryForm.indexStatus;
|
||||||
|
const matchEnabled = !queryForm.enabled || String(row.enabled ?? false) === queryForm.enabled;
|
||||||
|
const matchKeyword =
|
||||||
|
!kw ||
|
||||||
|
(row.documentTitle && row.documentTitle.toLowerCase().includes(kw)) ||
|
||||||
|
(row.documentSummary && row.documentSummary.toLowerCase().includes(kw)) ||
|
||||||
|
(row.remark && row.remark.toLowerCase().includes(kw));
|
||||||
|
return matchStore && matchParseStatus && matchIndexStatus && matchEnabled && matchKeyword;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadStores() {
|
||||||
|
try {
|
||||||
|
const response = await queryRagStores();
|
||||||
|
storeOptions.value = response.data ?? [];
|
||||||
|
} catch {
|
||||||
|
storeOptions.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDocs() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await queryRagDocuments({
|
||||||
|
...(queryForm.storeId ? { storeId: queryForm.storeId } : {}),
|
||||||
|
...(queryForm.parseStatus ? { parseStatus: queryForm.parseStatus } : {}),
|
||||||
|
...(queryForm.indexStatus ? { indexStatus: queryForm.indexStatus } : {}),
|
||||||
|
...(queryForm.enabled ? { enabled: queryForm.enabled === 'true' } : {}),
|
||||||
|
});
|
||||||
|
docRows.value = response.data ?? [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
loadDocs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
queryForm.storeId = '';
|
||||||
|
queryForm.parseStatus = '';
|
||||||
|
queryForm.indexStatus = '';
|
||||||
|
queryForm.enabled = '';
|
||||||
|
queryForm.keyword = '';
|
||||||
|
loadDocs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUploadDialog() {
|
||||||
|
if (storeOptions.value.length === 0) {
|
||||||
|
ElMessage.warning('请先创建知识库');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uploadDialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectionChange(rows: RagDocument[]) {
|
||||||
|
selectedDocuments.value = rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openParseDialog(rows: RagDocument[]) {
|
||||||
|
const ids = rows.map((row) => String(row.id ?? '')).filter(Boolean);
|
||||||
|
if (ids.length === 0) {
|
||||||
|
ElMessage.warning('请选择需要解析的文档');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
parseForm.documentIds = ids;
|
||||||
|
parseForm.chunkStrategy = 'FIXED_LENGTH';
|
||||||
|
parseForm.chunkSize = 800;
|
||||||
|
parseForm.chunkOverlap = 120;
|
||||||
|
parseForm.delimiter = '。';
|
||||||
|
parseDialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBatchParseDialog() {
|
||||||
|
openParseDialog(selectedDocuments.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEditDialog(row: RagDocument) {
|
||||||
|
const detail = row.id ? (await getRagDocumentById(String(row.id))).data : row;
|
||||||
|
|
||||||
|
editForm.id = String(detail.id ?? '');
|
||||||
|
editForm.storeId = detail.storeId;
|
||||||
|
editForm.attachmentId = detail.attachmentId ?? '';
|
||||||
|
editForm.documentTitle = detail.documentTitle ?? '';
|
||||||
|
editForm.documentSummary = detail.documentSummary ?? '';
|
||||||
|
editForm.enabled = detail.enabled ?? true;
|
||||||
|
editForm.remark = detail.remark ?? '';
|
||||||
|
editDialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEdit() {
|
||||||
|
if (!editForm.id || !editForm.storeId || !editForm.documentTitle) {
|
||||||
|
ElMessage.warning('请填写文档标题');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
await saveRagDocument({
|
||||||
|
id: editForm.id,
|
||||||
|
storeId: editForm.storeId,
|
||||||
|
attachmentId: editForm.attachmentId || undefined,
|
||||||
|
documentTitle: editForm.documentTitle,
|
||||||
|
documentSummary: editForm.documentSummary || undefined,
|
||||||
|
enabled: editForm.enabled,
|
||||||
|
remark: editForm.remark || undefined,
|
||||||
|
});
|
||||||
|
editDialogVisible.value = false;
|
||||||
|
ElMessage.success('文档信息已更新');
|
||||||
|
await loadDocs();
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeDoc(row: RagDocument) {
|
||||||
|
if (!row.id) return;
|
||||||
|
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确认删除文档「${row.documentTitle || '未命名'}」?`,
|
||||||
|
'删除确认',
|
||||||
|
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
|
||||||
|
);
|
||||||
|
|
||||||
|
await deleteRagDocument(String(row.id));
|
||||||
|
ElMessage.success('文档已删除');
|
||||||
|
await loadDocs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleEnabled(row: RagDocument) {
|
||||||
|
if (!row.id) return;
|
||||||
|
const newEnabled = !row.enabled;
|
||||||
|
saveRagDocument({
|
||||||
|
id: String(row.id),
|
||||||
|
storeId: row.storeId,
|
||||||
|
documentTitle: row.documentTitle ?? '',
|
||||||
|
enabled: newEnabled,
|
||||||
|
}).then(() => {
|
||||||
|
row.enabled = newEnabled;
|
||||||
|
ElMessage.success(`已${newEnabled ? '启用' : '停用'}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitParse() {
|
||||||
|
if (parseForm.documentIds.length === 0) {
|
||||||
|
ElMessage.warning('请选择需要解析的文档');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
parseSubmitting.value = true;
|
||||||
|
try {
|
||||||
|
await parseRagDocuments({
|
||||||
|
documentIds: parseForm.documentIds,
|
||||||
|
chunkStrategy: parseForm.chunkStrategy,
|
||||||
|
chunkSize: parseForm.chunkSize,
|
||||||
|
chunkOverlap: parseForm.chunkOverlap,
|
||||||
|
delimiter: parseForm.delimiter,
|
||||||
|
});
|
||||||
|
parseDialogVisible.value = false;
|
||||||
|
ElMessage.success('解析任务已提交');
|
||||||
|
await loadDocs();
|
||||||
|
} finally {
|
||||||
|
parseSubmitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStoreName(storeId: string) {
|
||||||
|
const store = storeOptions.value.find((s) => String(s.id) === storeId);
|
||||||
|
return store ? store.storeName : '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(status?: string | null) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
UPLOADED: '已上传',
|
||||||
|
PARSING: '解析中',
|
||||||
|
PARSED: '已解析',
|
||||||
|
FAILED: '解析失败',
|
||||||
|
PENDING: '待索引',
|
||||||
|
INDEXING: '索引中',
|
||||||
|
INDEXED: '已索引',
|
||||||
|
};
|
||||||
|
return status ? (map[status] ?? status) : '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getParseStatusType(status?: string | null) {
|
||||||
|
const success = ['PARSED'];
|
||||||
|
const warning = ['UPLOADED', 'PARSING'];
|
||||||
|
const danger = ['FAILED'];
|
||||||
|
if (!status) return 'info';
|
||||||
|
if (success.includes(status)) return 'success';
|
||||||
|
if (warning.includes(status)) return 'warning';
|
||||||
|
if (danger.includes(status)) return 'danger';
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIndexStatusType(status?: string | null) {
|
||||||
|
const success = ['INDEXED'];
|
||||||
|
const warning = ['PENDING', 'INDEXING'];
|
||||||
|
const danger = ['FAILED'];
|
||||||
|
if (!status) return 'info';
|
||||||
|
if (success.includes(status)) return 'success';
|
||||||
|
if (warning.includes(status)) return 'warning';
|
||||||
|
if (danger.includes(status)) return 'danger';
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const routeStoreId = route.query.storeId;
|
||||||
|
if (typeof routeStoreId === 'string') {
|
||||||
|
queryForm.storeId = routeStoreId;
|
||||||
|
}
|
||||||
|
loadStores();
|
||||||
|
loadDocs();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page-panel rag-doc-page">
|
||||||
|
<div class="page-panel__header">
|
||||||
|
<h2>知识文档</h2>
|
||||||
|
<span>Documents</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="document-query-bar" data-test="document-query-bar">
|
||||||
|
<el-form class="document-query-form" data-test="document-query-form" inline>
|
||||||
|
<el-form-item label="知识库">
|
||||||
|
<el-select
|
||||||
|
v-model="queryForm.storeId"
|
||||||
|
data-test="doc-store-filter"
|
||||||
|
placeholder="请选择"
|
||||||
|
clearable
|
||||||
|
class="query-control query-control--select"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="store in storeOptions"
|
||||||
|
:key="String(store.id)"
|
||||||
|
:label="store.storeName"
|
||||||
|
:value="String(store.id)"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="解析状态">
|
||||||
|
<el-select
|
||||||
|
v-model="queryForm.parseStatus"
|
||||||
|
data-test="doc-parse-filter"
|
||||||
|
placeholder="请选择"
|
||||||
|
clearable
|
||||||
|
class="query-control query-control--select"
|
||||||
|
>
|
||||||
|
<el-option label="已上传" value="UPLOADED" />
|
||||||
|
<el-option label="解析中" value="PARSING" />
|
||||||
|
<el-option label="已解析" value="PARSED" />
|
||||||
|
<el-option label="解析失败" value="FAILED" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="索引状态">
|
||||||
|
<el-select
|
||||||
|
v-model="queryForm.indexStatus"
|
||||||
|
data-test="doc-index-filter"
|
||||||
|
placeholder="请选择"
|
||||||
|
clearable
|
||||||
|
class="query-control query-control--select"
|
||||||
|
>
|
||||||
|
<el-option label="待索引" value="PENDING" />
|
||||||
|
<el-option label="索引中" value="INDEXING" />
|
||||||
|
<el-option label="已索引" value="INDEXED" />
|
||||||
|
<el-option label="索引失败" value="FAILED" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="启用状态">
|
||||||
|
<el-select
|
||||||
|
v-model="queryForm.enabled"
|
||||||
|
data-test="doc-enabled-filter"
|
||||||
|
placeholder="请选择"
|
||||||
|
clearable
|
||||||
|
class="query-control query-control--select"
|
||||||
|
>
|
||||||
|
<el-option label="启用" value="true" />
|
||||||
|
<el-option label="停用" value="false" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<el-input
|
||||||
|
v-model="queryForm.keyword"
|
||||||
|
data-test="doc-keyword-input"
|
||||||
|
placeholder="搜索标题/摘要/备注"
|
||||||
|
clearable
|
||||||
|
class="query-control query-control--keyword"
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item class="document-query-form__actions">
|
||||||
|
<el-button data-test="doc-search" type="primary" :icon="Search" @click="handleSearch">查询</el-button>
|
||||||
|
<el-button @click="handleReset">重置</el-button>
|
||||||
|
<el-button
|
||||||
|
data-test="open-batch-parse"
|
||||||
|
:icon="Operation"
|
||||||
|
:disabled="selectedDocuments.length === 0"
|
||||||
|
@click="openBatchParseDialog"
|
||||||
|
>
|
||||||
|
批量解析
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
data-test="open-doc-upload"
|
||||||
|
type="primary"
|
||||||
|
:icon="UploadFilled"
|
||||||
|
@click="openUploadDialog"
|
||||||
|
>
|
||||||
|
批量上传
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="filteredRows"
|
||||||
|
border
|
||||||
|
stripe
|
||||||
|
style="width: 100%"
|
||||||
|
@selection-change="handleSelectionChange"
|
||||||
|
>
|
||||||
|
<el-table-column type="selection" width="48" align="center" />
|
||||||
|
<el-table-column type="index" label="编号" width="70" align="center" />
|
||||||
|
<el-table-column prop="documentTitle" label="文档标题" min-width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column label="所属知识库" width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ getStoreName(row.storeId) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="解析状态" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="getParseStatusType(row.parseStatus)" size="small">
|
||||||
|
{{ getStatusLabel(row.parseStatus) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="索引状态" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="getIndexStatusType(row.indexStatus)" size="small">
|
||||||
|
{{ getStatusLabel(row.indexStatus) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="启用" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-switch
|
||||||
|
:model-value="row.enabled ?? false"
|
||||||
|
size="small"
|
||||||
|
@change="toggleEnabled(row)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="documentSummary" label="摘要" min-width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="createTime" label="创建时间" width="170" />
|
||||||
|
<el-table-column label="操作" width="210" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button :data-test="`doc-parse-${row.id}`" link type="primary" @click="openParseDialog([row])">解析</el-button>
|
||||||
|
<el-button :data-test="`doc-edit-${row.id}`" link type="primary" @click="openEditDialog(row)">编辑</el-button>
|
||||||
|
<el-button link type="danger" @click="removeDoc(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-empty v-if="!loading && filteredRows.length === 0" description="暂无知识文档" />
|
||||||
|
|
||||||
|
<RagDocumentBatchUploadDialog
|
||||||
|
v-model="uploadDialogVisible"
|
||||||
|
:stores="storeOptions"
|
||||||
|
:locked-store-id="queryForm.storeId || null"
|
||||||
|
@uploaded="loadDocs"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="parseDialogVisible"
|
||||||
|
data-test="document-parse-dialog"
|
||||||
|
title="解析配置"
|
||||||
|
width="620px"
|
||||||
|
>
|
||||||
|
<el-form :model="parseForm" label-width="112px">
|
||||||
|
<el-form-item label="文档数量">
|
||||||
|
<el-tag>{{ parseForm.documentIds.length }} 个文档</el-tag>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="切片方式">
|
||||||
|
<el-radio-group v-model="parseForm.chunkStrategy" class="chunk-strategy-group">
|
||||||
|
<el-radio
|
||||||
|
v-for="strategy in chunkStrategyOptions"
|
||||||
|
:key="strategy.value"
|
||||||
|
:value="strategy.value"
|
||||||
|
class="chunk-strategy-option"
|
||||||
|
>
|
||||||
|
<span class="chunk-strategy-option__label">{{ strategy.label }}</span>
|
||||||
|
<small>{{ strategy.description }}</small>
|
||||||
|
</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="parseForm.chunkStrategy === 'FIXED_LENGTH'" label="切片长度">
|
||||||
|
<el-input-number v-model="parseForm.chunkSize" :min="100" :max="4000" :step="100" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="parseForm.chunkStrategy === 'FIXED_LENGTH'" label="重叠长度">
|
||||||
|
<el-input-number v-model="parseForm.chunkOverlap" :min="0" :max="1000" :step="20" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="parseForm.chunkStrategy === 'DELIMITER'" label="分隔符">
|
||||||
|
<el-input v-model="parseForm.delimiter" maxlength="20" placeholder="如 。、换行符或自定义符号" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="parseDialogVisible = false">取消</el-button>
|
||||||
|
<el-button
|
||||||
|
data-test="document-parse-submit"
|
||||||
|
type="primary"
|
||||||
|
:loading="parseSubmitting"
|
||||||
|
@click="submitParse"
|
||||||
|
>
|
||||||
|
开始解析
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 编辑对话框 -->
|
||||||
|
<el-dialog v-model="editDialogVisible" title="编辑文档" width="560px">
|
||||||
|
<el-form :model="editForm" label-width="96px">
|
||||||
|
<el-form-item label="文档标题" required>
|
||||||
|
<el-input v-model="editForm.documentTitle" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="文档摘要">
|
||||||
|
<el-input v-model="editForm.documentSummary" type="textarea" :rows="3" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="启用">
|
||||||
|
<el-switch v-model="editForm.enabled" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注">
|
||||||
|
<el-input v-model="editForm.remark" type="textarea" :rows="2" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="editDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="submitEdit">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rag-doc-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-query-bar {
|
||||||
|
padding: 18px 28px 17px;
|
||||||
|
border-bottom: 1px solid #e8edf5;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-query-form {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-query-form :deep(.el-form-item) {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-query-form :deep(.el-form-item__label) {
|
||||||
|
height: 38px;
|
||||||
|
padding-right: 8px;
|
||||||
|
color: #606266;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-query-form :deep(.el-input__wrapper),
|
||||||
|
.document-query-form :deep(.el-select__wrapper) {
|
||||||
|
min-height: 38px;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 0 0 1px #d8dee9 inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-query-form :deep(.el-input__wrapper:hover),
|
||||||
|
.document-query-form :deep(.el-select__wrapper:hover) {
|
||||||
|
box-shadow: 0 0 0 1px #b9c6d8 inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-control--select {
|
||||||
|
width: 168px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-control--keyword {
|
||||||
|
width: 225px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-query-form__actions {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-query-form__actions :deep(.el-form-item__content) {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-strategy-group {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-strategy-option {
|
||||||
|
align-items: flex-start;
|
||||||
|
height: auto;
|
||||||
|
margin-right: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #d8dee9;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-strategy-option :deep(.el-radio__label) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-strategy-option__label {
|
||||||
|
color: #303133;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-strategy-option small {
|
||||||
|
color: #7a8599;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.document-query-bar {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-query-form {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-control--select,
|
||||||
|
.query-control--keyword {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-query-form__actions {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-query-form__actions :deep(.el-form-item__content) {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-strategy-group {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { CirclePlus, Delete, Edit, FolderAdd, Refresh, Search, UploadFilled } from '@element-plus/icons-vue';
|
import { CirclePlus, Delete, Edit, FolderAdd, Refresh, Search, UploadFilled } from '@element-plus/icons-vue';
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
import { computed, onMounted, reactive, ref } from 'vue';
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
deleteRagStore,
|
deleteRagStore,
|
||||||
@@ -14,9 +15,11 @@ import {
|
|||||||
type RagStoreOverview,
|
type RagStoreOverview,
|
||||||
type RagStore,
|
type RagStore,
|
||||||
} from '@/api/ragStores';
|
} from '@/api/ragStores';
|
||||||
|
import RagDocumentBatchUploadDialog from '@/components/rag/RagDocumentBatchUploadDialog.vue';
|
||||||
|
|
||||||
type StoreStatus = '启用' | '停用';
|
type StoreStatus = '启用' | '停用';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const detailLoading = ref(false);
|
const detailLoading = ref(false);
|
||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
@@ -32,6 +35,7 @@ const queryForm = reactive({
|
|||||||
|
|
||||||
const createDialogVisible = ref(false);
|
const createDialogVisible = ref(false);
|
||||||
const editDialogVisible = ref(false);
|
const editDialogVisible = ref(false);
|
||||||
|
const uploadDialogVisible = ref(false);
|
||||||
|
|
||||||
const createForm = reactive({
|
const createForm = reactive({
|
||||||
storeCode: '',
|
storeCode: '',
|
||||||
@@ -223,6 +227,31 @@ function showFutureMessage(actionName: string) {
|
|||||||
ElMessage.info(`${actionName} 会在下一批接口里补齐`);
|
ElMessage.info(`${actionName} 会在下一批接口里补齐`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openBatchUploadDialog() {
|
||||||
|
if (!activeStore.value?.id) {
|
||||||
|
ElMessage.warning('请选择知识库');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uploadDialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewActiveStoreDocuments() {
|
||||||
|
if (!activeStore.value?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push({
|
||||||
|
name: 'rag-documents',
|
||||||
|
query: { storeId: String(activeStore.value.id) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAfterUpload() {
|
||||||
|
await Promise.all([
|
||||||
|
loadOverview(),
|
||||||
|
activeStoreId.value ? selectStore(activeStoreId.value) : Promise.resolve(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
function getStatusTagType(status?: string | null) {
|
function getStatusTagType(status?: string | null) {
|
||||||
return status === '启用' ? 'success' : 'info';
|
return status === '启用' ? 'success' : 'info';
|
||||||
}
|
}
|
||||||
@@ -314,7 +343,12 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="detail-actions">
|
<div class="detail-actions">
|
||||||
<el-button :icon="Edit" @click="openEditDialog">编辑</el-button>
|
<el-button :icon="Edit" @click="openEditDialog">编辑</el-button>
|
||||||
<el-button type="primary" :icon="UploadFilled" @click="showFutureMessage('批量导入文件')">
|
<el-button
|
||||||
|
data-test="store-batch-upload"
|
||||||
|
type="primary"
|
||||||
|
:icon="UploadFilled"
|
||||||
|
@click="openBatchUploadDialog"
|
||||||
|
>
|
||||||
批量导入文件
|
批量导入文件
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button :icon="FolderAdd" @click="showFutureMessage('重建索引')">重建索引</el-button>
|
<el-button :icon="FolderAdd" @click="showFutureMessage('重建索引')">重建索引</el-button>
|
||||||
@@ -353,7 +387,14 @@ onMounted(() => {
|
|||||||
<article class="detail-card detail-card--placeholder">
|
<article class="detail-card detail-card--placeholder">
|
||||||
<div class="detail-card__header">
|
<div class="detail-card__header">
|
||||||
<h4>文档概览</h4>
|
<h4>文档概览</h4>
|
||||||
<span>已对接后端聚合接口</span>
|
<el-button
|
||||||
|
data-test="view-store-documents"
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
@click="viewActiveStoreDocuments"
|
||||||
|
>
|
||||||
|
查看文档
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-descriptions :column="2" border>
|
<el-descriptions :column="2" border>
|
||||||
<el-descriptions-item label="文档总数">
|
<el-descriptions-item label="文档总数">
|
||||||
@@ -456,6 +497,13 @@ onMounted(() => {
|
|||||||
<el-button type="primary" :loading="submitting" @click="submitEditStore">保存</el-button>
|
<el-button type="primary" :loading="submitting" @click="submitEditStore">保存</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<RagDocumentBatchUploadDialog
|
||||||
|
v-model="uploadDialogVisible"
|
||||||
|
:stores="activeStore ? [activeStore] : storeRows"
|
||||||
|
:locked-store-id="activeStoreId"
|
||||||
|
@uploaded="refreshAfterUpload"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
203
frontend/src/pages/rag/__tests__/RagDocumentsPage.spec.ts
Normal file
203
frontend/src/pages/rag/__tests__/RagDocumentsPage.spec.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { flushPromises, mount } from '@vue/test-utils';
|
||||||
|
import ElementPlus from 'element-plus';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import RagDocumentsPage from '../RagDocumentsPage.vue';
|
||||||
|
import { getRagDocumentById, parseRagDocuments, queryRagDocuments } from '@/api/ragDocuments';
|
||||||
|
import { queryRagStores } from '@/api/ragStores';
|
||||||
|
|
||||||
|
const routeQuery = vi.hoisted(() => ({ storeId: undefined as string | undefined }));
|
||||||
|
|
||||||
|
vi.mock('vue-router', () => ({
|
||||||
|
useRoute: () => ({
|
||||||
|
query: routeQuery,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/api/ragStores', () => ({
|
||||||
|
queryRagStores: vi.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
resultcode: '0',
|
||||||
|
message: null,
|
||||||
|
data: [
|
||||||
|
{ id: '1', storeCode: 'PROD_DOC', storeName: '产品制度库', status: '启用' },
|
||||||
|
{ id: '2', storeCode: 'FAQ', storeName: 'FAQ知识库', status: '停用' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/api/ragDocuments', () => ({
|
||||||
|
SOURCE_TYPE_RAG: 'RAG',
|
||||||
|
queryRagDocuments: vi.fn((query?: { storeId?: string }) => {
|
||||||
|
const rows = [
|
||||||
|
{
|
||||||
|
id: '11',
|
||||||
|
storeId: '1',
|
||||||
|
attachmentId: '101',
|
||||||
|
documentTitle: '产品制度总则',
|
||||||
|
documentSummary: '制度摘要',
|
||||||
|
parseStatus: 'UPLOADED',
|
||||||
|
indexStatus: 'PENDING',
|
||||||
|
enabled: true,
|
||||||
|
remark: '制度文档',
|
||||||
|
createTime: '2026-05-21 10:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '22',
|
||||||
|
storeId: '2',
|
||||||
|
attachmentId: '202',
|
||||||
|
documentTitle: 'FAQ 手册',
|
||||||
|
documentSummary: 'FAQ 摘要',
|
||||||
|
parseStatus: 'PARSED',
|
||||||
|
indexStatus: 'INDEXED',
|
||||||
|
enabled: false,
|
||||||
|
remark: '常见问题',
|
||||||
|
createTime: '2026-05-21 11:00:00',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const data = query?.storeId ? rows.filter((row) => row.storeId === query.storeId) : rows;
|
||||||
|
return Promise.resolve({ resultcode: '0', message: null, data });
|
||||||
|
}),
|
||||||
|
getRagDocumentById: vi.fn((id: string) =>
|
||||||
|
Promise.resolve({
|
||||||
|
resultcode: '0',
|
||||||
|
message: null,
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
storeId: '2',
|
||||||
|
attachmentId: '202',
|
||||||
|
documentTitle: 'FAQ 手册',
|
||||||
|
documentSummary: 'FAQ 摘要',
|
||||||
|
enabled: false,
|
||||||
|
remark: '常见问题',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
saveRagDocument: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: true })),
|
||||||
|
deleteRagDocument: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: true })),
|
||||||
|
batchUploadRagDocuments: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: [] })),
|
||||||
|
parseRagDocuments: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: [] })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('RagDocumentsPage', () => {
|
||||||
|
it('loads documents from query api', async () => {
|
||||||
|
routeQuery.storeId = undefined;
|
||||||
|
const wrapper = mount(RagDocumentsPage, {
|
||||||
|
global: {
|
||||||
|
plugins: [ElementPlus],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(queryRagStores).toHaveBeenCalled();
|
||||||
|
expect(queryRagDocuments).toHaveBeenCalledWith({});
|
||||||
|
expect(wrapper.text()).toContain('产品制度总则');
|
||||||
|
expect(wrapper.text()).toContain('FAQ 手册');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders document filters as a form-style query bar', async () => {
|
||||||
|
routeQuery.storeId = undefined;
|
||||||
|
const wrapper = mount(RagDocumentsPage, {
|
||||||
|
global: {
|
||||||
|
plugins: [ElementPlus],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="document-query-bar"]').exists()).toBe(true);
|
||||||
|
expect(wrapper.find('[data-test="document-query-form"]').exists()).toBe(true);
|
||||||
|
expect(wrapper.find('.toolbar__filters').exists()).toBe(false);
|
||||||
|
const labels = wrapper.findAll('.document-query-form .el-form-item__label').map((label) => label.text());
|
||||||
|
expect(labels).toEqual(expect.arrayContaining(['知识库', '解析状态', '索引状态', '启用状态']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses route storeId as the default document query', async () => {
|
||||||
|
routeQuery.storeId = '2';
|
||||||
|
const wrapper = mount(RagDocumentsPage, {
|
||||||
|
global: {
|
||||||
|
plugins: [ElementPlus],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(queryRagDocuments).toHaveBeenCalledWith({ storeId: '2' });
|
||||||
|
expect(wrapper.text()).toContain('FAQ 手册');
|
||||||
|
expect(wrapper.text()).not.toContain('产品制度总则');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads backend detail when editing a row', async () => {
|
||||||
|
routeQuery.storeId = undefined;
|
||||||
|
const wrapper = mount(RagDocumentsPage, {
|
||||||
|
global: {
|
||||||
|
plugins: [ElementPlus],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
await wrapper.get('[data-test="doc-edit-22"]').trigger('click');
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(getRagDocumentById).toHaveBeenCalledWith('22');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens parse dialog with chunk strategy options from row action', async () => {
|
||||||
|
routeQuery.storeId = undefined;
|
||||||
|
const wrapper = mount(RagDocumentsPage, {
|
||||||
|
global: {
|
||||||
|
plugins: [ElementPlus],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
await wrapper.get('[data-test="doc-parse-11"]').trigger('click');
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="document-parse-dialog"]').exists()).toBe(true);
|
||||||
|
expect(wrapper.text()).toContain('固定长度切片');
|
||||||
|
expect(wrapper.text()).toContain('按分隔符切片');
|
||||||
|
expect(wrapper.text()).toContain('语义切片');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits parse request with selected chunk strategy', async () => {
|
||||||
|
routeQuery.storeId = undefined;
|
||||||
|
const wrapper = mount(RagDocumentsPage, {
|
||||||
|
global: {
|
||||||
|
plugins: [ElementPlus],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
await wrapper.get('[data-test="doc-parse-11"]').trigger('click');
|
||||||
|
await flushPromises();
|
||||||
|
await wrapper.get('[data-test="document-parse-submit"]').trigger('click');
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(parseRagDocuments).toHaveBeenCalledWith({
|
||||||
|
documentIds: ['11'],
|
||||||
|
chunkStrategy: 'FIXED_LENGTH',
|
||||||
|
chunkSize: 800,
|
||||||
|
chunkOverlap: 120,
|
||||||
|
delimiter: '。',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders reusable upload dialog with drag upload area', async () => {
|
||||||
|
routeQuery.storeId = '1';
|
||||||
|
const wrapper = mount(RagDocumentsPage, {
|
||||||
|
global: {
|
||||||
|
plugins: [ElementPlus],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
await wrapper.get('[data-test="open-doc-upload"]').trigger('click');
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('拖拽文件到此处');
|
||||||
|
expect(wrapper.find('[data-test="batch-upload-locked-store"]').exists()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,19 @@ import {
|
|||||||
saveRagStore,
|
saveRagStore,
|
||||||
} from '@/api/ragStores';
|
} from '@/api/ragStores';
|
||||||
|
|
||||||
|
const routerPush = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock('vue-router', () => ({
|
||||||
|
useRouter: () => ({
|
||||||
|
push: routerPush,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/api/ragDocuments', () => ({
|
||||||
|
SOURCE_TYPE_RAG: 'RAG',
|
||||||
|
batchUploadRagDocuments: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: [] })),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@/api/ragStores', () => ({
|
vi.mock('@/api/ragStores', () => ({
|
||||||
getRagStoreOverview: vi.fn(() =>
|
getRagStoreOverview: vi.fn(() =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
@@ -181,4 +194,35 @@ describe('RagStoresPage', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('links document overview to the selected store documents page', async () => {
|
||||||
|
const wrapper = mount(RagStoresPage, {
|
||||||
|
global: {
|
||||||
|
plugins: [ElementPlus],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
await wrapper.get('[data-test="view-store-documents"]').trigger('click');
|
||||||
|
|
||||||
|
expect(routerPush).toHaveBeenCalledWith({
|
||||||
|
name: 'rag-documents',
|
||||||
|
query: { storeId: '1' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens reusable locked upload dialog from store detail', async () => {
|
||||||
|
const wrapper = mount(RagStoresPage, {
|
||||||
|
global: {
|
||||||
|
plugins: [ElementPlus],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
await wrapper.get('[data-test="store-batch-upload"]').trigger('click');
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('拖拽文件到此处');
|
||||||
|
expect(wrapper.text()).toContain('产品制度库');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import type { RouteRecordRaw } from 'vue-router';
|
import type { RouteRecordRaw } from 'vue-router';
|
||||||
import { createRouter, createWebHistory } from 'vue-router';
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
|
||||||
import DashboardPage from '@/pages/DashboardPage.vue';
|
import DashboardPage from '@/pages/dashboard/DashboardPage.vue';
|
||||||
import NotFoundPage from '@/pages/NotFoundPage.vue';
|
import NotFoundPage from '@/pages/common/NotFoundPage.vue';
|
||||||
import RagDocumentsPage from '@/pages/RagDocumentsPage.vue';
|
import RagDocumentsPage from '@/pages/rag/RagDocumentsPage.vue';
|
||||||
import RagStoresPage from '@/pages/RagStoresPage.vue';
|
import RagStoresPage from '@/pages/rag/RagStoresPage.vue';
|
||||||
import SystemAttachmentsPage from '@/pages/SystemAttachmentsPage.vue';
|
import SystemAttachmentsPage from '@/pages/system/SystemAttachmentsPage.vue';
|
||||||
import SystemEnumsPage from '@/pages/SystemEnumsPage.vue';
|
import SystemEnumsPage from '@/pages/system/SystemEnumsPage.vue';
|
||||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||||
|
|
||||||
export const routes: RouteRecordRaw[] = [
|
export const routes: RouteRecordRaw[] = [
|
||||||
|
|||||||
Reference in New Issue
Block a user