refactor: 重构知识库详情页布局,废弃旧组件
- DataBaseInfoView 整体重构为侧边栏标签页布局 - 新增顶部导航栏(返回、标题、编辑/复制操作) - 左侧功能导航(文件管理/检索测试/知识图谱/知识导图/RAG评估) - 文件管理面板增加统计卡片(待解析/待入库/总数/进度) - 检索配置从弹窗改为侧边内联面板 - 编辑弹窗支持 Dify/Notion 连接器配置和共享设置 - 移除废弃的 DatabaseHeader.vue 和 KnowledgeBaseCard.vue - FileTable 移除 rightPanelVisible prop 相关逻辑 - QuerySection 调整间距样式 Co-authored-by: xerrors <xerrors@qq.com>
This commit is contained in:
parent
5add3a070d
commit
e81b7ae847
@ -1,156 +0,0 @@
|
||||
<template>
|
||||
<HeaderComponent
|
||||
:title="database.name || '数据库信息加载中'"
|
||||
:loading="loading"
|
||||
class="database-info-header"
|
||||
>
|
||||
<template #left>
|
||||
<a-button
|
||||
@click="backToDatabase"
|
||||
shape="circle"
|
||||
:icon="h(LeftOutlined)"
|
||||
type="text"
|
||||
></a-button>
|
||||
</template>
|
||||
<template #behind-title>
|
||||
<a-button type="link" @click="showEditModal" :style="{ padding: '0px', color: 'inherit' }">
|
||||
<EditOutlined />
|
||||
</a-button>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="header-info">
|
||||
<span class="db-id"
|
||||
>ID:
|
||||
<span style="user-select: all">{{ database.db_id || 'N/A' }}</span>
|
||||
</span>
|
||||
<span class="file-count">{{ database.row_count || 0 }} 文件</span>
|
||||
<a-tag v-if="database.embedding_model_spec" color="blue">{{ database.embedding_model_spec }}</a-tag>
|
||||
<a-tag
|
||||
:color="getKbTypeColor(database.kb_type || 'milvus')"
|
||||
class="kb-type-tag"
|
||||
size="small"
|
||||
>
|
||||
<component :is="getKbTypeIcon(database.kb_type || 'milvus')" class="type-icon" />
|
||||
{{ getKbTypeLabel(database.kb_type || 'milvus') }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</template>
|
||||
</HeaderComponent>
|
||||
|
||||
<!-- 添加编辑对话框 -->
|
||||
<a-modal v-model:open="editModalVisible" title="编辑知识库信息">
|
||||
<template #footer>
|
||||
<a-button danger @click="deleteDatabase" style="margin-right: auto; margin-left: 0">
|
||||
<DeleteOutlined /> 删除数据库
|
||||
</a-button>
|
||||
<a-button key="back" @click="editModalVisible = false">取消</a-button>
|
||||
<a-button key="submit" type="primary" @click="handleEditSubmit">确定</a-button>
|
||||
</template>
|
||||
<a-form :model="editForm" :rules="rules" ref="editFormRef" layout="vertical">
|
||||
<a-form-item label="知识库名称" name="name" required>
|
||||
<a-input v-model:value="editForm.name" placeholder="请输入知识库名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="知识库描述" name="description">
|
||||
<AiTextarea
|
||||
v-model="editForm.description"
|
||||
:name="editForm.name"
|
||||
placeholder="请输入知识库描述"
|
||||
:rows="4"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useDatabaseStore } from '@/stores/database'
|
||||
import { getKbTypeLabel, getKbTypeIcon, getKbTypeColor } from '@/utils/kb_utils'
|
||||
import { LeftOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
||||
import HeaderComponent from '@/components/HeaderComponent.vue'
|
||||
import AiTextarea from '@/components/AiTextarea.vue'
|
||||
import { h } from 'vue'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useDatabaseStore()
|
||||
|
||||
const database = computed(() => store.database)
|
||||
const loading = computed(() => store.state.databaseLoading)
|
||||
|
||||
const editModalVisible = ref(false)
|
||||
const editFormRef = ref(null)
|
||||
const editForm = reactive({
|
||||
name: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入知识库名称' }]
|
||||
}
|
||||
|
||||
const backToDatabase = () => {
|
||||
router.push({ path: '/extensions', query: { tab: 'knowledge' } })
|
||||
}
|
||||
|
||||
const showEditModal = () => {
|
||||
editForm.name = database.value.name || ''
|
||||
editForm.description = database.value.description || ''
|
||||
editModalVisible.value = true
|
||||
}
|
||||
|
||||
const handleEditSubmit = () => {
|
||||
editFormRef.value
|
||||
.validate()
|
||||
.then(async () => {
|
||||
const updateData = {
|
||||
name: editForm.name,
|
||||
description: editForm.description
|
||||
}
|
||||
|
||||
await store.updateDatabaseInfo(updateData)
|
||||
editModalVisible.value = false
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('表单验证失败:', err)
|
||||
})
|
||||
}
|
||||
|
||||
const deleteDatabase = () => {
|
||||
store.deleteDatabase()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.database-info-header {
|
||||
padding: 8px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.db-id {
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.file-count {
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.kb-type-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.type-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
</style>
|
||||
@ -1,23 +1,8 @@
|
||||
<template>
|
||||
<div class="file-table-container">
|
||||
<div class="panel-header">
|
||||
<div class="upload-btn-group">
|
||||
<a-button type="primary" size="small" class="upload-btn" @click="showAddFilesModal()">
|
||||
<FileUp size="14" />
|
||||
上传
|
||||
</a-button>
|
||||
|
||||
<a-button
|
||||
class="panel-action-btn"
|
||||
type="text"
|
||||
size="small"
|
||||
@click="showCreateFolderModal"
|
||||
title="新建文件夹"
|
||||
>
|
||||
<template #icon><FolderPlus size="16" /></template>
|
||||
</a-button>
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<slot name="toolbar-extra" />
|
||||
<div class="panel-actions-default">
|
||||
<a-input
|
||||
v-model:value="filenameFilter"
|
||||
@ -32,23 +17,6 @@
|
||||
</template>
|
||||
</a-input>
|
||||
|
||||
<a-dropdown trigger="click">
|
||||
<a-button
|
||||
type="text"
|
||||
class="panel-action-btn"
|
||||
:class="{ active: sortField !== 'filename' }"
|
||||
title="排序"
|
||||
>
|
||||
<template #icon><ArrowUpDown size="16" /></template>
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu :selectedKeys="[sortField]" @click="handleSortMenuClick">
|
||||
<a-menu-item v-for="opt in sortOptions" :key="opt.value">
|
||||
{{ opt.label }}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
|
||||
<a-dropdown trigger="click">
|
||||
<a-button
|
||||
@ -114,21 +82,6 @@
|
||||
</a-input>
|
||||
</div>
|
||||
<div class="overflow-actions">
|
||||
<a-dropdown trigger="click" placement="bottomLeft">
|
||||
<div class="overflow-action-item" :class="{ active: sortField !== 'filename' }">
|
||||
<ArrowUpDown size="16" />
|
||||
<span>排序</span>
|
||||
<span class="overflow-action-hint">{{ currentSortLabel }}</span>
|
||||
</div>
|
||||
<template #overlay>
|
||||
<a-menu :selectedKeys="[sortField]" @click="handleSortMenuClick">
|
||||
<a-menu-item v-for="opt in sortOptions" :key="opt.value">
|
||||
{{ opt.label }}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
|
||||
<a-dropdown trigger="click" placement="bottomLeft">
|
||||
<div class="overflow-action-item" :class="{ active: statusFilter !== 'all' }">
|
||||
<Filter size="16" />
|
||||
@ -167,15 +120,6 @@
|
||||
</template>
|
||||
</a-dropdown>
|
||||
|
||||
<a-button
|
||||
type="text"
|
||||
@click="toggleRightPanel"
|
||||
title="切换右侧面板"
|
||||
class="panel-action-btn expand"
|
||||
:class="{ expanded: props.rightPanelVisible }"
|
||||
>
|
||||
<template #icon><ChevronLast size="16" /></template>
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -264,7 +208,6 @@
|
||||
row-key="file_id"
|
||||
class="my-table"
|
||||
size="small"
|
||||
:show-header="false"
|
||||
:pagination="tablePagination"
|
||||
@change="handleTableChange"
|
||||
v-model:expandedRowKeys="expandedRowKeys"
|
||||
@ -295,85 +238,56 @@
|
||||
{{ record.filename }}
|
||||
</span>
|
||||
</template>
|
||||
<a-popover
|
||||
v-else
|
||||
placement="right"
|
||||
overlayClassName="file-info-popover"
|
||||
:mouseEnterDelay="0.5"
|
||||
>
|
||||
<template #content>
|
||||
<div class="file-info-card">
|
||||
<div class="info-row">
|
||||
<span class="label">ID:</span> <span class="value">{{ record.file_id }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">状态:</span>
|
||||
<span class="value">{{ getStatusText(record.status) }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">时间:</span>
|
||||
<span class="value">{{ formatRelativeTime(record.created_at) }}</span>
|
||||
</div>
|
||||
<div v-if="record.error_message" class="info-row error">
|
||||
<span class="label">错误:</span>
|
||||
<span class="value">{{ record.error_message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-button class="main-btn" type="link" @click="openFileDetail(record)">
|
||||
<component
|
||||
:is="getFileIcon(record.displayName || text)"
|
||||
:style="{
|
||||
marginRight: '0',
|
||||
color: getFileIconColor(record.displayName || text),
|
||||
fontSize: '16px'
|
||||
}"
|
||||
/>
|
||||
{{ record.displayName || text }}
|
||||
</a-button>
|
||||
</a-popover>
|
||||
<a-button v-else class="main-btn" type="link" @click="openFileDetail(record)">
|
||||
<component
|
||||
:is="getFileIcon(record.displayName || text)"
|
||||
:style="{
|
||||
marginRight: '0',
|
||||
color: getFileIconColor(record.displayName || text),
|
||||
fontSize: '16px'
|
||||
}"
|
||||
/>
|
||||
{{ record.displayName || text }}
|
||||
</a-button>
|
||||
</div>
|
||||
<span v-else-if="column.key === 'type'">
|
||||
<span v-if="!record.is_folder" :class="['span-type', text]">{{
|
||||
text?.toUpperCase()
|
||||
}}</span>
|
||||
</span>
|
||||
<div
|
||||
v-else-if="column.key === 'status'"
|
||||
style="display: flex; align-items: center; justify-content: flex-end"
|
||||
>
|
||||
<div v-else-if="column.key === 'status'" class="file-status-cell">
|
||||
<template v-if="!record.is_folder">
|
||||
<a-tooltip :title="getStatusText(text)">
|
||||
<span
|
||||
v-if="text === 'done' || text === 'indexed'"
|
||||
style="color: var(--color-success-500)"
|
||||
><CheckCircleFilled
|
||||
/></span>
|
||||
<span
|
||||
v-else-if="
|
||||
text === 'failed' || text === 'error_parsing' || text === 'error_indexing'
|
||||
"
|
||||
style="color: var(--color-error-500)"
|
||||
><CloseCircleFilled
|
||||
/></span>
|
||||
<span
|
||||
v-else-if="text === 'processing' || text === 'parsing' || text === 'indexing'"
|
||||
style="color: var(--color-info-500)"
|
||||
><HourglassFilled
|
||||
/></span>
|
||||
<span
|
||||
v-else-if="text === 'waiting' || text === 'uploaded'"
|
||||
style="color: var(--color-warning-500)"
|
||||
><ClockCircleFilled
|
||||
/></span>
|
||||
<span v-else-if="text === 'parsed'" style="color: var(--color-primary-500)"
|
||||
><FileTextFilled
|
||||
/></span>
|
||||
<span v-else>{{ text }}</span>
|
||||
</a-tooltip>
|
||||
<span
|
||||
v-if="text === 'done' || text === 'indexed'"
|
||||
class="file-status-icon status-success"
|
||||
><CheckCircleFilled
|
||||
/></span>
|
||||
<span
|
||||
v-else-if="text === 'failed' || text === 'error_parsing' || text === 'error_indexing'"
|
||||
class="file-status-icon status-error"
|
||||
><CloseCircleFilled
|
||||
/></span>
|
||||
<span
|
||||
v-else-if="text === 'processing' || text === 'parsing' || text === 'indexing'"
|
||||
class="file-status-icon status-info"
|
||||
><HourglassFilled
|
||||
/></span>
|
||||
<span
|
||||
v-else-if="text === 'waiting' || text === 'uploaded'"
|
||||
class="file-status-icon status-warning"
|
||||
><ClockCircleFilled
|
||||
/></span>
|
||||
<span v-else-if="text === 'parsed'" class="file-status-icon status-primary"
|
||||
><FileTextFilled
|
||||
/></span>
|
||||
<span>{{ getStatusText(text) }}</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<span v-else-if="column.key === 'created_at'" class="file-time-cell">
|
||||
{{ record.is_folder ? '-' : formatStandardTime(text) }}
|
||||
</span>
|
||||
|
||||
<div v-else-if="column.key === 'action'" class="table-row-actions">
|
||||
<a-popover
|
||||
placement="bottomRight"
|
||||
@ -486,34 +400,18 @@ import {
|
||||
Trash2,
|
||||
Download,
|
||||
RotateCw,
|
||||
ChevronLast,
|
||||
Ellipsis,
|
||||
FolderPlus,
|
||||
CheckSquare,
|
||||
FileText,
|
||||
Database,
|
||||
FileUp,
|
||||
Search,
|
||||
Filter,
|
||||
ArrowUpDown,
|
||||
MoreHorizontal
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const store = useDatabaseStore()
|
||||
|
||||
const sortField = ref('filename')
|
||||
const sortOptions = [
|
||||
{ label: '文件名', value: 'filename' },
|
||||
{ label: '创建时间', value: 'created_at' },
|
||||
{ label: '状态', value: 'status' }
|
||||
]
|
||||
|
||||
const handleSortMenuClick = (e) => {
|
||||
sortField.value = e.key
|
||||
// 排序变化时重置到第一页
|
||||
paginationConfig.value.current = 1
|
||||
}
|
||||
|
||||
const handleStatusMenuClick = (e) => {
|
||||
statusFilter.value = e.key
|
||||
// 状态筛选变化时重置到第一页
|
||||
@ -538,15 +436,6 @@ const getStatusText = (status) => {
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
rightPanelVisible: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['showAddFilesModal', 'toggleRightPanel'])
|
||||
|
||||
const files = computed(() => Object.values(store.database.files || {}))
|
||||
const refreshing = computed(() => store.state.refrashing)
|
||||
const lock = computed(() => store.state.lock)
|
||||
@ -561,11 +450,6 @@ const selectedRowKeys = computed({
|
||||
const isSelectionMode = ref(false)
|
||||
const overflowMenuOpen = ref(false)
|
||||
|
||||
const currentSortLabel = computed(() => {
|
||||
const opt = sortOptions.find((o) => o.value === sortField.value)
|
||||
return opt ? opt.label : ''
|
||||
})
|
||||
|
||||
const currentStatusLabel = computed(() => {
|
||||
if (statusFilter.value === 'all') return ''
|
||||
const opt = statusOptions.find((o) => o.value === statusFilter.value)
|
||||
@ -643,6 +527,10 @@ const showCreateFolderModal = (parentId = null) => {
|
||||
createFolderModalVisible.value = true
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
showCreateFolderModal
|
||||
})
|
||||
|
||||
const toggleExpand = (record) => {
|
||||
if (!record.is_folder) return
|
||||
|
||||
@ -843,8 +731,7 @@ const columnsCompact = [
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 60,
|
||||
align: 'right',
|
||||
width: 90,
|
||||
sorter: (a, b) => {
|
||||
const statusOrder = {
|
||||
done: 1,
|
||||
@ -863,7 +750,15 @@ const columnsCompact = [
|
||||
},
|
||||
sortDirections: ['ascend', 'descend']
|
||||
},
|
||||
{ title: '', key: 'action', dataIndex: 'file_id', width: 40, align: 'center' }
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 180,
|
||||
sorter: (a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0),
|
||||
sortDirections: ['ascend', 'descend']
|
||||
},
|
||||
{ title: '操作', key: 'action', dataIndex: 'file_id', width: 64, align: 'center' }
|
||||
]
|
||||
|
||||
// 构建文件树
|
||||
@ -963,27 +858,7 @@ const buildFileTree = (fileList) => {
|
||||
if (a.is_folder && !b.is_folder) return -1
|
||||
if (!a.is_folder && b.is_folder) return 1
|
||||
|
||||
if (sortField.value === 'filename') {
|
||||
return (a.filename || '').localeCompare(b.filename || '')
|
||||
} else if (sortField.value === 'created_at') {
|
||||
return new Date(b.created_at || 0) - new Date(a.created_at || 0)
|
||||
} else if (sortField.value === 'status') {
|
||||
const statusOrder = {
|
||||
done: 1,
|
||||
indexed: 1,
|
||||
processing: 2,
|
||||
indexing: 2,
|
||||
parsing: 2,
|
||||
waiting: 3,
|
||||
uploaded: 3,
|
||||
parsed: 3,
|
||||
failed: 4,
|
||||
error_indexing: 4,
|
||||
error_parsing: 4
|
||||
}
|
||||
return (statusOrder[a.status] || 5) - (statusOrder[b.status] || 5)
|
||||
}
|
||||
return 0
|
||||
return (a.filename || '').localeCompare(b.filename || '')
|
||||
})
|
||||
nodes.forEach((node) => {
|
||||
if (node.children) sortNodes(node.children)
|
||||
@ -1056,21 +931,12 @@ const canBatchIndex = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const showAddFilesModal = (options = {}) => {
|
||||
emit('showAddFilesModal', options)
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
// 刷新时重置分页
|
||||
paginationConfig.value.current = 1
|
||||
store.getDatabaseInfo(undefined, true) // Skip query params for manual refresh
|
||||
}
|
||||
|
||||
const toggleRightPanel = () => {
|
||||
console.log(props.rightPanelVisible)
|
||||
emit('toggleRightPanel')
|
||||
}
|
||||
|
||||
const onSelectChange = (keys, selectedRows) => {
|
||||
// 只保留非文件夹的文件ID
|
||||
const fileKeys = selectedRows.filter((row) => !row.is_folder).map((row) => row.file_id)
|
||||
@ -1304,7 +1170,7 @@ const handleIndexConfigCancel = () => {
|
||||
}
|
||||
|
||||
// 导入工具函数
|
||||
import { getFileIcon, getFileIconColor, formatRelativeTime } from '@/utils/file_utils'
|
||||
import { getFileIcon, getFileIconColor, formatStandardTime } from '@/utils/file_utils'
|
||||
import { buildChunkParamsPayload, isPlainObject } from '@/utils/chunk_presets'
|
||||
import ChunkParamsConfig from '@/components/ChunkParamsConfig.vue'
|
||||
</script>
|
||||
@ -1328,22 +1194,25 @@ import ChunkParamsConfig from '@/components/ChunkParamsConfig.vue'
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
padding: 8px 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
|
||||
.panel-actions-default {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.overflow-trigger {
|
||||
display: none;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.action-searcher {
|
||||
@ -1412,7 +1281,7 @@ import ChunkParamsConfig from '@/components/ChunkParamsConfig.vue'
|
||||
background-color: transparent;
|
||||
min-height: 0;
|
||||
table-layout: fixed;
|
||||
padding-left: 4px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.my-table .main-btn {
|
||||
@ -1461,6 +1330,44 @@ import ChunkParamsConfig from '@/components/ChunkParamsConfig.vue'
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.file-status-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--gray-700);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-status-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
color: var(--color-success-500);
|
||||
}
|
||||
|
||||
.status-error {
|
||||
color: var(--color-error-500);
|
||||
}
|
||||
|
||||
.status-info {
|
||||
color: var(--color-info-500);
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
color: var(--color-warning-500);
|
||||
}
|
||||
|
||||
.status-primary {
|
||||
color: var(--color-primary-500);
|
||||
}
|
||||
|
||||
.file-time-cell {
|
||||
color: var(--gray-600);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.my-table .rechunk-btn:hover {
|
||||
color: var(--color-warning-500);
|
||||
}
|
||||
@ -1592,21 +1499,6 @@ import ChunkParamsConfig from '@/components/ChunkParamsConfig.vue'
|
||||
}
|
||||
}
|
||||
|
||||
.upload-btn-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.upload-btn {
|
||||
height: 28px;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
padding: 0 12px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="less">
|
||||
@ -1670,58 +1562,6 @@ import ChunkParamsConfig from '@/components/ChunkParamsConfig.vue'
|
||||
}
|
||||
}
|
||||
|
||||
.file-info-popover {
|
||||
.ant-popover-inner {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
// .ant-popover-inner-content {
|
||||
// padding: 16px;
|
||||
// }
|
||||
|
||||
.file-info-card {
|
||||
min-width: 120px;
|
||||
max-width: 320px;
|
||||
font-size: 13px;
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.5;
|
||||
align-items: flex-start;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--gray-500);
|
||||
width: 40px;
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
margin-right: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--gray-900);
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
font-family: monospace; /* Optional: for ID and numbers */
|
||||
}
|
||||
|
||||
&.error {
|
||||
.label {
|
||||
color: var(--color-error-500);
|
||||
}
|
||||
.value {
|
||||
color: var(--color-error-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.panel-overflow-popover {
|
||||
.ant-popover-inner {
|
||||
padding: 0;
|
||||
|
||||
@ -1,506 +0,0 @@
|
||||
<template>
|
||||
<div class="knowledge-base-card">
|
||||
<!-- 标题栏 -->
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<a-button
|
||||
@click="backToDatabase"
|
||||
class="back-button"
|
||||
shape="circle"
|
||||
:icon="h(LeftOutlined)"
|
||||
type="text"
|
||||
size="small"
|
||||
></a-button>
|
||||
<h3 class="card-title">{{ database.name || '数据库信息加载中' }}</h3>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<a-button type="text" size="small" @click="copyDatabaseId" title="复制知识库ID">
|
||||
<template #icon>
|
||||
<Copy :size="14" />
|
||||
</template>
|
||||
</a-button>
|
||||
<a-button @click="showEditModal" type="text" size="small">
|
||||
<template #icon>
|
||||
<Pencil :size="14" />
|
||||
</template>
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 卡片内容 -->
|
||||
<div class="card-content">
|
||||
<!-- 描述文本 -->
|
||||
<div class="description">
|
||||
<p class="description-text">{{ database.description || '暂无描述' }}</p>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑对话框 -->
|
||||
<a-modal v-model:open="editModalVisible" title="编辑知识库信息" width="700px">
|
||||
<template #footer>
|
||||
<a-button danger @click="deleteDatabase" style="margin-right: auto; margin-left: 0">
|
||||
<template #icon>
|
||||
<Trash2 :size="16" style="vertical-align: -3px; margin-right: 4px" />
|
||||
</template>
|
||||
删除数据库
|
||||
</a-button>
|
||||
<a-button key="back" @click="editModalVisible = false">取消</a-button>
|
||||
<a-button key="submit" type="primary" @click="handleEditSubmit">确定</a-button>
|
||||
</template>
|
||||
<a-form :model="editForm" :rules="rules" ref="editFormRef" layout="vertical">
|
||||
<a-form-item label="知识库名称" name="name" required>
|
||||
<a-input v-model:value="editForm.name" placeholder="请输入知识库名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="知识库描述" name="description">
|
||||
<AiTextarea
|
||||
v-model="editForm.description"
|
||||
:name="editForm.name"
|
||||
:files="fileList"
|
||||
placeholder="请输入知识库描述"
|
||||
:rows="4"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
v-if="!isReadOnlyConnector"
|
||||
label="自动生成问题"
|
||||
name="auto_generate_questions"
|
||||
>
|
||||
<a-switch
|
||||
v-model:checked="editForm.auto_generate_questions"
|
||||
checked-children="开启"
|
||||
un-checked-children="关闭"
|
||||
/>
|
||||
<span style="margin-left: 8px; font-size: 12px; color: var(--gray-500)"
|
||||
>上传文件后自动生成测试问题</span
|
||||
>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="!isReadOnlyConnector" name="chunk_preset_id">
|
||||
<template #label>
|
||||
<span class="chunk-preset-label">
|
||||
分块策略
|
||||
<a-tooltip :title="editPresetDescription">
|
||||
<QuestionCircleOutlined class="chunk-preset-help-icon" />
|
||||
</a-tooltip>
|
||||
</span>
|
||||
</template>
|
||||
<a-select v-model:value="editForm.chunk_preset_id" :options="chunkPresetOptions" />
|
||||
</a-form-item>
|
||||
|
||||
<template v-if="isDifyKb">
|
||||
<a-form-item label="Dify API URL" name="dify_api_url">
|
||||
<a-input
|
||||
v-model:value="editForm.dify_api_url"
|
||||
placeholder="例如: https://api.dify.ai/v1"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="Dify Token" name="dify_token">
|
||||
<a-input-password
|
||||
v-model:value="editForm.dify_token"
|
||||
placeholder="请输入 Dify API Token"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="Dataset ID" name="dify_dataset_id">
|
||||
<a-input v-model:value="editForm.dify_dataset_id" placeholder="请输入 Dify dataset_id" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<template v-if="isNotionKb">
|
||||
<a-form-item label="Notion Token" name="notion_token">
|
||||
<a-input-password
|
||||
v-model:value="editForm.notion_token"
|
||||
placeholder="留空则保持现有 Token 或使用环境变量"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="Data Source ID" name="notion_data_source_id">
|
||||
<a-input
|
||||
v-model:value="editForm.notion_data_source_id"
|
||||
placeholder="请输入 Notion data_source_id"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="Notion API Version" name="notion_version">
|
||||
<a-input v-model:value="editForm.notion_version" placeholder="2026-03-11" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 共享配置(超级管理员可编辑,非共享时本部门管理员也可编辑) -->
|
||||
<a-form-item v-if="canEditShareConfig" label="共享设置" name="share_config">
|
||||
<a-form-item-rest>
|
||||
<ShareConfigForm
|
||||
ref="shareConfigFormRef"
|
||||
:model-value="database.share_config"
|
||||
:auto-select-user-dept="true"
|
||||
/>
|
||||
</a-form-item-rest>
|
||||
</a-form-item>
|
||||
<!-- 非编辑状态下显示共享配置信息 -->
|
||||
<a-form-item v-else-if="database.share_config" label="共享设置" name="share_config_readonly">
|
||||
<div class="share-config-readonly">
|
||||
<a-tag :color="shareConfigDisplay.color">
|
||||
{{ shareConfigDisplay.label }}
|
||||
</a-tag>
|
||||
<span class="access-names">
|
||||
{{ shareConfigDisplay.detail }}
|
||||
</span>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, h, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useDatabaseStore } from '@/stores/database'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { CHUNK_PRESET_OPTIONS, getChunkPresetDescription } from '@/utils/chunk_presets'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { LeftOutlined, QuestionCircleOutlined } from '@ant-design/icons-vue'
|
||||
import { Pencil, Trash2, Copy } from 'lucide-vue-next'
|
||||
import { departmentApi } from '@/apis/department_api'
|
||||
import { authApi } from '@/apis/auth_api'
|
||||
import AiTextarea from '@/components/AiTextarea.vue'
|
||||
import ShareConfigForm from '@/components/ShareConfigForm.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useDatabaseStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const database = computed(() => store.database)
|
||||
const isDifyKb = computed(() => database.value?.kb_type === 'dify')
|
||||
const isNotionKb = computed(() => database.value?.kb_type === 'notion')
|
||||
const isReadOnlyConnector = computed(() => isDifyKb.value || isNotionKb.value)
|
||||
|
||||
const departments = ref([])
|
||||
const users = ref([])
|
||||
|
||||
const loadDepartments = async () => {
|
||||
try {
|
||||
const res = await departmentApi.getDepartments()
|
||||
departments.value = res.departments || res || []
|
||||
} catch (e) {
|
||||
console.error('加载部门列表失败:', e)
|
||||
departments.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
users.value = await authApi.getUserAccessOptions()
|
||||
} catch (e) {
|
||||
console.error('加载用户列表失败:', e)
|
||||
users.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDepartments()
|
||||
loadUsers()
|
||||
})
|
||||
|
||||
const shareConfigDisplay = computed(() => {
|
||||
const shareConfig = database.value?.share_config || { access_level: 'global' }
|
||||
if (shareConfig.access_level === 'department') {
|
||||
const departmentIds = shareConfig.department_ids || []
|
||||
const names = departmentIds.map((id) => getDepartmentName(id)).join('、') || '无'
|
||||
return {
|
||||
color: 'blue',
|
||||
label: '部门共享',
|
||||
detail: `${departmentIds.length} 个部门可访问:${names}`
|
||||
}
|
||||
}
|
||||
|
||||
if (shareConfig.access_level === 'user') {
|
||||
const userUids = shareConfig.user_uids || []
|
||||
const names = userUids.map((uid) => getUserName(uid)).join('、') || '无'
|
||||
return {
|
||||
color: 'purple',
|
||||
label: '指定人可访问',
|
||||
detail: `${userUids.length} 个用户可访问:${names}`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
color: 'green',
|
||||
label: '全局共享',
|
||||
detail: '所有用户可访问'
|
||||
}
|
||||
})
|
||||
|
||||
const getDepartmentName = (id) => {
|
||||
const dept = departments.value.find((item) => Number(item.id) === Number(id))
|
||||
return dept?.name || `部门${id}`
|
||||
}
|
||||
|
||||
const getUserName = (uid) => {
|
||||
const user = users.value.find((item) => item.uid === uid)
|
||||
return user?.username || uid
|
||||
}
|
||||
|
||||
// 是否可以编辑共享配置
|
||||
// 规则:1. 超级管理员可以编辑所有
|
||||
// 2. 管理员也可以编辑(后端会验证权限)
|
||||
const canEditShareConfig = computed(() => {
|
||||
if (userStore.isSuperAdmin) {
|
||||
return true
|
||||
}
|
||||
// 管理员可以编辑共享配置,后端会验证权限
|
||||
return userStore.isAdmin
|
||||
})
|
||||
|
||||
const fileList = computed(() => {
|
||||
if (!database.value?.files) return []
|
||||
return Object.values(database.value.files)
|
||||
.map((f) => f.filename)
|
||||
.filter(Boolean)
|
||||
})
|
||||
|
||||
// 复制数据库ID
|
||||
const copyDatabaseId = async () => {
|
||||
if (!database.value.db_id) {
|
||||
message.warning('知识库ID为空')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(database.value.db_id)
|
||||
message.success('知识库ID已复制到剪贴板')
|
||||
} catch {
|
||||
// 降级方案
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = database.value.db_id
|
||||
document.body.appendChild(textArea)
|
||||
textArea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textArea)
|
||||
message.success('知识库ID已复制到剪贴板')
|
||||
}
|
||||
}
|
||||
|
||||
// 返回数据库列表
|
||||
const backToDatabase = () => {
|
||||
router.push({ path: '/extensions', query: { tab: 'knowledge' } })
|
||||
}
|
||||
|
||||
// 编辑相关逻辑(复用自 DatabaseHeader)
|
||||
const editModalVisible = ref(false)
|
||||
const editFormRef = ref(null)
|
||||
const shareConfigFormRef = ref(null)
|
||||
const editForm = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
auto_generate_questions: false,
|
||||
chunk_preset_id: 'general',
|
||||
dify_api_url: '',
|
||||
dify_token: '',
|
||||
dify_dataset_id: '',
|
||||
notion_token: '',
|
||||
notion_data_source_id: '',
|
||||
notion_version: '2026-03-11'
|
||||
})
|
||||
|
||||
const chunkPresetOptions = CHUNK_PRESET_OPTIONS.map(({ label, value }) => ({ label, value }))
|
||||
const editPresetDescription = computed(() => getChunkPresetDescription(editForm.chunk_preset_id))
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入知识库名称' }]
|
||||
}
|
||||
|
||||
// 打开编辑弹窗
|
||||
const showEditModal = () => {
|
||||
console.log('[showEditModal] 被调用')
|
||||
|
||||
editForm.name = database.value.name || ''
|
||||
editForm.description = database.value.description || ''
|
||||
editForm.auto_generate_questions =
|
||||
database.value.additional_params?.auto_generate_questions || false
|
||||
editForm.chunk_preset_id = database.value.additional_params?.chunk_preset_id || 'general'
|
||||
editForm.dify_api_url = database.value.additional_params?.dify_api_url || ''
|
||||
editForm.dify_token = database.value.additional_params?.dify_token || ''
|
||||
editForm.dify_dataset_id = database.value.additional_params?.dify_dataset_id || ''
|
||||
editForm.notion_token = ''
|
||||
editForm.notion_data_source_id = database.value.additional_params?.notion_data_source_id || ''
|
||||
editForm.notion_version = database.value.additional_params?.notion_version || '2026-03-11'
|
||||
|
||||
editModalVisible.value = true
|
||||
}
|
||||
|
||||
const handleEditSubmit = () => {
|
||||
editFormRef.value
|
||||
.validate()
|
||||
.then(async () => {
|
||||
// 验证共享配置
|
||||
if (shareConfigFormRef.value) {
|
||||
const validation = shareConfigFormRef.value.validate()
|
||||
if (!validation.valid) {
|
||||
message.warning(validation.message)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const formConfig = shareConfigFormRef.value?.config || { access_level: 'global' }
|
||||
const updateData = {
|
||||
name: editForm.name,
|
||||
description: editForm.description,
|
||||
additional_params: {},
|
||||
share_config: {
|
||||
access_level: formConfig.access_level,
|
||||
department_ids: formConfig.access_level === 'department' ? formConfig.department_ids || [] : [],
|
||||
user_uids: formConfig.access_level === 'user' ? formConfig.user_uids || [] : []
|
||||
}
|
||||
}
|
||||
|
||||
if (isDifyKb.value) {
|
||||
if (
|
||||
!editForm.dify_api_url?.trim() ||
|
||||
!editForm.dify_token?.trim() ||
|
||||
!editForm.dify_dataset_id?.trim()
|
||||
) {
|
||||
message.error('请完整填写 Dify API URL、Token 和 Dataset ID')
|
||||
return
|
||||
}
|
||||
if (!editForm.dify_api_url.trim().endsWith('/v1')) {
|
||||
message.error('Dify API URL 必须以 /v1 结尾')
|
||||
return
|
||||
}
|
||||
updateData.additional_params = {
|
||||
dify_api_url: editForm.dify_api_url.trim(),
|
||||
dify_token: editForm.dify_token.trim(),
|
||||
dify_dataset_id: editForm.dify_dataset_id.trim()
|
||||
}
|
||||
} else if (isNotionKb.value) {
|
||||
if (!editForm.notion_data_source_id?.trim()) {
|
||||
message.error('请填写 Notion Data Source ID')
|
||||
return
|
||||
}
|
||||
updateData.additional_params = {
|
||||
notion_data_source_id: editForm.notion_data_source_id.trim(),
|
||||
notion_version: editForm.notion_version?.trim() || '2026-03-11'
|
||||
}
|
||||
if (editForm.notion_token?.trim()) {
|
||||
updateData.additional_params.notion_token = editForm.notion_token.trim()
|
||||
}
|
||||
} else {
|
||||
updateData.additional_params = {
|
||||
auto_generate_questions: editForm.auto_generate_questions,
|
||||
chunk_preset_id: editForm.chunk_preset_id || 'general'
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
'[handleEditSubmit] updateData.share_config:',
|
||||
JSON.stringify(updateData.share_config)
|
||||
)
|
||||
|
||||
await store.updateDatabaseInfo(updateData)
|
||||
editModalVisible.value = false
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('表单验证失败:', err)
|
||||
})
|
||||
}
|
||||
|
||||
const deleteDatabase = () => {
|
||||
store.deleteDatabase()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.knowledge-base-card {
|
||||
background: linear-gradient(120deg, var(--main-30) 0%, var(--gray-0) 100%);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--gray-200);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
// 只读共享配置显示
|
||||
.share-config-readonly {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.access-names {
|
||||
font-size: 13px;
|
||||
color: var(--gray-600);
|
||||
}
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
button.back-button {
|
||||
margin-left: -5px;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--gray-800);
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
// flex: 1;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
|
||||
button {
|
||||
color: var(--gray-500);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
color: var(--gray-700);
|
||||
background-color: var(--gray-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 0 16px 16px 16px;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-bottom: 12px;
|
||||
|
||||
.description-text {
|
||||
font-size: 14px;
|
||||
color: var(--gray-700);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.chunk-preset-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chunk-preset-help-icon {
|
||||
color: var(--gray-500);
|
||||
cursor: help;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@ -384,11 +384,10 @@ defineExpose({
|
||||
}
|
||||
|
||||
.query-input-container {
|
||||
padding: 16px;
|
||||
padding-bottom: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
background-color: var(--gray-0);
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
@ -512,7 +511,7 @@ defineExpose({
|
||||
}
|
||||
|
||||
.result-list {
|
||||
padding: 16px;
|
||||
// padding: 16px;
|
||||
|
||||
.no-results {
|
||||
text-align: center;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user