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:
Wenjie Zhang 2026-05-18 23:53:35 +08:00
parent 5add3a070d
commit e81b7ae847
5 changed files with 1118 additions and 1365 deletions

View File

@ -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>

View File

@ -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;

View File

@ -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>

View File

@ -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