Files
admin-ui/src/views/knowledge/index.vue

1023 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="knowledge-page">
<!-- 数据集列表页 -->
<div class="dataset-list-view" v-if="!currentDataset">
<div class="page-header">
<div class="header-left">
<el-icon class="header-icon"><ele-Folder /></el-icon>
<span class="header-title">知识库</span>
</div>
<el-button type="primary" @click="onAddDataset">
<el-icon><ele-Plus /></el-icon>
新建知识库
</el-button>
</div>
<div class="dataset-cards" v-loading="datasetLoading">
<!-- 数据集卡片 -->
<div
class="dataset-card"
v-for="item in datasetList"
:key="item.id"
@click="onSelectDataset(item)"
@contextmenu.prevent="onCardContextMenu($event, item)"
>
<div class="card-icon">
<span class="icon-text">{{ item.name?.charAt(0)?.toUpperCase() || 'D' }}</span>
</div>
<div class="card-info">
<div class="card-name">{{ item.name }}</div>
<div class="card-meta">{{ item.fileCount || 0 }} 个文件</div>
<div class="card-time">{{ item.createdAt }}</div>
</div>
<!-- 悬停操作按钮 -->
<div class="card-actions" @click.stop>
<el-tooltip content="重命名" placement="top">
<el-button text size="small" @click="onRenameDataset(item)">
<el-icon><ele-Edit /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="删除" placement="top">
<el-button text size="small" type="danger" @click="onDeleteDataset(item)">
<el-icon><ele-Delete /></el-icon>
</el-button>
</el-tooltip>
</div>
</div>
<!-- 查看全部卡片 -->
<div class="see-all-card" v-if="datasetList.length > 0">
<span>See All</span>
<el-icon><ele-ArrowRight /></el-icon>
</div>
<el-empty v-if="datasetList.length === 0 && !datasetLoading" description="暂无知识库,点击上方按钮创建" :image-size="100" />
</div>
</div>
<!-- 数据集详情页 -->
<div class="dataset-detail-view" v-else>
<!-- 顶部导航 -->
<div class="detail-header">
<div class="header-left">
<el-breadcrumb separator=">">
<el-breadcrumb-item>
<span class="back-link" @click="onBackToList">知识库</span>
</el-breadcrumb-item>
<el-breadcrumb-item>{{ currentDataset.name }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
</div>
<div class="detail-body">
<!-- 左侧信息面板 -->
<div class="info-sidebar">
<div class="dataset-profile">
<div class="profile-icon">
<span class="icon-text">{{ currentDataset.name?.charAt(0)?.toUpperCase() || 'D' }}</span>
</div>
<div class="profile-info">
<div class="profile-name">{{ currentDataset.name }}</div>
<div class="profile-meta">{{ currentDataset.fileCount || 0 }} 个文件 · {{ formatFileSize(currentDataset.totalSize || 0) }}</div>
<div class="profile-time">创建于 {{ currentDataset.createdAt }}</div>
</div>
</div>
<!-- 功能菜单 -->
<div class="func-menu">
<div
class="menu-item"
:class="{ active: activeMenu === 'files' }"
@click="activeMenu = 'files'"
>
<el-icon><ele-Document /></el-icon>
<span>文件列表</span>
</div>
<div
class="menu-item"
:class="{ active: activeMenu === 'search' }"
@click="activeMenu = 'search'"
>
<el-icon><ele-Search /></el-icon>
<span>检索测试</span>
</div>
<div
class="menu-item"
:class="{ active: activeMenu === 'logs' }"
@click="activeMenu = 'logs'"
>
<el-icon><ele-List /></el-icon>
<span>日志</span>
</div>
<div
class="menu-item"
:class="{ active: activeMenu === 'settings' }"
@click="activeMenu = 'settings'"
>
<el-icon><ele-Setting /></el-icon>
<span>配置</span>
</div>
</div>
</div>
<!-- 右侧内容区 -->
<div class="main-content">
<!-- 文件列表 -->
<template v-if="activeMenu === 'files'">
<div class="content-header">
<div class="header-title">
<h3>文件列表</h3>
<span class="subtitle">解析成功后才能问答哦</span>
</div>
<div class="header-actions">
<el-input
v-model="searchKeyword"
placeholder="搜索文件"
clearable
style="width: 200px"
@keyup.enter="getFileList"
>
<template #prefix>
<el-icon><ele-Search /></el-icon>
</template>
</el-input>
<el-button type="primary" @click="onUploadFile">
<el-icon><ele-Plus /></el-icon>
新增文件
</el-button>
</div>
</div>
<div class="file-table" v-loading="fileLoading">
<el-table :data="fileList" style="width: 100%" row-key="id" border>
<el-table-column type="selection" width="50" align="center" />
<el-table-column prop="name" label="名称" min-width="200" sortable>
<template #default="scope">
<div class="file-name" @click="onViewDocumentDetail(scope.row)">
<el-icon class="file-icon" :style="{ color: getFileIconColor(scope.row.fileType) }">
<ele-Document />
</el-icon>
<span class="file-link">{{ scope.row.name }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="上传日期" width="180" sortable />
<el-table-column prop="source" label="来源" width="80" align="center">
<template #default>
<el-icon><ele-Monitor /></el-icon>
</template>
</el-table-column>
<el-table-column prop="enabled" label="启用" width="80" align="center">
<template #default="scope">
<el-switch
v-model="scope.row.enabled"
size="small"
@change="onFileStatusChange(scope.row)"
/>
</template>
</el-table-column>
<el-table-column prop="chunkCount" label="分块数" width="80" align="center" />
<el-table-column prop="parseStatus" label="解析" width="100" align="center">
<template #default="scope">
<el-tag :type="getParseStatusType(scope.row.parseStatus)" size="small">
{{ scope.row.parseStatus }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="动作" width="140" align="center">
<template #default="scope">
<el-button text size="small" @click="onPreviewFile(scope.row)">
<el-icon><ele-View /></el-icon>
</el-button>
<el-button text size="small" @click="onDownloadFile(scope.row)">
<el-icon><ele-Download /></el-icon>
</el-button>
<el-button text size="small" type="danger" @click="onDeleteFile(scope.row)">
<el-icon><ele-Delete /></el-icon>
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="fileList.length === 0 && !fileLoading" description="暂无文件,点击上方按钮上传" />
</div>
</template>
<!-- 检索测试 -->
<template v-if="activeMenu === 'search'">
<div class="panel-card">
<h3>检索测试</h3>
<el-input
v-model="searchQuery"
type="textarea"
:rows="3"
placeholder="输入问题进行检索测试..."
/>
<el-button type="primary" class="mt15" @click="onSearchTest">测试检索</el-button>
<div class="search-results mt15" v-if="searchResults.length > 0">
<h4>检索结果</h4>
<div class="result-item" v-for="(item, index) in searchResults" :key="index">
<div class="result-score">相似度: {{ (item.score * 100).toFixed(1) }}%</div>
<div class="result-content">{{ item.content }}</div>
</div>
</div>
</div>
</template>
<!-- 日志 -->
<template v-if="activeMenu === 'logs'">
<div class="panel-card">
<h3>操作日志</h3>
<el-timeline>
<el-timeline-item
v-for="(log, index) in logList"
:key="index"
:timestamp="log.time"
placement="top"
>
<span>{{ log.content }}</span>
</el-timeline-item>
</el-timeline>
<el-empty v-if="logList.length === 0" description="暂无日志" />
</div>
</template>
<!-- 配置 -->
<template v-if="activeMenu === 'settings'">
<div class="panel-card">
<h3>数据集配置</h3>
<el-form label-width="120px" style="max-width: 500px">
<el-form-item label="数据集名称">
<el-input v-model="currentDataset.name" disabled />
</el-form-item>
<el-form-item label="向量模型">
<el-select v-model="settingsForm.embeddingModel" style="width: 100%">
<el-option label="text-embedding-ada-002" value="text-embedding-ada-002" />
<el-option label="bge-large-zh" value="bge-large-zh" />
<el-option label="m3e-base" value="m3e-base" />
</el-select>
</el-form-item>
<el-form-item label="分段长度">
<el-input-number v-model="settingsForm.chunkSize" :min="100" :max="2000" />
</el-form-item>
<el-form-item label="重叠长度">
<el-input-number v-model="settingsForm.chunkOverlap" :min="0" :max="500" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSaveSettings">保存配置</el-button>
</el-form-item>
</el-form>
</div>
</template>
</div>
</div>
</div>
<!-- 新增/编辑数据集弹窗 -->
<el-dialog
:title="datasetForm.id ? '编辑知识库' : '新建知识库'"
v-model="showDatasetDialog"
width="500px"
:close-on-click-modal="false"
>
<el-form ref="datasetFormRef" :model="datasetForm" :rules="datasetRules" label-width="100px">
<el-form-item label="名称" prop="name">
<el-input v-model="datasetForm.name" placeholder="请输入知识库名称" />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="datasetForm.description" type="textarea" :rows="3" placeholder="请输入描述" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showDatasetDialog = false">取消</el-button>
<el-button type="primary" @click="onSaveDataset" :loading="datasetSaving">确定</el-button>
</template>
</el-dialog>
<!-- 上传文件弹窗 -->
<el-dialog
title="上传文件"
v-model="showUploadDialog"
width="600px"
:close-on-click-modal="false"
>
<el-upload
ref="uploadRef"
class="upload-area"
drag
multiple
:auto-upload="false"
:file-list="uploadFileList"
:on-change="onUploadChange"
:on-remove="onUploadRemove"
accept=".pdf,.docx,.doc,.txt,.md,.html,.csv"
>
<el-icon class="el-icon--upload"><ele-UploadFilled /></el-icon>
<div class="el-upload__text">将文件拖到此处<em>点击上传</em></div>
<template #tip>
<div class="el-upload__tip">支持 PDFWordTXTMarkdownHTMLCSV 格式</div>
</template>
</el-upload>
<template #footer>
<el-button @click="showUploadDialog = false">取消</el-button>
<el-button type="primary" @click="onConfirmUpload" :loading="uploading" :disabled="uploadFileList.length === 0">
上传 ({{ uploadFileList.length }} 个文件)
</el-button>
</template>
</el-dialog>
<!-- 文档详情弹窗 -->
<DocumentDetailDialog
v-model="showDocumentDetailDialog"
:datasetId="currentDataset?.id || ''"
:datasetName="currentDataset?.name || ''"
:document="currentDocument"
/>
</div>
</template>
<script lang="ts">
export default {
name: 'knowledge',
};
</script>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import type { FormInstance, FormRules, UploadFile } from 'element-plus';
import DocumentDetailDialog from './component/documentDetailDialog.vue';
const router = useRouter();
// 数据集相关
const datasetLoading = ref(false);
const datasetList = ref<any[]>([]);
const currentDataset = ref<any>(null);
const showDatasetDialog = ref(false);
const datasetSaving = ref(false);
const datasetFormRef = ref<FormInstance>();
const datasetForm = reactive({
id: '',
name: '',
description: '',
});
const datasetRules = reactive<FormRules>({
name: [{ required: true, message: '请输入知识库名称', trigger: 'blur' }],
});
// 文件相关
const fileLoading = ref(false);
const fileList = ref<any[]>([]);
const searchKeyword = ref('');
const showUploadDialog = ref(false);
const uploadFileList = ref<UploadFile[]>([]);
const uploading = ref(false);
// 文档详情弹窗
const showDocumentDetailDialog = ref(false);
const currentDocument = ref<any>(null);
// 菜单相关
const activeMenu = ref('files');
// 检索测试
const searchQuery = ref('');
const searchResults = ref<any[]>([]);
// 日志
const logList = ref<any[]>([]);
// 配置
const settingsForm = reactive({
embeddingModel: 'text-embedding-ada-002',
chunkSize: 500,
chunkOverlap: 50,
});
// 格式化文件大小
const formatFileSize = (size: number) => {
if (!size) return '0 B';
if (size < 1024) return size + ' B';
if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB';
return (size / 1024 / 1024).toFixed(1) + ' MB';
};
// 格式化日期
const formatDate = (date: string) => {
if (!date) return '';
return date.split(' ')[0];
};
// 获取文件图标颜色
const getFileIconColor = (fileType: string) => {
const colors: Record<string, string> = {
pdf: '#f56c6c',
docx: '#409eff',
doc: '#409eff',
txt: '#909399',
md: '#67c23a',
html: '#e6a23c',
csv: '#67c23a',
};
return colors[fileType] || '#909399';
};
// 获取解析状态类型
const getParseStatusType = (status: string) => {
const types: Record<string, string> = {
general: 'success',
pending: 'warning',
failed: 'danger',
};
return types[status] || 'info';
};
// 获取数据集列表
const getDatasetList = async () => {
datasetLoading.value = true;
try {
// 模拟数据
datasetList.value = [
{
id: '1',
name: 'dataset_tenant_1',
fileCount: 3,
totalSize: 30,
createdAt: '28/01/2026 14:09:45',
},
];
} catch (error) {
console.error('获取数据集列表失败:', error);
} finally {
datasetLoading.value = false;
}
};
// 选择数据集
const onSelectDataset = (item: any) => {
currentDataset.value = item;
activeMenu.value = 'files';
getFileList();
getLogList();
};
// 新增数据集
const onAddDataset = () => {
datasetForm.id = '';
datasetForm.name = '';
datasetForm.description = '';
showDatasetDialog.value = true;
};
// 返回列表
const onBackToList = () => {
currentDataset.value = null;
};
// 右键菜单
const onCardContextMenu = (event: MouseEvent, item: any) => {
// 可以在这里实现右键菜单,暂时用悬停按钮代替
};
// 重命名数据集
const onRenameDataset = (item: any) => {
datasetForm.id = item.id;
datasetForm.name = item.name;
datasetForm.description = item.description || '';
showDatasetDialog.value = true;
};
// 删除数据集
const onDeleteDataset = (item: any) => {
ElMessageBox.confirm(`确定要删除知识库【${item.name}】吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(() => {
ElMessage.success('删除成功');
if (currentDataset.value?.id === item.id) {
currentDataset.value = null;
}
getDatasetList();
}).catch(() => {});
};
// 保存数据集
const onSaveDataset = async () => {
const form = datasetFormRef.value;
if (!form) return;
form.validate(async (valid: boolean) => {
if (valid) {
datasetSaving.value = true;
try {
ElMessage.success(datasetForm.id ? '保存成功' : '创建成功');
showDatasetDialog.value = false;
getDatasetList();
} catch (error) {
console.error('保存失败:', error);
} finally {
datasetSaving.value = false;
}
}
});
};
// 获取文件列表
const getFileList = async () => {
if (!currentDataset.value) return;
fileLoading.value = true;
try {
// 模拟数据
fileList.value = [
{
id: '1',
name: '456_product(1).txt',
fileType: 'txt',
createdAt: '21/01/2026 16:53:32',
enabled: true,
chunkCount: 1,
parseStatus: 'general',
},
{
id: '2',
name: '123_speech(1).txt',
fileType: 'txt',
createdAt: '21/01/2026 16:53:26',
enabled: true,
chunkCount: 1,
parseStatus: 'general',
},
{
id: '3',
name: '123_product.txt',
fileType: 'txt',
createdAt: '21/01/2026 14:39:41',
enabled: true,
chunkCount: 1,
parseStatus: 'general',
},
];
} catch (error) {
console.error('获取文件列表失败:', error);
} finally {
fileLoading.value = false;
}
};
// 上传文件
const onUploadFile = () => {
uploadFileList.value = [];
showUploadDialog.value = true;
};
// 上传文件变化
const onUploadChange = (file: UploadFile, files: UploadFile[]) => {
uploadFileList.value = files;
};
// 移除上传文件
const onUploadRemove = (file: UploadFile, files: UploadFile[]) => {
uploadFileList.value = files;
};
// 确认上传
const onConfirmUpload = async () => {
uploading.value = true;
try {
ElMessage.success(`成功上传 ${uploadFileList.value.length} 个文件`);
showUploadDialog.value = false;
getFileList();
} catch (error) {
console.error('上传失败:', error);
} finally {
uploading.value = false;
}
};
// 文件状态变化
const onFileStatusChange = (row: any) => {
ElMessage.success(row.enabled ? '已启用' : '已禁用');
};
// 查看文档详情
const onViewDocumentDetail = (row: any) => {
currentDocument.value = row;
showDocumentDetailDialog.value = true;
};
// 预览文件
const onPreviewFile = (row: any) => {
onViewDocumentDetail(row);
};
// 下载文件
const onDownloadFile = (row: any) => {
ElMessage.info(`下载文件: ${row.name}`);
};
// 删除文件
const onDeleteFile = (row: any) => {
ElMessageBox.confirm(`确定要删除文件【${row.name}】吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(() => {
ElMessage.success('删除成功');
getFileList();
}).catch(() => {});
};
// 检索测试
const onSearchTest = () => {
if (!searchQuery.value.trim()) {
ElMessage.warning('请输入检索内容');
return;
}
// 模拟检索结果
searchResults.value = [
{ score: 0.92, content: '这是检索到的第一条相关内容...' },
{ score: 0.85, content: '这是检索到的第二条相关内容...' },
{ score: 0.78, content: '这是检索到的第三条相关内容...' },
];
};
// 获取日志列表
const getLogList = () => {
logList.value = [
{ time: '2026-01-21 16:53:32', content: '上传文件 456_product(1).txt' },
{ time: '2026-01-21 16:53:26', content: '上传文件 123_speech(1).txt' },
{ time: '2026-01-21 14:39:41', content: '上传文件 123_product.txt' },
{ time: '2026-01-17 10:00:00', content: '创建知识库 dataset_tenant_1' },
];
};
// 保存配置
const onSaveSettings = () => {
ElMessage.success('配置保存成功');
};
// 页面加载
onMounted(() => {
getDatasetList();
});
</script>
<style scoped lang="scss">
.knowledge-page {
height: 100%;
padding: 15px;
box-sizing: border-box;
// 数据集列表页
.dataset-list-view {
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
.header-left {
display: flex;
align-items: center;
.header-icon {
font-size: 24px;
color: #409eff;
margin-right: 10px;
}
.header-title {
font-size: 20px;
font-weight: 600;
color: #303133;
}
}
}
.dataset-cards {
display: flex;
flex-wrap: wrap;
gap: 16px;
.dataset-card {
width: 200px;
padding: 16px;
background: #fff;
border-radius: 8px;
border: 1px solid #ebeef5;
cursor: pointer;
transition: all 0.3s;
position: relative;
&:hover {
border-color: #409eff;
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.15);
.card-actions {
opacity: 1;
}
}
.card-icon {
width: 40px;
height: 40px;
border-radius: 8px;
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
.icon-text {
color: #fff;
font-size: 18px;
font-weight: 600;
}
}
.card-info {
.card-name {
font-size: 14px;
font-weight: 500;
color: #303133;
margin-bottom: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-meta {
font-size: 12px;
color: #909399;
margin-bottom: 4px;
}
.card-time {
font-size: 11px;
color: #c0c4cc;
}
}
.card-actions {
position: absolute;
top: 8px;
right: 8px;
opacity: 0;
transition: opacity 0.2s;
display: flex;
gap: 4px;
background: #fff;
padding: 4px;
border-radius: 4px;
}
}
.see-all-card {
width: 200px;
padding: 16px;
background: #f5f7fa;
border-radius: 8px;
border: 1px dashed #dcdfe6;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: #909399;
transition: all 0.3s;
&:hover {
border-color: #409eff;
color: #409eff;
}
}
}
}
// 数据集详情页
.dataset-detail-view {
height: 100%;
display: flex;
flex-direction: column;
background: #fff;
border-radius: 4px;
overflow: hidden;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
.detail-header {
padding: 12px 20px;
border-bottom: 1px solid #ebeef5;
.back-link {
color: #409eff;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
.detail-body {
flex: 1;
display: flex;
overflow: hidden;
// 左侧信息面板
.info-sidebar {
width: 200px;
border-right: 1px solid #ebeef5;
display: flex;
flex-direction: column;
background: #fafafa;
.dataset-profile {
padding: 20px 16px;
border-bottom: 1px solid #ebeef5;
.profile-icon {
width: 48px;
height: 48px;
border-radius: 8px;
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
.icon-text {
color: #fff;
font-size: 20px;
font-weight: 600;
}
}
.profile-info {
.profile-name {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 6px;
word-break: break-all;
}
.profile-meta {
font-size: 12px;
color: #909399;
margin-bottom: 4px;
}
.profile-time {
font-size: 11px;
color: #c0c4cc;
}
}
}
.func-menu {
flex: 1;
padding: 12px 0;
.menu-item {
display: flex;
align-items: center;
padding: 12px 16px;
color: #606266;
cursor: pointer;
transition: all 0.2s;
font-size: 14px;
.el-icon {
margin-right: 8px;
font-size: 16px;
}
&:hover {
color: #409eff;
background: #f0f0f0;
}
&.active {
color: #409eff;
background: #ecf5ff;
border-left: 3px solid #409eff;
font-weight: 500;
}
}
}
}
// 右侧主内容
.main-content {
flex: 1;
padding: 20px;
overflow: auto;
background: #f5f7fa;
.content-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
.header-title {
h3 {
margin: 0;
color: #303133;
font-size: 18px;
font-weight: 600;
}
.subtitle {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
}
.header-actions {
display: flex;
gap: 12px;
}
}
.file-table {
background: #fff;
border-radius: 4px;
padding: 16px;
.file-name {
display: flex;
align-items: center;
cursor: pointer;
.file-icon {
margin-right: 8px;
font-size: 16px;
}
.file-link {
color: #409eff;
&:hover {
text-decoration: underline;
}
}
}
}
.panel-card {
background: #fff;
border-radius: 4px;
padding: 20px;
h3 {
color: #303133;
margin-top: 0;
margin-bottom: 20px;
font-size: 16px;
font-weight: 600;
}
h4 {
color: #303133;
margin-bottom: 12px;
}
}
.search-results {
.result-item {
background: #f5f7fa;
padding: 12px 16px;
border-radius: 4px;
margin-bottom: 12px;
border: 1px solid #ebeef5;
.result-score {
color: #67c23a;
font-size: 12px;
margin-bottom: 8px;
font-weight: 500;
}
.result-content {
color: #606266;
line-height: 1.6;
}
}
}
}
}
}
}
.upload-area {
width: 100%;
:deep(.el-upload) {
width: 100%;
}
:deep(.el-upload-dragger) {
width: 100%;
}
}
</style>