feat(文档向量): 添加文档向量管理功能

- 新增文档向量查询接口和更新接口
- 重构文档详情弹窗,将切片展示改为向量列表展示
- 优化表格模板语法使用解构写法
- 统一文件计数字段名为documentCount
This commit is contained in:
2026-04-13 15:16:08 +08:00
parent 919aaa195d
commit 3055da01c7
3 changed files with 168 additions and 116 deletions

View File

@@ -54,6 +54,30 @@ export interface DocumentInfo {
updatedAt?: string;
}
// 文件块向量查询参数
export interface DocumentVectorQueryParams {
documentId?: string;
datasetId?: string;
pageNum: number;
pageSize: number;
}
// 文件块向量信息
export interface DocumentVectorInfo {
id: number;
status: number;
vectorStatus: number;
datasetId: number;
documentId: number;
content: string;
contentHash: string;
chunkIndex: number;
vector: number[];
metadata: Record<string, any>;
createdAt: string;
updatedAt: string;
}
// 获取文档列表
export function listDocuments(params: DocumentQueryParams) {
return request({
@@ -194,3 +218,21 @@ export function generateVector(id: string, datasetId: string) {
data: { id, datasetId },
});
}
// 获取文件块向量列表
export function listDocumentVectors(params: DocumentVectorQueryParams) {
return request({
url: '/rag/document/vector/list',
method: 'get',
params,
});
}
// 更新文件块
export function updateDocumentVector(data: any) {
return request({
url: '/rag/document/vector/update',
method: 'put',
data,
});
}

View File

@@ -56,64 +56,55 @@
</div>
</div>
<!-- 右侧切片结果 -->
<!-- 右侧向量列表 -->
<div class="chunk-panel">
<div class="panel-header">
<h3>切片结果</h3>
<div class="panel-subtitle">查看用于嵌入和召回的切片段落</div>
<h3>向量列表</h3>
<div class="panel-subtitle">查看文档的向量信息</div>
</div>
<div class="panel-toolbar">
<div class="toolbar-tabs">
<el-radio-group v-model="viewMode" size="small">
<el-radio-button label="full">全文</el-radio-button>
<el-radio-button label="chunk">省略</el-radio-button>
</el-radio-group>
</div>
<div class="toolbar-actions">
<el-input v-model="chunkSearch" placeholder="搜索" clearable size="small" style="width: 120px">
<el-input v-model="vectorSearch" placeholder="搜索" clearable size="small" style="width: 120px">
<template #prefix>
<el-icon><ele-Search /></el-icon>
</template>
</el-input>
<el-button size="small" @click="onAddChunk">
<el-icon><ele-Plus /></el-icon>
</el-button>
</div>
</div>
<div class="chunk-list" v-loading="chunkLoading">
<div class="select-all">
<el-checkbox v-model="selectAll" @change="onSelectAllChange">选择所有</el-checkbox>
</div>
<div class="chunk-item" v-for="chunk in filteredChunks" :key="chunk.id">
<div class="chunk-checkbox">
<el-checkbox v-model="chunk.selected" />
<div class="vector-list" v-loading="vectorLoading">
<div class="vector-item" v-for="vector in vectorList" :key="vector.id">
<div class="vector-header">
<span class="vector-index"> {{ vector.chunkIndex }}</span>
<span class="vector-status">状态: {{ vector.status === 1 ? '启用' : '禁用' }}</span>
<span class="vector-vector-status">向量状态: {{ vector.vectorStatus === 1 ? '已生成' : '未生成' }}</span>
<el-switch v-model="vector.status" size="small" @change="(value: boolean) => onVectorStatusChange(vector, !value)" />
</div>
<div class="chunk-content">
<span class="chunk-text">{{ viewMode === 'full' ? chunk.content : truncateText(chunk.content, 100) }}</span>
<div class="vector-content">
<span class="vector-text">{{ truncateText(vector.content, 150) }}</span>
</div>
<div class="chunk-actions">
<el-switch v-model="chunk.enabled" size="small" @change="onChunkStatusChange(chunk)" />
<div class="vector-meta">
<span class="vector-hash">哈希: {{ vector.contentHash }}</span>
<span class="vector-time">创建时间: {{ vector.createdAt }}</span>
</div>
</div>
<el-empty v-if="filteredChunks.length === 0 && !chunkLoading" description="暂无切片" :image-size="60" />
<el-empty v-if="vectorList.length === 0 && !vectorLoading" description="暂无向量" :image-size="60" />
</div>
<!-- 分页 -->
<div class="panel-footer">
<span class="total-info">总共 {{ chunkTotal }} </span>
<span class="total-info">总共 {{ vectorTotal }} </span>
<el-pagination
v-model:current-page="chunkPage"
:page-size="chunkPageSize"
:total="chunkTotal"
v-model:current-page="vectorPage"
:page-size="vectorPageSize"
:total="vectorTotal"
layout="prev, pager, next"
small
@current-change="getChunkList"
@current-change="getVectorList"
/>
<el-select v-model="chunkPageSize" size="small" style="width: 80px" @change="getChunkList">
<el-select v-model="vectorPageSize" size="small" style="width: 80px" @change="getVectorList">
<el-option :value="10" label="10条/页" />
<el-option :value="20" label="20条/页" />
<el-option :value="50" label="50条/页" />
@@ -133,6 +124,7 @@ export default {
<script setup lang="ts">
import { ref, reactive, computed, watch, nextTick } from 'vue';
import { ElMessage } from 'element-plus';
import { listDocumentVectors, updateDocumentVector } from '/@/api/knowledge/document';
const props = defineProps<{
modelValue: boolean;
@@ -161,21 +153,14 @@ const documentInfo = reactive({
const documentContent = ref('');
const contentLoading = ref(false);
// 切片相关
const chunkLoading = ref(false);
const chunkList = ref<any[]>([]);
const chunkTotal = ref(0);
const chunkPage = ref(1);
const chunkPageSize = ref(50);
const chunkSearch = ref('');
const viewMode = ref('full');
const selectAll = ref(false);
// 过滤后的切片列表
const filteredChunks = computed(() => {
if (!chunkSearch.value) return chunkList.value;
return chunkList.value.filter((chunk) => (chunk.content || '').toLowerCase().includes(chunkSearch.value.toLowerCase()));
});
// 向量列表相关
const vectorLoading = ref(false);
const vectorList = ref<any[]>([]);
const vectorTotal = ref(0);
const vectorPage = ref(1);
const vectorPageSize = ref(50);
const vectorSearch = ref('');
const isInitializing = ref(true); // 初始化标志,用于防止加载时触发更新
// 格式化文件大小
const formatFileSize = (size: number) => {
@@ -238,44 +223,57 @@ const getDocumentDetail = async () => {
}
};
// 获取切片列表
const getChunkList = async () => {
chunkLoading.value = true;
// 获取向量列表
const getVectorList = async () => {
vectorLoading.value = true;
try {
// 模拟数据
chunkList.value = [
{
id: '1',
content: '123',
enabled: true,
selected: false,
},
];
chunkTotal.value = 1;
const response = await listDocumentVectors({
documentId: props.document?.id,
datasetId: props.document?.datasetId,
pageNum: vectorPage.value,
pageSize: vectorPageSize.value,
});
// 深拷贝数据避免v-model触发不必要的更新
vectorList.value = (response.data?.list || []).map((item: any) => JSON.parse(JSON.stringify(item)));
vectorTotal.value = response.data?.total || 0;
} catch (_error) {
ElMessage.error('获取切片列表失败');
chunkList.value = [];
chunkTotal.value = 0;
ElMessage.error('获取向量列表失败');
vectorList.value = [];
vectorTotal.value = 0;
} finally {
chunkLoading.value = false;
vectorLoading.value = false;
// 初始化完成,允许状态更新
setTimeout(() => {
isInitializing.value = false;
}, 100);
}
};
// 全选变化
const onSelectAllChange = (val: boolean) => {
chunkList.value.forEach((chunk) => {
chunk.selected = val;
});
};
// 更新向量状态
const onVectorStatusChange = async (vector: any, oldValue: boolean) => {
// 初始化过程中不处理状态变化
if (isInitializing.value) {
return;
}
// 切片状态变化
const onChunkStatusChange = (chunk: any) => {
ElMessage.success(chunk.enabled ? '已启用' : '已禁用');
};
// 计算新状态ElSwitch的v-model是boolean需要转换为数字
const newStatus = vector.status ? 1 : 0;
const oldStatus = oldValue ? 1 : 0;
// 添加切片
const onAddChunk = () => {
ElMessage.info('添加切片功能开发中');
// 只在状态真正改变时才调用API
if (newStatus !== oldStatus) {
try {
await updateDocumentVector({
id: vector.id,
status: newStatus,
});
ElMessage.success('更新成功');
} catch (error) {
ElMessage.error('更新失败');
// 恢复原始状态
vector.status = oldValue;
}
}
};
// 监听弹窗打开
@@ -283,8 +281,10 @@ watch(
() => props.modelValue,
(val) => {
if (val && props.document) {
// 重置初始化标志
isInitializing.value = true;
getDocumentDetail();
getChunkList();
getVectorList();
}
}
);
@@ -382,34 +382,43 @@ watch(
}
}
.chunk-list {
.vector-list {
flex: 1;
overflow: auto;
padding: 12px 20px;
.select-all {
margin-bottom: 12px;
}
.chunk-item {
.vector-item {
display: flex;
align-items: flex-start;
flex-direction: column;
padding: 12px;
background: #f5f7fa;
border-radius: 6px;
margin-bottom: 8px;
border: 1px solid #ebeef5;
.chunk-checkbox {
margin-right: 12px;
padding-top: 2px;
.vector-header {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 8px;
font-size: 12px;
color: #606266;
.vector-index {
font-weight: 600;
color: #303133;
}
.el-switch {
margin-left: auto;
}
}
.chunk-content {
flex: 1;
min-width: 0;
.vector-content {
margin-bottom: 8px;
.chunk-text {
.vector-text {
font-size: 14px;
color: #303133;
line-height: 1.5;
@@ -417,9 +426,18 @@ watch(
}
}
.chunk-actions {
margin-left: 12px;
flex-shrink: 0;
.vector-meta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #909399;
.vector-hash {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
}
}
}
}

View File

@@ -27,7 +27,7 @@
</div>
<div class="card-info">
<div class="card-name">{{ item.name }}</div>
<div class="card-meta">{{ item.fileCount || 0 }} 个文件</div>
<div class="card-meta">{{ item.documentCount || 0 }} 个文件</div>
<div class="card-time">{{ item.createdAt }}</div>
</div>
<!-- 悬停操作按钮 -->
@@ -78,7 +78,7 @@
</div>
<div class="profile-info">
<div class="profile-name">{{ currentknowledge.name }}</div>
<div class="profile-meta">{{ currentknowledge.fileCount || 0 }} 个文件 · {{ formatFileSize(currentknowledge.totalSize || 0) }}</div>
<div class="profile-meta">{{ currentknowledge.documentCount || 0 }} 个文件 · {{ formatFileSize(currentknowledge.totalSize || 0) }}</div>
<div class="profile-time">创建于 {{ currentknowledge.createdAt }}</div>
</div>
</div>
@@ -128,37 +128,29 @@
<div class="file-table" v-loading="fileLoading">
<el-table :data="fileList" style="width: 100%" row-key="id" border>
<el-table-column prop="title" label="名称" min-width="200">
<template #default="scope">
<span class="file-link" @click="onViewDocumentDetail(scope.row)" style="cursor: pointer; color: #409eff">{{
scope.row.title
}}</span>
<template #default="{ row }">
<span class="file-link" @click="onViewDocumentDetail(row)" style="cursor: pointer; color: #409eff">{{ row.title }}</span>
</template>
</el-table-column>
<el-table-column prop="chunkCount" label="分块数" width="80" align="center" />
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="scope">
<el-switch
v-model="scope.row.statusEnabled"
inline-prompt
active-text=""
inactive-text=""
@change="onFileStatusChange(scope.row)"
/>
<template #default="{ row }">
<el-switch v-model="row.statusEnabled" inline-prompt active-text="启" inactive-text="停" @change="onFileStatusChange(row)" />
</template>
</el-table-column>
<el-table-column prop="vectorStatus" label="向量化" width="100" align="center">
<template #default="scope">
<el-tag :type="scope.row.vectorStatus === 2 ? 'success' : 'warning'" size="small">
{{ scope.row.vectorStatus === 2 ? '已完成' : '未完成' }}
<template #default="{ row }">
<el-tag :type="row.vectorStatus === 2 ? 'success' : 'warning'" size="small">
{{ row.vectorStatus === 2 ? '已完成' : '未完成' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="上传日期" width="180" />
<el-table-column label="动作" width="180" align="center">
<template #default="scope">
<el-button text size="small" @click="onPreviewFile(scope.row)">预览</el-button>
<el-button text size="small" type="primary" @click="onGenerateVector(scope.row)">生成向量</el-button>
<el-button text size="small" type="danger" @click="onDeleteFile(scope.row)">删除</el-button>
<template #default="{ row }">
<el-button text size="small" @click="onPreviewFile(row)">预览</el-button>
<el-button text size="small" type="primary" @click="onGenerateVector(row)">生成向量</el-button>
<el-button text size="small" type="danger" @click="onDeleteFile(row)">删除</el-button>
</template>
</el-table-column>
</el-table>