refactor(frontend): 重构RAG页面结构并收敛无效入口

This commit is contained in:
2026-05-24 22:03:42 +08:00
parent cfa5d1f4e1
commit e51903efbe
14 changed files with 1066 additions and 695 deletions

View File

@@ -1,639 +1,7 @@
<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,
RAG_CHUNK_STRATEGY,
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: RAG_CHUNK_STRATEGY.FIXED_LENGTH as RagChunkStrategy,
chunkSize: 800,
chunkOverlap: 120,
delimiter: '。',
});
const chunkStrategyOptions: Array<{ label: string; value: RagChunkStrategy; description: string }> = [
{ label: '固定长度切片', value: RAG_CHUNK_STRATEGY.FIXED_LENGTH, description: '按指定长度和重叠长度切分通用文本' },
{ label: '按段落切片', value: RAG_CHUNK_STRATEGY.PARAGRAPH, description: '按空行、自然段落边界切分' },
{ label: '按标题层级切片', value: RAG_CHUNK_STRATEGY.HEADING, description: '按标题和章节层级组织内容' },
{ label: '按表格行切片', value: RAG_CHUNK_STRATEGY.TABLE_ROW, description: '适合 Excel 表格和结构化明细数据' },
{ label: '按分隔符切片', value: RAG_CHUNK_STRATEGY.DELIMITER, description: '按句号、换行符或自定义分隔符切分' },
{ label: '语义切片', value: RAG_CHUNK_STRATEGY.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 = RAG_CHUNK_STRATEGY.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();
});
import RagDocumentsPage from '@/pages/rag/documents/RagDocumentsPage.vue';
</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 === RAG_CHUNK_STRATEGY.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 === RAG_CHUNK_STRATEGY.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 === RAG_CHUNK_STRATEGY.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>
<RagDocumentsPage />
</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>