feat: 知识库笔记工坊立项 + contributor 功能 + Fable5 视角文档
- 知识库笔记工坊子项目立项:PRD v1.0 + 寄存条 + CLAUDE.md 登记 - 后端:知识库上传记录 contributor、新增 /contributors 接口 - 前端:知识库列表支持按贡献人筛选 - 新增 Fable5 视角模式库/案例集/建议文档 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,12 +5,10 @@ import useAuthStore from '../../stores/authStore'
|
||||
import knowledgeService from '../../services/knowledgeService'
|
||||
import KnowledgeTree from '../../components/KnowledgeTree/KnowledgeTree'
|
||||
|
||||
// 渲染 snippet 中的 **keyword** 加粗标记(不使用 dangerouslySetInnerHTML)
|
||||
function renderSnippet(text) {
|
||||
if (!text) return null
|
||||
const parts = text.split(/\*\*(.+?)\*\*/g)
|
||||
return parts.map((part, i) => {
|
||||
// 奇数索引是被 ** 包裹的关键词
|
||||
if (i % 2 === 1) {
|
||||
return <strong key={i} style={{ color: '#1677ff' }}>{part}</strong>
|
||||
}
|
||||
@@ -20,7 +18,6 @@ function renderSnippet(text) {
|
||||
|
||||
const { Dragger } = Upload
|
||||
|
||||
// source_type 枚举值(固定五类,不写死但供 Select 用)
|
||||
const SOURCE_TYPE_OPTIONS = [
|
||||
{ label: '全部类型', value: '' },
|
||||
{ label: '杂志文章', value: 'military_report' },
|
||||
@@ -28,7 +25,6 @@ const SOURCE_TYPE_OPTIONS = [
|
||||
{ label: '报题单', value: 'baoti' },
|
||||
]
|
||||
|
||||
// source_type 中文标签
|
||||
const SOURCE_TYPE_LABEL = {
|
||||
military_report: '杂志文章',
|
||||
manuscript: '节目文稿',
|
||||
@@ -42,18 +38,18 @@ export default function KnowledgeBase() {
|
||||
const [treeData, setTreeData] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [sources, setSources] = useState([]) // 出处下拉选项
|
||||
const [sources, setSources] = useState([])
|
||||
const [contributors, setContributors] = useState([])
|
||||
const [sourceTypeFilter, setSourceTypeFilter] = useState('')
|
||||
const [sourceDetailFilter, setSourceDetailFilter] = useState('')
|
||||
const [selectedTreeNode, setSelectedTreeNode] = useState(null) // { type, detail }
|
||||
const [contributorFilter, setContributorFilter] = useState('')
|
||||
const [selectedTreeNode, setSelectedTreeNode] = useState(null)
|
||||
|
||||
// 搜索状态
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchResults, setSearchResults] = useState([])
|
||||
const [searchLoading, setSearchLoading] = useState(false)
|
||||
const [expandedIds, setExpandedIds] = useState(new Set()) // 展开全文的卡片 id 集合
|
||||
const [expandedIds, setExpandedIds] = useState(new Set())
|
||||
|
||||
// 切换卡片展开/收起
|
||||
const toggleExpand = (id) => {
|
||||
setExpandedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
@@ -90,20 +86,28 @@ export default function KnowledgeBase() {
|
||||
const fetchSources = async () => {
|
||||
try {
|
||||
const data = await knowledgeService.listSources()
|
||||
// 接口返回 [{source: "航空知识 2026年第1期"}, ...],提取 source 字段
|
||||
setSources(data.map(s => s.source).filter(Boolean))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const fetchContributors = async () => {
|
||||
try {
|
||||
const data = await knowledgeService.listContributors()
|
||||
setContributors(data.map(c => c.contributor).filter(Boolean))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems()
|
||||
fetchTree()
|
||||
fetchSources()
|
||||
fetchContributors()
|
||||
}, [sourceTypeFilter])
|
||||
|
||||
// 上传
|
||||
const handleUpload = async (file) => {
|
||||
if (!file.name.endsWith('.md')) {
|
||||
message.warning('仅支持 .md 文件')
|
||||
@@ -117,6 +121,7 @@ export default function KnowledgeBase() {
|
||||
message.success(`成功入库 ${uploaded.length} 篇`)
|
||||
fetchItems()
|
||||
fetchTree()
|
||||
fetchContributors()
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
errors.forEach(e => message.error(`${e.file}: ${e.error}`))
|
||||
@@ -126,10 +131,9 @@ export default function KnowledgeBase() {
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
return false // 阻止默认上传行为
|
||||
return false
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
await knowledgeService.deleteItem(id)
|
||||
@@ -141,7 +145,6 @@ export default function KnowledgeBase() {
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.trim()) {
|
||||
setSearchResults([])
|
||||
@@ -159,42 +162,39 @@ export default function KnowledgeBase() {
|
||||
}
|
||||
}
|
||||
|
||||
// 清空搜索 → 恢复树形浏览(不丢树状态)
|
||||
const handleClearSearch = () => {
|
||||
setSearchQuery('')
|
||||
setSearchResults([])
|
||||
}
|
||||
|
||||
// 树节点选中 → 联动过滤
|
||||
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) {
|
||||
// 按大类决定用哪个字段比对(节目文稿=author,杂志文章=source_detail)
|
||||
if (type === 'manuscript') {
|
||||
if (i.author !== detail) return false
|
||||
} else {
|
||||
// 杂志文章等用 source_detail
|
||||
if (i.source_detail !== detail) return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
} else if (sourceDetailFilter) {
|
||||
// 保留原有的 source_detail 筛选逻辑
|
||||
result = result.filter(i => i.source_detail === sourceDetailFilter)
|
||||
}
|
||||
|
||||
if (contributorFilter) {
|
||||
result = result.filter(i => i.contributor === contributorFilter)
|
||||
}
|
||||
|
||||
return result
|
||||
})()
|
||||
|
||||
@@ -203,47 +203,52 @@ export default function KnowledgeBase() {
|
||||
title: '标题',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
width: 280,
|
||||
ellipsis: true,
|
||||
render: (text) => (
|
||||
<span style={{ fontWeight: 500, color: '#3b4a3b', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>{text}</span>
|
||||
<span style={{ fontWeight: 500, color: '#3b4a3b' }}>{text}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '作者',
|
||||
dataIndex: 'author',
|
||||
key: 'author',
|
||||
width: 100,
|
||||
render: (val) => val || '-',
|
||||
},
|
||||
{
|
||||
title: '播出/发表时间',
|
||||
dataIndex: 'publish_date',
|
||||
key: 'publish_date',
|
||||
width: 160,
|
||||
width: 80,
|
||||
render: (val) => val || '-',
|
||||
},
|
||||
{
|
||||
title: '出处',
|
||||
dataIndex: 'source_detail',
|
||||
key: 'source_detail',
|
||||
width: 200,
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (val) => val || '-',
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'source_type',
|
||||
key: 'source_type',
|
||||
width: 96,
|
||||
width: 90,
|
||||
render: (val) => (
|
||||
<Tag color="default" style={{ borderRadius: 8 }}>
|
||||
{SOURCE_TYPE_LABEL[val] || val}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '贡献人',
|
||||
dataIndex: 'contributor',
|
||||
key: 'contributor',
|
||||
width: 100,
|
||||
render: (val) => (
|
||||
<span style={{ color: val === '栏目冷启动' ? '#aaa' : '#3b4a3b', fontSize: 13 }}>
|
||||
{val || '栏目冷启动'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 90,
|
||||
width: 80,
|
||||
render: (_, record) => (
|
||||
<Popconfirm
|
||||
title="确认删除"
|
||||
@@ -262,14 +267,14 @@ export default function KnowledgeBase() {
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 1200, padding: '12px 16px' }}>
|
||||
<div style={{ padding: '12px 0' }}>
|
||||
{/* 标题栏 */}
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600, color: '#3b4a3b' }}>知识库</h2>
|
||||
<p style={{ margin: '4px 0 0', fontSize: 13, color: '#888' }}>上传 md 笔记 · 语义检索 · 报题参考</p>
|
||||
</div>
|
||||
|
||||
{/* 上传区(收小为一行按钮) */}
|
||||
{/* 上传区 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||
<Dragger
|
||||
accept=".md"
|
||||
@@ -285,7 +290,7 @@ export default function KnowledgeBase() {
|
||||
<span style={{ fontSize: 12, color: '#aaa' }}>支持批量上传,系统自动解析 yaml frontmatter 并生成语义向量</span>
|
||||
</div>
|
||||
|
||||
{/* 搜索栏(搜索模式/浏览模式互斥,清空恢复树形浏览不丢状态) */}
|
||||
{/* 搜索栏 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<Input
|
||||
placeholder="输入一段文字,语义搜索知识库…"
|
||||
@@ -320,7 +325,7 @@ export default function KnowledgeBase() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 搜索结果列表(搜索模式显示,浏览模式隐藏) */}
|
||||
{/* 搜索结果 */}
|
||||
{searchQuery && searchResults.length > 0 && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 13, color: '#666', marginBottom: 8, fontWeight: 500 }}>
|
||||
@@ -376,7 +381,6 @@ export default function KnowledgeBase() {
|
||||
>
|
||||
{renderSnippet(item.snippet)}
|
||||
</div>
|
||||
{/* 展开全文区域 */}
|
||||
{isExpanded && item.content_md && (
|
||||
<div
|
||||
style={{
|
||||
@@ -396,7 +400,6 @@ export default function KnowledgeBase() {
|
||||
{item.content_md}
|
||||
</div>
|
||||
)}
|
||||
{/* 展开/收起按钮 */}
|
||||
{item.content_md && (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<span
|
||||
@@ -433,11 +436,11 @@ export default function KnowledgeBase() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 主体:左侧树 + 右侧列表(flex 左右并列,宽度稳定) */}
|
||||
{/* 主体:左侧树 + 右侧列表 */}
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
||||
{/* 左侧树 */}
|
||||
<Card
|
||||
style={{ borderRadius: 14, width: 280, flexShrink: 0 }}
|
||||
style={{ borderRadius: 14, width: 220, flexShrink: 0 }}
|
||||
bodyStyle={{ padding: 0, height: '100%' }}
|
||||
>
|
||||
<KnowledgeTree
|
||||
@@ -450,15 +453,15 @@ export default function KnowledgeBase() {
|
||||
{/* 右侧列表 + 筛选栏 */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{/* 筛选栏 */}
|
||||
<Card style={{ borderRadius: 14, marginBottom: 12 }}>
|
||||
<Card style={{ borderRadius: 14, marginBottom: 12 }} bodyStyle={{ padding: '12px 16px' }}>
|
||||
<Space size="middle" wrap>
|
||||
<span style={{ fontSize: 14, color: '#666' }}>筛选:</span>
|
||||
<Select
|
||||
placeholder="按类型"
|
||||
options={SOURCE_TYPE_OPTIONS}
|
||||
value={sourceTypeFilter}
|
||||
onChange={val => { setSourceTypeFilter(val); setSourceDetailFilter(''); setSelectedTreeNode(null) }}
|
||||
style={{ width: 140 }}
|
||||
onChange={val => { setSourceTypeFilter(val); setSourceDetailFilter(''); setContributorFilter(''); setSelectedTreeNode(null) }}
|
||||
style={{ width: 130 }}
|
||||
allowClear
|
||||
/>
|
||||
<Select
|
||||
@@ -466,12 +469,20 @@ export default function KnowledgeBase() {
|
||||
options={[{ label: '全部出处', value: '' }, ...sources.map(s => ({ label: s, value: s }))]}
|
||||
value={sourceDetailFilter}
|
||||
onChange={val => { setSourceDetailFilter(val); setSelectedTreeNode(null) }}
|
||||
style={{ width: 200 }}
|
||||
style={{ width: 180 }}
|
||||
allowClear
|
||||
/>
|
||||
<Select
|
||||
placeholder="按贡献人"
|
||||
options={[{ label: '全部贡献人', value: '' }, ...contributors.map(c => ({ label: c, value: c }))]}
|
||||
value={contributorFilter}
|
||||
onChange={val => setContributorFilter(val)}
|
||||
style={{ width: 130 }}
|
||||
allowClear
|
||||
/>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => { fetchItems(); fetchTree() }}
|
||||
onClick={() => { fetchItems(); fetchTree(); fetchContributors() }}
|
||||
size="small"
|
||||
>
|
||||
刷新
|
||||
@@ -480,18 +491,19 @@ export default function KnowledgeBase() {
|
||||
</Card>
|
||||
|
||||
{/* 列表 */}
|
||||
<Card style={{ borderRadius: 14 }}>
|
||||
<Card style={{ borderRadius: 14 }} bodyStyle={{ padding: '8px 16px' }}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={displayedItems}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 10, size: 'small' }}
|
||||
pagination={{ pageSize: 12, size: 'small' }}
|
||||
locale={{ emptyText: '暂无知识库条目,上传 md 文件开始入库' }}
|
||||
size="small"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,11 @@ const knowledgeService = {
|
||||
/**
|
||||
* 获取按来源分组的树形结构(含「全部」根节点)
|
||||
*/
|
||||
async listContributors() {
|
||||
const resp = await http.get('/knowledge/contributors')
|
||||
return resp.data
|
||||
},
|
||||
|
||||
async getGroupedItems() {
|
||||
const resp = await http.get('/knowledge/grouped')
|
||||
return resp.data
|
||||
|
||||
Reference in New Issue
Block a user