File size: 7,364 Bytes
55847d9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 | import { useState, useEffect, useCallback } from 'react'
import {
Database, RefreshCw, BookOpen,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { ipc } from '../../services/ipc-client'
import { Button } from '../ui/Button'
import { EmptyState } from '../ui/EmptyState'
import { useProjectStore } from '../../stores/project-store'
import { globalEventBus } from '../../shared/event-bus'
import { loadKBData, type KBDocument } from '../../services/knowledge-service'
/** 知识库管理面板(侧栏)— 纯只读展示 + 搜索,数据由定稿自动驱动 */
export default function KnowledgePanel() {
const { t } = useTranslation('panels')
const [documents, setDocuments] = useState<KBDocument[]>([])
const [stats, setStats] = useState({ documentCount: 0, totalChunks: 0 })
const [currentPage, setCurrentPage] = useState(1)
const pageSize = 20
const [titleMap, setTitleMap] = useState<Record<string, string>>({})
/** 加载文档列表 + 统计(通过 Service 层) */
const loadData = useCallback(async () => {
try {
const { documents: docs, stats: s } = await loadKBData()
setDocuments(docs)
setStats(s)
} catch { /* 忽略 */ }
}, [])
useEffect(() => {
let mounted = true
Promise.resolve().then(() => { if (mounted) loadData() })
return () => { mounted = false }
}, [loadData])
// 通过 EventBus 监听资源刷新和定稿完成事件
useEffect(() => {
const unsub1 = globalEventBus.on('REFRESH_RESOURCE', () => { loadData() })
const unsub2 = globalEventBus.on('FINALIZE_COMPLETE', () => { loadData() })
return () => { unsub1(); unsub2() }
}, [loadData])
useEffect(() => {
let cancelled = false
const loadTitles = async () => {
if (documents.length === 0) return
const missing = documents.filter(d => d.filePath && !titleMap[d.id])
if (missing.length === 0) return
const newTitles: Record<string, string> = {}
await Promise.all(
missing.map(async (doc) => {
let title = doc.fileName
const rawName = doc.fileName.replace(/\.[^.]+$/, '')
const chMatch = rawName.match(/^(?:chapter_(\d+)|第(\d+)章)\s*(.*)$/)
if (chMatch) {
const num = chMatch[1] ? parseInt(chMatch[1], 10) : parseInt(chMatch[2], 10)
const rest = (chMatch[3] || '').trim()
title = rest ? t('knowledge.chapterTitle', { chapter: num, title: rest }) : t('knowledge.chapterOnly', { chapter: num })
}
try {
const res = await ipc.invoke('fs:read-file', doc.filePath)
if (res.success && res.content) {
const firstLine = res.content.split('\n').find((l: string) => l.trim())
if (firstLine) {
title = firstLine.replace(/^#+\s*/, '').trim() || title
}
}
} catch { /* 忽略 */ }
newTitles[doc.id] = title
})
)
if (!cancelled) setTitleMap(prev => ({ ...prev, ...newTitles }))
}
loadTitles()
return () => { cancelled = true }
}, [documents]) // eslint-disable-line react-hooks/exhaustive-deps -- titleMap 不需要作为依赖:内部通过 prev => 合并即可获取最新值
const currentProject = useProjectStore(s => s.currentProject)
if (!currentProject) {
return (
<EmptyState
icon={<BookOpen size={36} />}
message={t('knowledge.openProjectFirst')}
className="pb-[15vh]"
opacity={0.4}
/>
)
}
return (
<div className="h-full flex flex-col overflow-hidden text-sm">
{/* 标题栏 */}
<div className="flex items-center justify-between px-3 h-9 flex-shrink-0 border-b border-[var(--color-border)]">
<span className="text-xs font-medium text-[var(--color-text)] flex items-center gap-1.5">
<Database size={13} />
{t('knowledge.title')}
<span className="text-[0.7rem] text-[var(--color-text-muted)]">
{t('knowledge.docsChunks', { docs: stats.documentCount, chunks: stats.totalChunks })}
</span>
</span>
<Button
variant="ghost" size="icon"
onClick={() => loadData()}
title={t('common.refresh')}
className="h-6 w-6"
>
<RefreshCw size={11} />
</Button>
</div>
<div className="flex-1 overflow-y-auto">
{/* 已入库章节列表 */}
<div className="px-3 py-1.5 text-[0.7rem] text-[var(--color-text-muted)] font-medium uppercase tracking-wide">
{t('knowledge.indexedChapters')}
</div>
{documents.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 gap-2 opacity-40">
<BookOpen size={28} />
<span className="text-xs">{t('knowledge.noManuscript')}</span>
<span className="text-[0.7rem] text-center px-4">{t('knowledge.importDocumentAuto')}</span>
</div>
) : (
<div className="pb-4">
{documents
.sort((a, b) => new Date(b.importedAt).getTime() - new Date(a.importedAt).getTime())
.slice((currentPage - 1) * pageSize, currentPage * pageSize)
.map((doc) => (
<div
key={doc.id}
className="flex items-center justify-between px-3 py-2 hover:bg-[var(--color-hover)] transition-colors group"
>
<div className="flex-1 min-w-0">
<div className="text-xs text-[var(--color-text)] truncate" title={doc.fileName}>
{titleMap[doc.id] || doc.fileName}
</div>
<div className="flex items-center gap-2 text-[0.7rem] text-[var(--color-text-muted)] mt-0.5">
<span>{t('knowledge.chunks', { count: doc.chunkCount })}</span>
<span>{new Date(doc.importedAt).toLocaleDateString('zh-CN')}</span>
</div>
</div>
</div>
))}
{Math.ceil(documents.length / pageSize) > 1 && (
<div className="flex items-center justify-between px-3 pt-3">
<span className="text-[0.65rem] text-[var(--color-text-muted)]">
{currentPage} / {Math.ceil(documents.length / pageSize)}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline" size="sm"
className="h-6 text-[0.65rem] px-2"
disabled={currentPage === 1}
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
>
{t('knowledge.previousPage')}
</Button>
<Button
variant="outline" size="sm"
className="h-6 text-[0.65rem] px-2"
disabled={currentPage === Math.ceil(documents.length / pageSize)}
onClick={() => setCurrentPage(p => Math.min(Math.ceil(documents.length / pageSize), p + 1))}
>
{t('knowledge.nextPage')}
</Button>
</div>
</div>
)}
</div>
)}
</div>
</div>
)
}
|