feat(rag-document): 补全文档管理接口与页面
This commit is contained in:
96
frontend/src/api/__tests__/ragDocuments.spec.ts
Normal file
96
frontend/src/api/__tests__/ragDocuments.spec.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
batchUploadRagDocuments,
|
||||
deleteRagDocument,
|
||||
getRagDocumentById,
|
||||
listRagDocuments,
|
||||
queryRagDocuments,
|
||||
saveRagDocument,
|
||||
SOURCE_TYPE_RAG,
|
||||
} from '../ragDocuments';
|
||||
import { get, post } from '../request';
|
||||
|
||||
vi.mock('../request', () => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('rag documents api', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('lists documents with explicit action url', () => {
|
||||
listRagDocuments();
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/rag/documents/list');
|
||||
});
|
||||
|
||||
it('queries details and saves documents', () => {
|
||||
queryRagDocuments({ storeId: '1001', parseStatus: 'UPLOADED' });
|
||||
getRagDocumentById('2002');
|
||||
saveRagDocument({
|
||||
id: '2002',
|
||||
storeId: '1001',
|
||||
attachmentId: '3003',
|
||||
documentTitle: '知识文档',
|
||||
parseStatus: 'PARSED',
|
||||
indexStatus: 'INDEXED',
|
||||
enabled: true,
|
||||
errorMessage: '无',
|
||||
remark: '备注',
|
||||
});
|
||||
deleteRagDocument('2002');
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/rag/documents/query', {
|
||||
storeId: '1001',
|
||||
parseStatus: 'UPLOADED',
|
||||
});
|
||||
expect(get).toHaveBeenCalledWith('/rag/documents/detail', {
|
||||
params: { id: '2002' },
|
||||
});
|
||||
expect(post).toHaveBeenCalledWith('/rag/documents/save', {
|
||||
id: '2002',
|
||||
storeId: '1001',
|
||||
attachmentId: '3003',
|
||||
documentTitle: '知识文档',
|
||||
parseStatus: 'PARSED',
|
||||
indexStatus: 'INDEXED',
|
||||
enabled: true,
|
||||
errorMessage: '无',
|
||||
remark: '备注',
|
||||
});
|
||||
expect(post).toHaveBeenCalledWith('/rag/documents/delete', undefined, {
|
||||
params: { id: '2002' },
|
||||
});
|
||||
});
|
||||
|
||||
it('batch uploads documents with constant sourceType and storeId sourceId', () => {
|
||||
const formData = new FormData();
|
||||
const file = new File(['hello'], 'knowledge.txt', { type: 'text/plain' });
|
||||
|
||||
batchUploadRagDocuments({
|
||||
storeId: '1001',
|
||||
sourceType: SOURCE_TYPE_RAG,
|
||||
files: [file],
|
||||
documentSummary: '摘要',
|
||||
remark: '备注',
|
||||
});
|
||||
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
const [url, body] = vi.mocked(post).mock.calls[0] ?? [];
|
||||
expect(url).toBe('/rag/documents/batchUpload');
|
||||
expect(body).toBeInstanceOf(FormData);
|
||||
|
||||
formData.append('storeId', '1001');
|
||||
formData.append('sourceType', SOURCE_TYPE_RAG);
|
||||
formData.append('files', file);
|
||||
formData.append('documentSummary', '摘要');
|
||||
formData.append('remark', '备注');
|
||||
|
||||
const actualEntries = Array.from((body as FormData).entries());
|
||||
const expectedEntries = Array.from(formData.entries());
|
||||
expect(actualEntries).toEqual(expectedEntries);
|
||||
});
|
||||
});
|
||||
88
frontend/src/api/ragDocuments.ts
Normal file
88
frontend/src/api/ragDocuments.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { get, post } from './request';
|
||||
|
||||
/** 附件来源类型:标识该附件归属于 RAG 知识库业务。 */
|
||||
export const SOURCE_TYPE_RAG = 'RAG';
|
||||
|
||||
export interface RagDocument {
|
||||
id?: string;
|
||||
storeId: string;
|
||||
attachmentId?: string | null;
|
||||
documentTitle?: string | null;
|
||||
documentSummary?: string | null;
|
||||
parseStatus?: string | null;
|
||||
indexStatus?: string | null;
|
||||
enabled?: boolean | null;
|
||||
errorMessage?: string | null;
|
||||
remark?: string | null;
|
||||
createTime?: string | null;
|
||||
updateTime?: string | null;
|
||||
}
|
||||
|
||||
export interface RagDocumentQueryRequest {
|
||||
storeId?: string;
|
||||
attachmentId?: string;
|
||||
parseStatus?: string;
|
||||
indexStatus?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface RagDocumentSaveRequest {
|
||||
id?: string;
|
||||
storeId: string;
|
||||
attachmentId?: string;
|
||||
documentTitle?: string;
|
||||
documentSummary?: string;
|
||||
parseStatus?: string;
|
||||
indexStatus?: string;
|
||||
enabled?: boolean;
|
||||
errorMessage?: string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface RagDocumentBatchUploadRequest {
|
||||
storeId: string;
|
||||
sourceType?: string;
|
||||
files: File[];
|
||||
documentSummary?: string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export function listRagDocuments() {
|
||||
return post<RagDocument[]>('/rag/documents/list');
|
||||
}
|
||||
|
||||
export function queryRagDocuments(query?: RagDocumentQueryRequest) {
|
||||
return post<RagDocument[], RagDocumentQueryRequest | undefined>('/rag/documents/query', query);
|
||||
}
|
||||
|
||||
export function getRagDocumentById(id: string) {
|
||||
return get<RagDocument>('/rag/documents/detail', {
|
||||
params: { id },
|
||||
});
|
||||
}
|
||||
|
||||
export function saveRagDocument(data: RagDocumentSaveRequest) {
|
||||
return post<boolean, RagDocumentSaveRequest>('/rag/documents/save', data);
|
||||
}
|
||||
|
||||
export function deleteRagDocument(id: string) {
|
||||
return post<boolean>('/rag/documents/delete', undefined, {
|
||||
params: { id },
|
||||
});
|
||||
}
|
||||
|
||||
export function batchUploadRagDocuments(data: RagDocumentBatchUploadRequest) {
|
||||
const formData = new FormData();
|
||||
formData.append('storeId', data.storeId);
|
||||
formData.append('sourceType', data.sourceType || SOURCE_TYPE_RAG);
|
||||
data.files.forEach((file) => {
|
||||
formData.append('files', file);
|
||||
});
|
||||
if (data.documentSummary) {
|
||||
formData.append('documentSummary', data.documentSummary);
|
||||
}
|
||||
if (data.remark) {
|
||||
formData.append('remark', data.remark);
|
||||
}
|
||||
return post<RagDocument[], FormData>('/rag/documents/batchUpload', formData);
|
||||
}
|
||||
@@ -1,8 +1,478 @@
|
||||
<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">
|
||||
<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>
|
||||
|
||||
125
frontend/src/pages/__tests__/RagDocumentsPage.spec.ts
Normal file
125
frontend/src/pages/__tests__/RagDocumentsPage.spec.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user