feat:新增知识库管理页面并联调知识库接口

This commit is contained in:
zhiye.sun
2026-05-21 13:09:33 +08:00
parent 387681a6ab
commit 91e6d5bdd3
12 changed files with 1506 additions and 17 deletions

View File

@@ -1,8 +1,668 @@
<script setup lang="ts">
import { CirclePlus, Delete, Edit, FolderAdd, Refresh, Search, UploadFilled } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { computed, onMounted, reactive, ref } from 'vue';
import {
deleteRagStore,
getRagStoreById,
queryRagStores,
saveRagStore,
type RagStore,
} from '@/api/ragStores';
type StoreStatus = '启用' | '停用';
const loading = ref(false);
const detailLoading = ref(false);
const submitting = ref(false);
const storeRows = ref<RagStore[]>([]);
const activeStoreId = ref<string | null>(null);
const activeStore = ref<RagStore | null>(null);
const queryForm = reactive({
storeName: '',
});
const createDialogVisible = ref(false);
const editDialogVisible = ref(false);
const createForm = reactive({
storeCode: '',
storeName: '',
description: '',
status: '启用' as StoreStatus,
remark: '',
});
const editForm = reactive({
id: '',
storeCode: '',
storeName: '',
description: '',
status: '启用' as StoreStatus,
remark: '',
});
const overviewCards = computed(() => {
const totalStores = storeRows.value.length;
const retrievableStores = storeRows.value.filter((row) => row.status === '启用').length;
return [
{ label: '知识库总数', value: totalStores, hint: '当前已登记知识库' },
{ label: '文档总数', value: '-', hint: '待文档统计接口补充' },
{ label: '切片总数', value: '-', hint: '待切片统计接口补充' },
{ label: '可检索知识库数', value: retrievableStores, hint: '当前按启用状态暂代统计' },
];
});
async function loadStores(preferredStoreId?: string | null) {
loading.value = true;
try {
const response = await queryRagStores({
storeName: queryForm.storeName.trim() || undefined,
});
storeRows.value = response.data ?? [];
if (storeRows.value.length === 0) {
activeStoreId.value = null;
activeStore.value = null;
return;
}
const firstStore = storeRows.value[0];
const targetId =
preferredStoreId && storeRows.value.some((row) => String(row.id) === preferredStoreId)
? preferredStoreId
: firstStore
? String(firstStore.id)
: null;
if (!targetId) {
activeStoreId.value = null;
activeStore.value = null;
return;
}
await selectStore(targetId);
} finally {
loading.value = false;
}
}
async function selectStore(storeId: string) {
activeStoreId.value = storeId;
detailLoading.value = true;
try {
const response = await getRagStoreById(storeId);
activeStore.value = response.data ?? null;
} finally {
detailLoading.value = false;
}
}
function handleSearch() {
loadStores(activeStoreId.value);
}
function handleReset() {
queryForm.storeName = '';
loadStores();
}
function openCreateDialog() {
createForm.storeCode = '';
createForm.storeName = '';
createForm.description = '';
createForm.status = '启用';
createForm.remark = '';
createDialogVisible.value = true;
}
function openEditDialog() {
if (!activeStore.value) {
return;
}
editForm.id = String(activeStore.value.id ?? '');
editForm.storeCode = activeStore.value.storeCode;
editForm.storeName = activeStore.value.storeName;
editForm.description = activeStore.value.description ?? '';
editForm.status = (activeStore.value.status as StoreStatus) || '启用';
editForm.remark = activeStore.value.remark ?? '';
editDialogVisible.value = true;
}
async function submitCreateStore() {
if (!createForm.storeCode || !createForm.storeName) {
ElMessage.warning('请填写知识库编码和知识库名称');
return;
}
submitting.value = true;
try {
await saveRagStore({
storeCode: createForm.storeCode,
storeName: createForm.storeName,
description: createForm.description,
status: createForm.status,
remark: createForm.remark,
});
createDialogVisible.value = false;
ElMessage.success('知识库已创建');
await loadStores();
} finally {
submitting.value = false;
}
}
async function submitEditStore() {
if (!editForm.id || !editForm.storeCode || !editForm.storeName) {
ElMessage.warning('请填写知识库编码和知识库名称');
return;
}
submitting.value = true;
try {
await saveRagStore({
id: editForm.id,
storeCode: editForm.storeCode,
storeName: editForm.storeName,
description: editForm.description,
status: editForm.status,
remark: editForm.remark,
});
editDialogVisible.value = false;
ElMessage.success('知识库信息已更新');
await loadStores(editForm.id);
} finally {
submitting.value = false;
}
}
async function removeStore() {
if (!activeStore.value?.id) {
return;
}
await ElMessageBox.confirm(`确认删除知识库「${activeStore.value.storeName}」?`, '删除确认', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
});
await deleteRagStore(String(activeStore.value.id));
ElMessage.success('知识库已删除');
await loadStores();
}
function showFutureMessage(actionName: string) {
ElMessage.info(`${actionName} 会在下一批接口里补齐`);
}
function getStatusTagType(status?: string | null) {
return status === '启用' ? 'success' : 'info';
}
onMounted(() => {
loadStores();
});
</script>
<template>
<section class="page-panel">
<div class="page-panel__header">
<h2>知识库</h2>
<span>RAG</span>
<section class="page-panel rag-store-page">
<div class="page-panel__header rag-store-page__header">
<div>
<h2>知识库</h2>
<p>统一管理知识库及其文档索引状态</p>
</div>
<span>RAG Stores</span>
</div>
<div class="overview-grid">
<article v-for="card in overviewCards" :key="card.label" class="overview-card">
<span class="overview-card__label">{{ card.label }}</span>
<strong class="overview-card__value">{{ card.value }}</strong>
<small class="overview-card__hint">{{ card.hint }}</small>
</article>
</div>
<div class="rag-store-page__content">
<section class="store-list-panel">
<div class="section-heading">
<div>
<h3>知识库列表</h3>
<p>按知识库名称检索并切换当前查看对象</p>
</div>
<el-button data-test="create-store" type="primary" :icon="CirclePlus" @click="openCreateDialog">
新建知识库
</el-button>
</div>
<div class="store-search-bar">
<el-input
v-model="queryForm.storeName"
data-test="store-name-input"
clearable
placeholder="请输入知识库名称"
@keyup.enter="handleSearch"
/>
<el-button data-test="store-search" type="primary" :icon="Search" @click="handleSearch">
查询
</el-button>
<el-button :icon="Refresh" @click="handleReset">重置</el-button>
</div>
<div v-loading="loading" class="store-list">
<button
v-for="store in storeRows"
:key="store.id"
:data-test="`store-card-${String(store.storeCode).toLowerCase()}`"
class="store-card"
:class="{ 'store-card--active': String(store.id) === activeStoreId }"
type="button"
@click="selectStore(String(store.id))"
>
<div class="store-card__title-row">
<strong>{{ store.storeName }}</strong>
<el-tag size="small" :type="getStatusTagType(store.status)">{{ store.status || '未设置' }}</el-tag>
</div>
<p>编码{{ store.storeCode }}</p>
<div class="store-card__metrics">
<span>描述{{ store.description || '暂无描述' }}</span>
</div>
<div class="store-card__metrics">
<span>更新时间{{ store.updateTime || '-' }}</span>
</div>
</button>
<el-empty v-if="!loading && storeRows.length === 0" description="未找到匹配的知识库" />
</div>
</section>
<section class="store-detail-panel">
<div v-loading="detailLoading" class="store-detail-panel__body">
<template v-if="activeStore">
<div class="section-heading section-heading--detail">
<div>
<h3>{{ activeStore.storeName }}</h3>
<p>编码{{ activeStore.storeCode }}</p>
</div>
<div class="detail-actions">
<el-button :icon="Edit" @click="openEditDialog">编辑</el-button>
<el-button type="primary" :icon="UploadFilled" @click="showFutureMessage('批量导入文件')">
批量导入文件
</el-button>
<el-button :icon="FolderAdd" @click="showFutureMessage('重建索引')">重建索引</el-button>
<el-button type="danger" :icon="Delete" @click="removeStore">删除</el-button>
</div>
</div>
<div class="detail-grid">
<article class="detail-card">
<div class="detail-card__header">
<h4>基本信息</h4>
<el-tag :type="getStatusTagType(activeStore.status)">{{ activeStore.status || '未设置' }}</el-tag>
</div>
<el-descriptions :column="2" border>
<el-descriptions-item label="知识库名称">
{{ activeStore.storeName }}
</el-descriptions-item>
<el-descriptions-item label="知识库编码">
{{ activeStore.storeCode }}
</el-descriptions-item>
<el-descriptions-item label="描述" :span="2">
{{ activeStore.description || '暂无描述' }}
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">
{{ activeStore.remark || '暂无备注' }}
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ activeStore.createTime || '-' }}
</el-descriptions-item>
<el-descriptions-item label="更新时间">
{{ activeStore.updateTime || '-' }}
</el-descriptions-item>
</el-descriptions>
</article>
<article class="detail-card detail-card--placeholder">
<div class="detail-card__header">
<h4>文档概览</h4>
<span>下一批接口补充</span>
</div>
<el-empty description="文档数量、切片数量、最近上传时间待后端聚合接口补充" />
</article>
<article class="detail-card detail-card--placeholder">
<div class="detail-card__header">
<h4>检索配置</h4>
<span>下一批接口补充</span>
</div>
<el-empty description="检索模式、Embedding 模型、Chunk 参数待后端补充" />
</article>
<article class="detail-card detail-card--placeholder">
<div class="detail-card__header">
<h4>最近任务</h4>
<span>下一批接口补充</span>
</div>
<el-empty description="导入任务、索引任务与状态流转待后端补充" />
</article>
</div>
</template>
<el-empty v-else description="请选择左侧一个知识库查看详情" />
</div>
</section>
</div>
<el-dialog v-model="createDialogVisible" title="新建知识库" width="560px">
<el-form :model="createForm" label-width="96px">
<el-form-item label="知识库编码" required>
<el-input v-model="createForm.storeCode" data-test="create-store-code" placeholder="如 PROD_DOC" />
</el-form-item>
<el-form-item label="知识库名称" required>
<el-input
v-model="createForm.storeName"
data-test="create-store-name"
placeholder="请输入知识库名称"
/>
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="createForm.status">
<el-radio-button label="启用" />
<el-radio-button label="停用" />
</el-radio-group>
</el-form-item>
<el-form-item label="描述">
<el-input v-model="createForm.description" type="textarea" :rows="3" placeholder="请输入知识库描述" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="createForm.remark" type="textarea" :rows="2" placeholder="可选" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createDialogVisible = false">取消</el-button>
<el-button data-test="create-store-submit" type="primary" :loading="submitting" @click="submitCreateStore">
保存
</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.storeCode" />
</el-form-item>
<el-form-item label="知识库名称" required>
<el-input v-model="editForm.storeName" />
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="editForm.status">
<el-radio-button label="启用" />
<el-radio-button label="停用" />
</el-radio-group>
</el-form-item>
<el-form-item label="描述">
<el-input v-model="editForm.description" type="textarea" :rows="3" />
</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="submitEditStore">保存</el-button>
</template>
</el-dialog>
</section>
</template>
<style scoped>
.rag-store-page {
display: flex;
flex-direction: column;
}
.rag-store-page__header {
align-items: flex-start;
}
.rag-store-page__header p {
margin: 6px 0 0;
color: #667085;
font-size: 13px;
}
.overview-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
padding: 20px 22px 0;
}
.overview-card {
border: 1px solid #e6ebf3;
border-radius: 12px;
padding: 18px 18px 16px;
background: linear-gradient(180deg, #ffffff, #f9fbff);
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.04);
}
.overview-card__label {
display: block;
color: #667085;
font-size: 13px;
font-weight: 600;
}
.overview-card__value {
display: block;
margin-top: 8px;
color: #172033;
font-size: 28px;
font-weight: 700;
}
.overview-card__hint {
display: block;
margin-top: 8px;
color: #98a2b3;
font-size: 12px;
}
.rag-store-page__content {
display: grid;
grid-template-columns: minmax(320px, 0.95fr) minmax(0, 1.45fr);
gap: 18px;
padding: 18px 22px 22px;
min-height: 0;
flex: 1;
}
.store-list-panel,
.store-detail-panel {
border: 1px solid #e6ebf3;
border-radius: 12px;
background: #fcfdff;
overflow: hidden;
}
.store-detail-panel__body {
min-height: 100%;
}
.section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 18px 14px;
border-bottom: 1px solid #eef2f7;
background: linear-gradient(180deg, #ffffff, #fbfcff);
}
.section-heading h3 {
margin: 0;
color: #172033;
font-size: 17px;
}
.section-heading p {
margin: 6px 0 0;
color: #667085;
font-size: 13px;
}
.store-search-bar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 10px;
padding: 16px 18px;
border-bottom: 1px solid #eef2f7;
background: #ffffff;
}
.store-list {
display: flex;
flex-direction: column;
gap: 12px;
padding: 18px;
max-height: 780px;
overflow: auto;
}
.store-card {
border: 1px solid #e3e8f0;
border-radius: 12px;
padding: 16px;
text-align: left;
background: #ffffff;
cursor: pointer;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
}
.store-card:hover {
border-color: #bfdbfe;
box-shadow: 0 8px 22px rgba(22, 119, 255, 0.08);
transform: translateY(-1px);
}
.store-card--active {
border-color: #1677ff;
box-shadow: 0 10px 24px rgba(22, 119, 255, 0.12);
background: #f7fbff;
}
.store-card__title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.store-card strong {
color: #172033;
font-size: 16px;
}
.store-card p {
margin: 10px 0 0;
color: #475467;
font-size: 13px;
}
.store-card__metrics {
display: flex;
align-items: center;
gap: 10px;
margin-top: 10px;
color: #667085;
font-size: 13px;
flex-wrap: wrap;
}
.section-heading--detail {
align-items: flex-start;
}
.detail-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.detail-grid {
display: grid;
gap: 16px;
padding: 18px;
}
.detail-card {
border: 1px solid #e7edf5;
border-radius: 12px;
background: #ffffff;
padding: 16px;
}
.detail-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.detail-card__header h4 {
margin: 0;
color: #172033;
font-size: 15px;
}
.detail-card__header span {
color: #667085;
font-size: 12px;
}
.detail-card--placeholder :deep(.el-empty) {
padding: 12px 0;
}
@media (max-width: 1280px) {
.overview-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.rag-store-page__content {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.overview-grid {
grid-template-columns: 1fr;
padding: 16px 16px 0;
}
.rag-store-page__content {
padding: 16px;
}
.section-heading,
.section-heading--detail {
flex-direction: column;
align-items: stretch;
}
.store-search-bar {
grid-template-columns: 1fr;
}
.detail-actions {
justify-content: flex-start;
}
}
</style>

View File

@@ -0,0 +1,132 @@
import { flushPromises, mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
import { describe, expect, it, vi } from 'vitest';
import RagStoresPage from '../RagStoresPage.vue';
import { getRagStoreById, queryRagStores, saveRagStore } from '@/api/ragStores';
vi.mock('@/api/ragStores', () => ({
queryRagStores: vi.fn((query?: { storeName?: string }) => {
const rows = [
{
id: '1',
storeCode: 'PROD_DOC',
storeName: '产品制度库',
description: '产品制度、业务规范、流程材料',
status: '启用',
createTime: '2026-05-03 10:20:00',
updateTime: '2026-05-21 16:40:00',
},
{
id: '2',
storeCode: 'FAQ',
storeName: 'FAQ知识库',
description: '常见问题知识沉淀',
status: '停用',
createTime: '2026-05-06 09:10:00',
updateTime: '2026-05-21 11:12:00',
},
];
const keyword = query?.storeName?.trim();
const data = keyword ? rows.filter((row) => row.storeName.includes(keyword)) : rows;
return Promise.resolve({ resultcode: '0', message: null, data });
}),
getRagStoreById: vi.fn((id: string) =>
Promise.resolve({
resultcode: '0',
message: null,
data:
id === '2'
? {
id: '2',
storeCode: 'FAQ',
storeName: 'FAQ知识库',
description: '常见问题知识沉淀',
status: '停用',
remark: 'FAQ 场景知识',
createTime: '2026-05-06 09:10:00',
updateTime: '2026-05-21 11:12:00',
}
: {
id: '1',
storeCode: 'PROD_DOC',
storeName: '产品制度库',
description: '产品制度、业务规范、流程材料',
status: '启用',
remark: '核心制度库',
createTime: '2026-05-03 10:20:00',
updateTime: '2026-05-21 16:40:00',
},
}),
),
saveRagStore: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: true })),
deleteRagStore: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: true })),
}));
describe('RagStoresPage', () => {
it('renders overview cards and loads default store detail from backend data', async () => {
const wrapper = mount(RagStoresPage, {
global: {
plugins: [ElementPlus],
},
});
await flushPromises();
expect(wrapper.text()).toContain('知识库总数');
expect(wrapper.text()).toContain('产品制度库');
expect(wrapper.text()).toContain('核心制度库');
expect(queryRagStores).toHaveBeenCalled();
expect(getRagStoreById).toHaveBeenCalledWith('1');
});
it('filters stores by name and updates detail when a store is selected', async () => {
const wrapper = mount(RagStoresPage, {
global: {
plugins: [ElementPlus],
},
});
await flushPromises();
await wrapper.get('[data-test="store-name-input"]').setValue('FAQ');
await wrapper.get('[data-test="store-search"]').trigger('click');
await flushPromises();
expect(queryRagStores).toHaveBeenLastCalledWith({
storeName: 'FAQ',
});
expect(wrapper.text()).toContain('FAQ知识库');
expect(wrapper.text()).not.toContain('核心制度库');
await wrapper.get('[data-test="store-card-faq"]').trigger('click');
await flushPromises();
expect(getRagStoreById).toHaveBeenLastCalledWith('2');
expect(wrapper.text()).toContain('FAQ 场景知识');
});
it('submits create form through backend api', async () => {
const wrapper = mount(RagStoresPage, {
global: {
plugins: [ElementPlus],
},
});
await flushPromises();
await wrapper.get('[data-test="create-store"]').trigger('click');
await flushPromises();
await wrapper.get('[data-test="create-store-code"]').setValue('NEW_STORE');
await wrapper.get('[data-test="create-store-name"]').setValue('新建知识库');
await wrapper.get('[data-test="create-store-submit"]').trigger('click');
await flushPromises();
expect(saveRagStore).toHaveBeenCalledWith(
expect.objectContaining({
storeCode: 'NEW_STORE',
storeName: '新建知识库',
}),
);
});
});