2026-05-26 18:52:34 +08:00
|
|
|
|
import { useState, useEffect } from 'react'
|
|
|
|
|
|
import { Card, Upload, Table, Button, Space, Select, Popconfirm, message, Tag } from 'antd'
|
|
|
|
|
|
import { InboxOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons'
|
|
|
|
|
|
import useAuthStore from '../../stores/authStore'
|
|
|
|
|
|
import knowledgeService from '../../services/knowledgeService'
|
2026-05-27 09:35:36 +08:00
|
|
|
|
import KnowledgeTree from '../../components/KnowledgeTree/KnowledgeTree'
|
2026-05-26 18:52:34 +08:00
|
|
|
|
|
|
|
|
|
|
const { Dragger } = Upload
|
|
|
|
|
|
|
|
|
|
|
|
// source_type 枚举值(固定五类,不写死但供 Select 用)
|
|
|
|
|
|
const SOURCE_TYPE_OPTIONS = [
|
|
|
|
|
|
{ label: '全部类型', value: '' },
|
|
|
|
|
|
{ label: '杂志文章', value: 'military_report' },
|
|
|
|
|
|
{ label: '节目文稿', value: 'manuscript' },
|
|
|
|
|
|
{ label: '报题单', value: 'baoti' },
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
// source_type 中文标签
|
|
|
|
|
|
const SOURCE_TYPE_LABEL = {
|
|
|
|
|
|
military_report: '杂志文章',
|
|
|
|
|
|
manuscript: '节目文稿',
|
|
|
|
|
|
baoti: '报题单',
|
|
|
|
|
|
manual: '其他',
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default function KnowledgeBase() {
|
|
|
|
|
|
const { user } = useAuthStore()
|
|
|
|
|
|
const [items, setItems] = useState([])
|
2026-05-27 09:35:36 +08:00
|
|
|
|
const [treeData, setTreeData] = useState([])
|
2026-05-26 18:52:34 +08:00
|
|
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
|
|
const [uploading, setUploading] = useState(false)
|
|
|
|
|
|
const [sourceTypeFilter, setSourceTypeFilter] = useState('')
|
|
|
|
|
|
const [sourceDetailFilter, setSourceDetailFilter] = useState('')
|
2026-05-27 09:35:36 +08:00
|
|
|
|
const [selectedTreeNode, setSelectedTreeNode] = useState(null) // { type, detail }
|
2026-05-26 18:52:34 +08:00
|
|
|
|
|
|
|
|
|
|
const fetchItems = async () => {
|
|
|
|
|
|
setLoading(true)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await knowledgeService.listItems(sourceTypeFilter || null)
|
|
|
|
|
|
setItems(data)
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
message.error('加载知识库失败')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-27 09:35:36 +08:00
|
|
|
|
const fetchTree = async () => {
|
2026-05-26 18:52:34 +08:00
|
|
|
|
try {
|
2026-05-27 09:35:36 +08:00
|
|
|
|
const data = await knowledgeService.getGroupedItems()
|
|
|
|
|
|
setTreeData(data || [])
|
2026-05-26 18:52:34 +08:00
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
fetchItems()
|
2026-05-27 09:35:36 +08:00
|
|
|
|
fetchTree()
|
2026-05-26 18:52:34 +08:00
|
|
|
|
}, [sourceTypeFilter])
|
|
|
|
|
|
|
|
|
|
|
|
// 上传
|
|
|
|
|
|
const handleUpload = async (file) => {
|
|
|
|
|
|
if (!file.name.endsWith('.md')) {
|
|
|
|
|
|
message.warning('仅支持 .md 文件')
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
setUploading(true)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await knowledgeService.uploadFiles([file])
|
|
|
|
|
|
const { uploaded, errors } = result
|
|
|
|
|
|
if (uploaded.length > 0) {
|
|
|
|
|
|
message.success(`成功入库 ${uploaded.length} 篇`)
|
|
|
|
|
|
fetchItems()
|
2026-05-27 09:35:36 +08:00
|
|
|
|
fetchTree()
|
2026-05-26 18:52:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (errors.length > 0) {
|
|
|
|
|
|
errors.forEach(e => message.error(`${e.file}: ${e.error}`))
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
message.error('上传失败')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setUploading(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
return false // 阻止默认上传行为
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 删除
|
|
|
|
|
|
const handleDelete = async (id) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await knowledgeService.deleteItem(id)
|
|
|
|
|
|
message.success('删除成功')
|
|
|
|
|
|
fetchItems()
|
2026-05-27 09:35:36 +08:00
|
|
|
|
fetchTree()
|
2026-05-26 18:52:34 +08:00
|
|
|
|
} catch {
|
|
|
|
|
|
message.error('删除失败')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-27 09:35:36 +08:00
|
|
|
|
// 树节点选中 → 联动过滤
|
|
|
|
|
|
const handleNodeSelect = (node) => {
|
|
|
|
|
|
setSelectedTreeNode(node)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 根据树节点过滤列表
|
|
|
|
|
|
const displayedItems = (() => {
|
|
|
|
|
|
let result = items
|
|
|
|
|
|
|
|
|
|
|
|
// 树节点过滤优先(覆盖原有 Select 筛选)
|
|
|
|
|
|
if (selectedTreeNode) {
|
|
|
|
|
|
const { type, detail } = selectedTreeNode
|
|
|
|
|
|
result = result.filter(i => {
|
|
|
|
|
|
if (i.source_type !== type) return false
|
|
|
|
|
|
if (detail !== null && i.source_detail !== detail) return false
|
|
|
|
|
|
return true
|
|
|
|
|
|
})
|
|
|
|
|
|
} else if (sourceDetailFilter) {
|
|
|
|
|
|
// 保留原有的 source_detail 筛选逻辑
|
|
|
|
|
|
result = result.filter(i => i.source_detail === sourceDetailFilter)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
})()
|
2026-05-26 18:52:34 +08:00
|
|
|
|
|
|
|
|
|
|
const columns = [
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '标题',
|
|
|
|
|
|
dataIndex: 'title',
|
|
|
|
|
|
key: 'title',
|
|
|
|
|
|
render: (text, record) => (
|
|
|
|
|
|
<span style={{ fontWeight: 500, color: '#3b4a3b' }}>{text}</span>
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '作者',
|
|
|
|
|
|
dataIndex: 'author',
|
|
|
|
|
|
key: 'author',
|
2026-05-27 09:35:36 +08:00
|
|
|
|
render: (val) => val || null,
|
2026-05-26 18:52:34 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '播出/发表时间',
|
|
|
|
|
|
dataIndex: 'publish_date',
|
|
|
|
|
|
key: 'publish_date',
|
2026-05-27 09:35:36 +08:00
|
|
|
|
render: (val) => val || null,
|
2026-05-26 18:52:34 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '出处',
|
|
|
|
|
|
dataIndex: 'source_detail',
|
|
|
|
|
|
key: 'source_detail',
|
|
|
|
|
|
render: (val) => val || '-',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '类型',
|
|
|
|
|
|
dataIndex: 'source_type',
|
|
|
|
|
|
key: 'source_type',
|
|
|
|
|
|
render: (val) => (
|
|
|
|
|
|
<Tag color="default" style={{ borderRadius: 8 }}>
|
|
|
|
|
|
{SOURCE_TYPE_LABEL[val] || val}
|
|
|
|
|
|
</Tag>
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '入库时间',
|
|
|
|
|
|
dataIndex: 'created_at',
|
|
|
|
|
|
key: 'created_at',
|
|
|
|
|
|
render: (val) => val ? new Date(val).toLocaleString('zh-CN') : '-',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '操作',
|
|
|
|
|
|
key: 'action',
|
|
|
|
|
|
render: (_, record) => (
|
|
|
|
|
|
<Popconfirm
|
|
|
|
|
|
title="确认删除"
|
|
|
|
|
|
description="删除后不可恢复,该篇及其向量将一并清除"
|
|
|
|
|
|
onConfirm={() => handleDelete(record.id)}
|
|
|
|
|
|
okText="确认删除"
|
|
|
|
|
|
cancelText="取消"
|
|
|
|
|
|
placement="left"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Button size="small" danger icon={<DeleteOutlined />}>
|
|
|
|
|
|
删除
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Popconfirm>
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
2026-05-15 09:46:42 +08:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-05-27 09:35:36 +08:00
|
|
|
|
<div style={{ maxWidth: 1200, padding: '12px' }}>
|
2026-05-26 18:52:34 +08:00
|
|
|
|
<div style={{ marginBottom: 12 }}>
|
|
|
|
|
|
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: '#3b4a3b' }}>知识库</h2>
|
|
|
|
|
|
<p style={{ margin: '4px 0 0', fontSize: 12, color: '#888' }}>上传 md 笔记 · 语义检索 · 报题参考</p>
|
2026-05-15 09:46:42 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-26 18:52:34 +08:00
|
|
|
|
{/* 上传区 */}
|
|
|
|
|
|
<Card style={{ borderRadius: 14, marginBottom: 12 }} bodyStyle={{ padding: 0 }}>
|
|
|
|
|
|
<Dragger
|
|
|
|
|
|
accept=".md"
|
|
|
|
|
|
multiple
|
|
|
|
|
|
showUploadList={false}
|
|
|
|
|
|
beforeUpload={handleUpload}
|
|
|
|
|
|
disabled={uploading}
|
|
|
|
|
|
style={{ padding: '28px 20px', borderRadius: 14 }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<p className="ant-upload-drag-icon" style={{ marginBottom: 8 }}>
|
|
|
|
|
|
<InboxOutlined style={{ fontSize: 40, color: '#6b8e6b' }} />
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p className="ant-upload-text" style={{ fontSize: 15, fontWeight: 500, color: '#3b4a3b' }}>
|
|
|
|
|
|
{uploading ? '正在上传并生成向量…' : '点击或拖拽 .md 文件上传'}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p className="ant-upload-hint" style={{ fontSize: 12, color: '#888' }}>
|
|
|
|
|
|
支持批量上传,系统自动解析 yaml frontmatter 并生成语义向量
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</Dragger>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
2026-05-27 09:35:36 +08:00
|
|
|
|
{/* 主体:左侧树 + 右侧列表 */}
|
|
|
|
|
|
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
|
|
|
|
|
{/* 左侧树 */}
|
|
|
|
|
|
<Card
|
|
|
|
|
|
style={{ borderRadius: 14, width: 280, flexShrink: 0 }}
|
|
|
|
|
|
bodyStyle={{ padding: 0, height: '100%' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<KnowledgeTree
|
|
|
|
|
|
treeData={treeData}
|
|
|
|
|
|
onNodeSelect={handleNodeSelect}
|
|
|
|
|
|
selectedKey={selectedTreeNode ? `${selectedTreeNode.type}${selectedTreeNode.detail ? '|' + selectedTreeNode.detail : ''}` : 'all'}
|
2026-05-26 18:52:34 +08:00
|
|
|
|
/>
|
2026-05-27 09:35:36 +08:00
|
|
|
|
</Card>
|
2026-05-26 18:52:34 +08:00
|
|
|
|
|
2026-05-27 09:35:36 +08:00
|
|
|
|
{/* 右侧列表 + 筛选栏 */}
|
|
|
|
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
|
|
|
|
{/* 筛选栏 */}
|
|
|
|
|
|
<Card style={{ borderRadius: 14, marginBottom: 12 }}>
|
|
|
|
|
|
<Space size="middle" wrap>
|
|
|
|
|
|
<span style={{ fontSize: 13, color: '#666' }}>筛选:</span>
|
|
|
|
|
|
<Select
|
|
|
|
|
|
placeholder="按类型"
|
|
|
|
|
|
options={SOURCE_TYPE_OPTIONS}
|
|
|
|
|
|
value={sourceTypeFilter}
|
|
|
|
|
|
onChange={val => { setSourceTypeFilter(val); setSourceDetailFilter(''); setSelectedTreeNode(null) }}
|
|
|
|
|
|
style={{ width: 140 }}
|
|
|
|
|
|
allowClear
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Select
|
|
|
|
|
|
placeholder="按出处"
|
|
|
|
|
|
options={[{ label: '全部出处', value: '' }, ...(items.length ? [] : [])]}
|
|
|
|
|
|
value={sourceDetailFilter}
|
|
|
|
|
|
onChange={val => { setSourceDetailFilter(val); setSelectedTreeNode(null) }}
|
|
|
|
|
|
style={{ width: 200 }}
|
|
|
|
|
|
allowClear
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
icon={<ReloadOutlined />}
|
|
|
|
|
|
onClick={() => { fetchItems(); fetchTree() }}
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
>
|
|
|
|
|
|
刷新
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 列表 */}
|
|
|
|
|
|
<Card style={{ borderRadius: 14 }}>
|
|
|
|
|
|
<Table
|
|
|
|
|
|
columns={columns}
|
|
|
|
|
|
dataSource={displayedItems}
|
|
|
|
|
|
rowKey="id"
|
|
|
|
|
|
loading={loading}
|
|
|
|
|
|
pagination={{ pageSize: 10, size: 'small' }}
|
|
|
|
|
|
locale={{ emptyText: '暂无知识库条目,上传 md 文件开始入库' }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-26 18:52:34 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|