refactor(frontend): 重构RAG页面结构并收敛无效入口
This commit is contained in:
@@ -65,6 +65,10 @@ export type RagChunkStrategy = (typeof RAG_CHUNK_STRATEGY)[keyof typeof RAG_CHUN
|
||||
|
||||
export interface RagDocumentParseRequest {
|
||||
documentIds: string[];
|
||||
}
|
||||
|
||||
export interface RagDocumentChunkRequest {
|
||||
documentIds: string[];
|
||||
chunkStrategy: RagChunkStrategy;
|
||||
chunkSize?: number;
|
||||
chunkOverlap?: number;
|
||||
@@ -123,3 +127,7 @@ export function batchUploadRagDocuments(data: RagDocumentBatchUploadRequest) {
|
||||
export function parseRagDocuments(data: RagDocumentParseRequest) {
|
||||
return post<RagDocumentParseResponse[], RagDocumentParseRequest>('/rag/documents/parse', data);
|
||||
}
|
||||
|
||||
export function chunkRagDocuments(data: RagDocumentChunkRequest) {
|
||||
return post<boolean, RagDocumentChunkRequest>('/rag/documents/chunk', data);
|
||||
}
|
||||
|
||||
59
frontend/src/components/rag/chunk/RagChunkTaskBoard.vue
Normal file
59
frontend/src/components/rag/chunk/RagChunkTaskBoard.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
total: number;
|
||||
parsed: number;
|
||||
failed: number;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="task-board">
|
||||
<div class="task-card">
|
||||
<strong>{{ total }}</strong>
|
||||
<span>文档总数</span>
|
||||
</div>
|
||||
<div class="task-card">
|
||||
<strong>{{ parsed }}</strong>
|
||||
<span>可切片文档</span>
|
||||
</div>
|
||||
<div class="task-card">
|
||||
<strong>{{ failed }}</strong>
|
||||
<span>解析失败文档</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.task-board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 14px;
|
||||
border: 1px solid #d8dee9;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.task-card strong {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
color: #2f3a4f;
|
||||
}
|
||||
|
||||
.task-card span {
|
||||
color: #738099;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.task-board {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
38
frontend/src/components/rag/document/RagFlowOverview.vue
Normal file
38
frontend/src/components/rag/document/RagFlowOverview.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import type { RagDocument } from '@/api/ragDocuments';
|
||||
|
||||
const props = defineProps<{
|
||||
documents: RagDocument[];
|
||||
}>();
|
||||
|
||||
const total = computed(() => props.documents.length);
|
||||
const parsing = computed(() => props.documents.filter((row) => row.parseStatus === 'PARSING').length);
|
||||
const parsed = computed(() => props.documents.filter((row) => row.parseStatus === 'PARSED').length);
|
||||
const failed = computed(() => props.documents.filter((row) => row.parseStatus === 'FAILED').length);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flow-overview">
|
||||
<el-tag type="info">总文档 {{ total }}</el-tag>
|
||||
<el-tag type="warning">解析中 {{ parsing }}</el-tag>
|
||||
<el-tag type="success">可切片 {{ parsed }}</el-tag>
|
||||
<el-tag type="danger">解析失败 {{ failed }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.flow-overview {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding: 14px 28px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.flow-overview {
|
||||
padding: 14px 16px 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,18 +2,18 @@
|
||||
import {
|
||||
Box,
|
||||
Collection,
|
||||
DataBoard,
|
||||
Document,
|
||||
Files,
|
||||
Grid,
|
||||
Histogram,
|
||||
List,
|
||||
} from '@element-plus/icons-vue';
|
||||
|
||||
const menuItems = [
|
||||
{ path: '/dashboard', label: '工作台', icon: DataBoard },
|
||||
{ path: '/system/enums', label: '系统枚举', icon: Grid },
|
||||
{ path: '/system/attachments', label: '附件管理', icon: Files },
|
||||
{ path: '/rag/stores', label: '知识库', icon: Collection },
|
||||
{ path: '/rag/workbench', label: 'RAG工作台', icon: Histogram },
|
||||
{ path: '/rag/documents', label: '知识文档', icon: Document },
|
||||
{ path: '/rag/tasks/chunk', label: '切片任务', icon: List },
|
||||
];
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<main class="not-found">
|
||||
<h1>404</h1>
|
||||
<RouterLink to="/dashboard">返回工作台</RouterLink>
|
||||
<RouterLink to="/rag/workbench">返回RAG工作台</RouterLink>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
<template>
|
||||
<section class="page-panel">
|
||||
<div class="page-panel__header">
|
||||
<h2>工作台</h2>
|
||||
<span>Console</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -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>
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 { chunkRagDocuments, getRagDocumentById, parseRagDocuments, queryRagDocuments } from '@/api/ragDocuments';
|
||||
import { queryRagStores } from '@/api/ragStores';
|
||||
|
||||
const routeQuery = vi.hoisted(() => ({ storeId: undefined as string | undefined }));
|
||||
@@ -85,6 +85,7 @@ vi.mock('@/api/ragDocuments', () => ({
|
||||
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: [] })),
|
||||
chunkRagDocuments: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: true })),
|
||||
parseRagDocuments: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: [] })),
|
||||
}));
|
||||
|
||||
@@ -152,7 +153,7 @@ describe('RagDocumentsPage', () => {
|
||||
expect(getRagDocumentById).toHaveBeenCalledWith('22');
|
||||
});
|
||||
|
||||
it('opens parse dialog with chunk strategy options from row action', async () => {
|
||||
it('opens chunk dialog with chunk strategy options from row action', async () => {
|
||||
routeQuery.storeId = undefined;
|
||||
const wrapper = mount(RagDocumentsPage, {
|
||||
global: {
|
||||
@@ -161,16 +162,16 @@ describe('RagDocumentsPage', () => {
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
await wrapper.get('[data-test="doc-parse-11"]').trigger('click');
|
||||
await wrapper.get('[data-test="doc-chunk-22"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-test="document-parse-dialog"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-test="document-chunk-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 () => {
|
||||
it('submits chunk request with selected chunk strategy', async () => {
|
||||
routeQuery.storeId = undefined;
|
||||
const wrapper = mount(RagDocumentsPage, {
|
||||
global: {
|
||||
@@ -179,13 +180,13 @@ describe('RagDocumentsPage', () => {
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
await wrapper.get('[data-test="doc-parse-11"]').trigger('click');
|
||||
await wrapper.get('[data-test="doc-chunk-22"]').trigger('click');
|
||||
await flushPromises();
|
||||
await wrapper.get('[data-test="document-parse-submit"]').trigger('click');
|
||||
await wrapper.get('[data-test="document-chunk-submit"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(parseRagDocuments).toHaveBeenCalledWith({
|
||||
documentIds: ['11'],
|
||||
expect(chunkRagDocuments).toHaveBeenCalledWith({
|
||||
documentIds: ['22'],
|
||||
chunkStrategy: 1,
|
||||
chunkSize: 800,
|
||||
chunkOverlap: 120,
|
||||
@@ -193,6 +194,39 @@ describe('RagDocumentsPage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('retries parse for failed document', async () => {
|
||||
routeQuery.storeId = undefined;
|
||||
vi.mocked(queryRagDocuments).mockResolvedValueOnce({
|
||||
resultcode: '0',
|
||||
message: null,
|
||||
data: [
|
||||
{
|
||||
id: '33',
|
||||
storeId: '1',
|
||||
attachmentId: '303',
|
||||
documentTitle: '失败文档',
|
||||
documentSummary: '失败摘要',
|
||||
parseStatus: 'FAILED',
|
||||
indexStatus: 'PENDING',
|
||||
enabled: true,
|
||||
remark: '失败',
|
||||
createTime: '2026-05-21 11:00:00',
|
||||
},
|
||||
],
|
||||
});
|
||||
const wrapper = mount(RagDocumentsPage, {
|
||||
global: {
|
||||
plugins: [ElementPlus],
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
await wrapper.get('[data-test="doc-retry-parse-33"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(parseRagDocuments).toHaveBeenCalledWith({ documentIds: ['33'] });
|
||||
});
|
||||
|
||||
it('renders reusable upload dialog with drag upload area', async () => {
|
||||
routeQuery.storeId = '1';
|
||||
const wrapper = mount(RagDocumentsPage, {
|
||||
|
||||
708
frontend/src/pages/rag/documents/RagDocumentsPage.vue
Normal file
708
frontend/src/pages/rag/documents/RagDocumentsPage.vue
Normal file
@@ -0,0 +1,708 @@
|
||||
<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 {
|
||||
chunkRagDocuments,
|
||||
deleteRagDocument,
|
||||
getRagDocumentById,
|
||||
parseRagDocuments,
|
||||
queryRagDocuments,
|
||||
saveRagDocument,
|
||||
RAG_CHUNK_STRATEGY,
|
||||
type RagChunkStrategy,
|
||||
type RagDocument,
|
||||
} from '@/api/ragDocuments';
|
||||
import { queryRagStores, type RagStore } from '@/api/ragStores';
|
||||
import RagChunkTaskBoard from '@/components/rag/chunk/RagChunkTaskBoard.vue';
|
||||
import RagDocumentBatchUploadDialog from '@/components/rag/RagDocumentBatchUploadDialog.vue';
|
||||
import RagFlowOverview from '@/components/rag/document/RagFlowOverview.vue';
|
||||
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const parseSubmitting = ref(false);
|
||||
const retryParsing = 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 chunkDialogVisible = ref(false);
|
||||
const selectedDocuments = ref<RagDocument[]>([]);
|
||||
|
||||
const editForm = reactive({
|
||||
id: '',
|
||||
storeId: '',
|
||||
attachmentId: '',
|
||||
documentTitle: '',
|
||||
documentSummary: '',
|
||||
enabled: true,
|
||||
remark: '',
|
||||
});
|
||||
|
||||
const chunkForm = 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;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const parsedCount = computed(() => docRows.value.filter((row) => row.parseStatus === 'PARSED').length);
|
||||
const failedCount = computed(() => docRows.value.filter((row) => row.parseStatus === 'FAILED').length);
|
||||
const selectedParsedCount = computed(() => selectedDocuments.value.filter((row) => row.parseStatus === 'PARSED').length);
|
||||
const selectedFailedCount = computed(() => selectedDocuments.value.filter((row) => row.parseStatus === 'FAILED').length);
|
||||
|
||||
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 openChunkDialog(rows: RagDocument[]) {
|
||||
const parsedRows = rows.filter((row) => row.parseStatus === 'PARSED');
|
||||
const ids = parsedRows.map((row) => String(row.id ?? '')).filter(Boolean);
|
||||
if (ids.length === 0) {
|
||||
ElMessage.warning('请选择解析完成(PARSED)的文档进行切片');
|
||||
return;
|
||||
}
|
||||
if (parsedRows.length < rows.length) {
|
||||
ElMessage.warning(`已自动跳过 ${rows.length - parsedRows.length} 个未解析完成文档`);
|
||||
}
|
||||
chunkForm.documentIds = ids;
|
||||
chunkForm.chunkStrategy = RAG_CHUNK_STRATEGY.FIXED_LENGTH;
|
||||
chunkForm.chunkSize = 800;
|
||||
chunkForm.chunkOverlap = 120;
|
||||
chunkForm.delimiter = '。';
|
||||
chunkDialogVisible.value = true;
|
||||
}
|
||||
|
||||
function openBatchChunkDialog() {
|
||||
openChunkDialog(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 submitChunk() {
|
||||
if (chunkForm.documentIds.length === 0) {
|
||||
ElMessage.warning('请选择需要切片的文档');
|
||||
return;
|
||||
}
|
||||
parseSubmitting.value = true;
|
||||
try {
|
||||
await chunkRagDocuments({
|
||||
documentIds: chunkForm.documentIds,
|
||||
chunkStrategy: chunkForm.chunkStrategy,
|
||||
chunkSize: chunkForm.chunkSize,
|
||||
chunkOverlap: chunkForm.chunkOverlap,
|
||||
delimiter: chunkForm.delimiter,
|
||||
});
|
||||
chunkDialogVisible.value = false;
|
||||
ElMessage.success('切片任务已提交');
|
||||
await loadDocs();
|
||||
} finally {
|
||||
parseSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function retryParseRows(rows: RagDocument[]) {
|
||||
const ids = rows
|
||||
.filter((row) => row.parseStatus === 'FAILED')
|
||||
.map((row) => String(row.id ?? ''))
|
||||
.filter(Boolean);
|
||||
if (ids.length === 0) {
|
||||
ElMessage.warning('请选择解析失败(FAILED)的文档重试解析');
|
||||
return;
|
||||
}
|
||||
retryParsing.value = true;
|
||||
try {
|
||||
await parseRagDocuments({ documentIds: ids });
|
||||
ElMessage.success('已提交解析重试任务');
|
||||
await loadDocs();
|
||||
} finally {
|
||||
retryParsing.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>上传自动解析,切片手动触发</span>
|
||||
</div>
|
||||
|
||||
<RagFlowOverview :documents="docRows" />
|
||||
<div class="task-board-wrap">
|
||||
<RagChunkTaskBoard :total="docRows.length" :parsed="parsedCount" :failed="failedCount" />
|
||||
</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-chunk"
|
||||
:icon="Operation"
|
||||
:disabled="selectedParsedCount === 0"
|
||||
@click="openBatchChunkDialog"
|
||||
>
|
||||
批量切片({{ selectedParsedCount }})
|
||||
</el-button>
|
||||
<el-button
|
||||
data-test="retry-batch-parse"
|
||||
:loading="retryParsing"
|
||||
:disabled="selectedFailedCount === 0"
|
||||
@click="retryParseRows(selectedDocuments)"
|
||||
>
|
||||
重试解析({{ selectedFailedCount }})
|
||||
</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="280" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
:data-test="`doc-chunk-${row.id}`"
|
||||
link
|
||||
type="primary"
|
||||
:disabled="row.parseStatus !== 'PARSED'"
|
||||
@click="openChunkDialog([row])"
|
||||
>
|
||||
切片
|
||||
</el-button>
|
||||
<el-button
|
||||
:data-test="`doc-retry-parse-${row.id}`"
|
||||
link
|
||||
type="warning"
|
||||
:disabled="row.parseStatus !== 'FAILED'"
|
||||
@click="retryParseRows([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="chunkDialogVisible"
|
||||
data-test="document-chunk-dialog"
|
||||
title="切片配置"
|
||||
width="620px"
|
||||
>
|
||||
<el-form :model="chunkForm" label-width="112px">
|
||||
<el-form-item label="文档数量">
|
||||
<el-tag>{{ chunkForm.documentIds.length }} 个文档</el-tag>
|
||||
</el-form-item>
|
||||
<el-form-item label="切片方式">
|
||||
<el-radio-group v-model="chunkForm.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="chunkForm.chunkStrategy === RAG_CHUNK_STRATEGY.FIXED_LENGTH" label="切片长度">
|
||||
<el-input-number v-model="chunkForm.chunkSize" :min="100" :max="4000" :step="100" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="chunkForm.chunkStrategy === RAG_CHUNK_STRATEGY.FIXED_LENGTH" label="重叠长度">
|
||||
<el-input-number v-model="chunkForm.chunkOverlap" :min="0" :max="1000" :step="20" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="chunkForm.chunkStrategy === RAG_CHUNK_STRATEGY.DELIMITER" label="分隔符">
|
||||
<el-input v-model="chunkForm.delimiter" maxlength="20" placeholder="如 。、换行符或自定义符号" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="chunkDialogVisible = false">取消</el-button>
|
||||
<el-button
|
||||
data-test="document-chunk-submit"
|
||||
type="primary"
|
||||
:loading="parseSubmitting"
|
||||
@click="submitChunk"
|
||||
>
|
||||
开始切片
|
||||
</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;
|
||||
}
|
||||
|
||||
.task-board-wrap {
|
||||
padding: 10px 28px 0;
|
||||
}
|
||||
|
||||
.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) {
|
||||
.task-board-wrap {
|
||||
padding: 10px 16px 0;
|
||||
}
|
||||
|
||||
.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>
|
||||
96
frontend/src/pages/rag/tasks/RagChunkTasksPage.vue
Normal file
96
frontend/src/pages/rag/tasks/RagChunkTasksPage.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { queryRagDocuments } from '@/api/ragDocuments';
|
||||
import RagChunkTaskBoard from '@/components/rag/chunk/RagChunkTaskBoard.vue';
|
||||
|
||||
const loading = ref(false);
|
||||
const docs = ref<any[]>([]);
|
||||
const router = useRouter();
|
||||
|
||||
const failedDocs = computed(() => docs.value.filter((row) => row.parseStatus === 'FAILED'));
|
||||
const parsedDocs = computed(() => docs.value.filter((row) => row.parseStatus === 'PARSED'));
|
||||
|
||||
async function loadDocs() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await queryRagDocuments();
|
||||
docs.value = response.data ?? [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadDocs);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-panel chunk-task-page">
|
||||
<div class="page-panel__header">
|
||||
<h2>切片任务</h2>
|
||||
<span>聚焦可切片文档与异常解析文档</span>
|
||||
</div>
|
||||
|
||||
<div class="task-board-wrap">
|
||||
<RagChunkTaskBoard :total="docs.length" :parsed="parsedDocs.length" :failed="failedDocs.length" />
|
||||
</div>
|
||||
|
||||
<div class="task-actions">
|
||||
<el-button type="primary" @click="router.push('/rag/documents')">去执行切片</el-button>
|
||||
<el-button :loading="loading" @click="loadDocs">刷新任务视图</el-button>
|
||||
</div>
|
||||
|
||||
<div class="task-list">
|
||||
<h3>解析失败文档</h3>
|
||||
<el-empty v-if="!loading && failedDocs.length === 0" description="当前没有失败文档" />
|
||||
<el-table v-else :data="failedDocs" border>
|
||||
<el-table-column prop="documentTitle" label="文档标题" min-width="220" />
|
||||
<el-table-column prop="parseStatus" label="解析状态" width="120" />
|
||||
<el-table-column prop="errorMessage" label="失败原因" min-width="280" />
|
||||
</el-table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chunk-task-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.task-board-wrap {
|
||||
padding: 10px 28px 0;
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 16px 28px;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
padding: 0 28px 24px;
|
||||
}
|
||||
|
||||
.task-list h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 15px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.task-board-wrap {
|
||||
padding: 10px 16px 0;
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
padding: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
76
frontend/src/pages/rag/workbench/RagWorkbenchPage.vue
Normal file
76
frontend/src/pages/rag/workbench/RagWorkbenchPage.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { queryRagDocuments } from '@/api/ragDocuments';
|
||||
import RagChunkTaskBoard from '@/components/rag/chunk/RagChunkTaskBoard.vue';
|
||||
import RagFlowOverview from '@/components/rag/document/RagFlowOverview.vue';
|
||||
|
||||
const loading = ref(false);
|
||||
const docs = ref<any[]>([]);
|
||||
const router = useRouter();
|
||||
|
||||
const parsedCount = () => docs.value.filter((row) => row.parseStatus === 'PARSED').length;
|
||||
const failedCount = () => docs.value.filter((row) => row.parseStatus === 'FAILED').length;
|
||||
|
||||
async function loadDocs() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await queryRagDocuments();
|
||||
docs.value = response.data ?? [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadDocs);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-panel rag-workbench">
|
||||
<div class="page-panel__header">
|
||||
<h2>RAG 工作台</h2>
|
||||
<span>上传自动解析,切片手动触发</span>
|
||||
</div>
|
||||
|
||||
<RagFlowOverview :documents="docs" />
|
||||
|
||||
<div class="workbench-board">
|
||||
<RagChunkTaskBoard :total="docs.length" :parsed="parsedCount()" :failed="failedCount()" />
|
||||
</div>
|
||||
|
||||
<div class="workbench-actions">
|
||||
<el-button type="primary" @click="router.push('/rag/documents')">进入文档管理</el-button>
|
||||
<el-button @click="router.push('/rag/tasks/chunk')">查看切片任务</el-button>
|
||||
<el-button :loading="loading" @click="loadDocs">刷新概览</el-button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rag-workbench {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.workbench-board {
|
||||
padding: 10px 28px 0;
|
||||
}
|
||||
|
||||
.workbench-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 18px 28px 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.workbench-board {
|
||||
padding: 10px 16px 0;
|
||||
}
|
||||
|
||||
.workbench-actions {
|
||||
padding: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +0,0 @@
|
||||
<template>
|
||||
<section class="page-panel">
|
||||
<div class="page-panel__header">
|
||||
<h2>附件管理</h2>
|
||||
<span>Files</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -7,11 +7,11 @@ describe('router', () => {
|
||||
const paths = routes.map((route) => route.path);
|
||||
|
||||
expect(paths).toContain('/');
|
||||
expect(paths).toContain('/dashboard');
|
||||
expect(paths).toContain('/system/enums');
|
||||
expect(paths).toContain('/system/attachments');
|
||||
expect(paths).toContain('/rag/stores');
|
||||
expect(paths).toContain('/rag/workbench');
|
||||
expect(paths).toContain('/rag/documents');
|
||||
expect(paths).toContain('/rag/tasks/chunk');
|
||||
expect(paths).toContain('/system/enums');
|
||||
expect(paths).toContain('/:pathMatch(.*)*');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
|
||||
import DashboardPage from '@/pages/dashboard/DashboardPage.vue';
|
||||
import NotFoundPage from '@/pages/common/NotFoundPage.vue';
|
||||
import RagDocumentsPage from '@/pages/rag/RagDocumentsPage.vue';
|
||||
import RagStoresPage from '@/pages/rag/RagStoresPage.vue';
|
||||
import SystemAttachmentsPage from '@/pages/system/SystemAttachmentsPage.vue';
|
||||
import RagChunkTasksPage from '@/pages/rag/tasks/RagChunkTasksPage.vue';
|
||||
import RagWorkbenchPage from '@/pages/rag/workbench/RagWorkbenchPage.vue';
|
||||
import SystemEnumsPage from '@/pages/system/SystemEnumsPage.vue';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
|
||||
export const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/dashboard',
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'dashboard',
|
||||
component: DashboardPage,
|
||||
meta: { title: '工作台' },
|
||||
redirect: '/rag/workbench',
|
||||
},
|
||||
{
|
||||
path: '/system/enums',
|
||||
@@ -26,24 +20,30 @@ export const routes: RouteRecordRaw[] = [
|
||||
component: SystemEnumsPage,
|
||||
meta: { title: '系统枚举' },
|
||||
},
|
||||
{
|
||||
path: '/system/attachments',
|
||||
name: 'system-attachments',
|
||||
component: SystemAttachmentsPage,
|
||||
meta: { title: '附件管理' },
|
||||
},
|
||||
{
|
||||
path: '/rag/stores',
|
||||
name: 'rag-stores',
|
||||
component: RagStoresPage,
|
||||
meta: { title: '知识库' },
|
||||
},
|
||||
{
|
||||
path: '/rag/workbench',
|
||||
name: 'rag-workbench',
|
||||
component: RagWorkbenchPage,
|
||||
meta: { title: 'RAG工作台' },
|
||||
},
|
||||
{
|
||||
path: '/rag/documents',
|
||||
name: 'rag-documents',
|
||||
component: RagDocumentsPage,
|
||||
meta: { title: '知识文档' },
|
||||
},
|
||||
{
|
||||
path: '/rag/tasks/chunk',
|
||||
name: 'rag-chunk-tasks',
|
||||
component: RagChunkTasksPage,
|
||||
meta: { title: '切片任务' },
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'not-found',
|
||||
@@ -55,42 +55,42 @@ export const routes: RouteRecordRaw[] = [
|
||||
const routerRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/dashboard',
|
||||
redirect: '/rag/workbench',
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: AdminLayout,
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'dashboard',
|
||||
component: DashboardPage,
|
||||
meta: { title: '工作台' },
|
||||
},
|
||||
{
|
||||
path: 'system/enums',
|
||||
name: 'system-enums',
|
||||
component: SystemEnumsPage,
|
||||
meta: { title: '系统枚举' },
|
||||
},
|
||||
{
|
||||
path: 'system/attachments',
|
||||
name: 'system-attachments',
|
||||
component: SystemAttachmentsPage,
|
||||
meta: { title: '附件管理' },
|
||||
},
|
||||
{
|
||||
path: 'rag/stores',
|
||||
name: 'rag-stores',
|
||||
component: RagStoresPage,
|
||||
meta: { title: '知识库' },
|
||||
},
|
||||
{
|
||||
path: 'rag/workbench',
|
||||
name: 'rag-workbench',
|
||||
component: RagWorkbenchPage,
|
||||
meta: { title: 'RAG工作台' },
|
||||
},
|
||||
{
|
||||
path: 'rag/documents',
|
||||
name: 'rag-documents',
|
||||
component: RagDocumentsPage,
|
||||
meta: { title: '知识文档' },
|
||||
},
|
||||
{
|
||||
path: 'rag/tasks/chunk',
|
||||
name: 'rag-chunk-tasks',
|
||||
component: RagChunkTasksPage,
|
||||
meta: { title: '切片任务' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user