feat(product-source): 光湖知识库模块 v0.1.0 — Git 驱动的完整知识库系统
铸渊 2026-08-08 开发,冰朔架构决策:
- Git 是底层引擎,不依赖任何数据库(PostgreSQL/Redis/ORM)
- Agent 直达底层,中间不隔第三方服务
- 拆解 Outline v0.80.2 为参考样本,光湖自己实现全部能力
技术栈:
- 后端:Express v5 + simple-git + gray-matter(Git 操作层)
- 前端:Vite + React 19 + TypeScript + marked
- 存储:Markdown 文件 + Git 仓库(commit=版本历史,diff=对比)
功能清单(全部可用):
- 文档 CRUD(创建/读取/更新/删除/移动)
- 文档树导航(文件夹层级)
- Markdown 编辑器(编辑/预览双栏)
- 全文搜索(防抖 + 下拉结果)
- 版本历史(git log)
- 版本 diff 对比(选择两个 commit 对比)
API 端点(Agent 和 UI 共用):
- GET/POST/PUT/DELETE /api/docs/{*path}
- GET /api/tree
- GET /api/history/{*path}
- GET /api/version/:hash/{*path}
- GET /api/diff/{*path}?from=&to=
- GET /api/search?q=
- POST /api/move
- GET /api/health
This commit is contained in:
parent
920b6bf690
commit
ecfeca40e2
16 changed files with 5970 additions and 0 deletions
|
|
@ -0,0 +1,87 @@
|
|||
import { useState } from 'react';
|
||||
import { DocTreeNode } from '../api';
|
||||
|
||||
interface Props {
|
||||
nodes: DocTreeNode[];
|
||||
currentPath: string;
|
||||
onSelect: (path: string) => void;
|
||||
onCreate: (parentPath: string) => void;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
export function DocTree({ nodes, currentPath, onSelect, onCreate, depth = 0 }: Props) {
|
||||
return (
|
||||
<div className="kb-tree" style={{ paddingLeft: depth > 0 ? 16 : 0 }}>
|
||||
{nodes.map(node => (
|
||||
<TreeNode
|
||||
key={node.path}
|
||||
node={node}
|
||||
currentPath={currentPath}
|
||||
onSelect={onSelect}
|
||||
onCreate={onCreate}
|
||||
depth={depth}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TreeNode({
|
||||
node,
|
||||
currentPath,
|
||||
onSelect,
|
||||
onCreate,
|
||||
depth,
|
||||
}: {
|
||||
node: DocTreeNode;
|
||||
currentPath: string;
|
||||
onSelect: (path: string) => void;
|
||||
onCreate: (parentPath: string) => void;
|
||||
depth: number;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(depth < 2);
|
||||
const isActive = node.path === currentPath;
|
||||
|
||||
if (node.type === 'folder') {
|
||||
return (
|
||||
<div className="kb-tree-folder">
|
||||
<div
|
||||
className={`kb-tree-item kb-tree-folder-header ${expanded ? 'expanded' : ''}`}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<span className="kb-tree-icon">{expanded ? '📂' : '📁'}</span>
|
||||
<span className="kb-tree-name">{node.name}</span>
|
||||
<button
|
||||
className="kb-tree-add"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onCreate(node.path);
|
||||
}}
|
||||
title="在此文件夹下新建文档"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{expanded && node.children && (
|
||||
<DocTree
|
||||
nodes={node.children}
|
||||
currentPath={currentPath}
|
||||
onSelect={onSelect}
|
||||
onCreate={onCreate}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`kb-tree-item kb-tree-doc ${isActive ? 'active' : ''}`}
|
||||
onClick={() => onSelect(node.path)}
|
||||
>
|
||||
<span className="kb-tree-icon">📄</span>
|
||||
<span className="kb-tree-name">{node.name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
product-source/guanghu-knowledge-base/src/components/Editor.tsx
Normal file
105
product-source/guanghu-knowledge-base/src/components/Editor.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { DocContent } from '../api';
|
||||
import { marked } from 'marked';
|
||||
|
||||
interface Props {
|
||||
doc: DocContent;
|
||||
onSave: (title: string, body: string) => void;
|
||||
}
|
||||
|
||||
export function Editor({ doc, onSave }: Props) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [title, setTitle] = useState(doc.meta.title);
|
||||
const [body, setBody] = useState(doc.body);
|
||||
|
||||
// 切换文档时重置
|
||||
useEffect(() => {
|
||||
setTitle(doc.meta.title);
|
||||
setBody(doc.body);
|
||||
setEditing(false);
|
||||
}, [doc.meta.id]);
|
||||
|
||||
const rendered = useMemo(() => {
|
||||
try {
|
||||
return marked(body, { async: false }) as string;
|
||||
} catch {
|
||||
return '<p>渲染失败</p>';
|
||||
}
|
||||
}, [body]);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(title, body);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setTitle(doc.meta.title);
|
||||
setBody(doc.body);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="kb-editor">
|
||||
{/* 元信息栏 */}
|
||||
<div className="kb-editor-meta">
|
||||
<span className="kb-editor-path">{doc.meta.id}</span>
|
||||
<span className="kb-editor-date">
|
||||
更新于 {new Date(doc.meta.updatedAt).toLocaleString('zh-CN')}
|
||||
</span>
|
||||
<span className="kb-editor-author">by {doc.meta.author}</span>
|
||||
</div>
|
||||
|
||||
{/* 标题 */}
|
||||
{editing ? (
|
||||
<input
|
||||
className="kb-editor-title-input"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder="文档标题"
|
||||
/>
|
||||
) : (
|
||||
<h1 className="kb-editor-title" onClick={() => setEditing(true)}>
|
||||
{doc.meta.title}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
{/* 操作栏 */}
|
||||
<div className="kb-editor-toolbar">
|
||||
{editing ? (
|
||||
<>
|
||||
<button className="kb-btn-primary" onClick={handleSave}>保存</button>
|
||||
<button className="kb-btn-secondary" onClick={handleCancel}>取消</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="kb-btn-primary" onClick={() => setEditing(true)}>编辑</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 内容区 */}
|
||||
{editing ? (
|
||||
<div className="kb-editor-edit-pane">
|
||||
<textarea
|
||||
className="kb-editor-textarea"
|
||||
value={body}
|
||||
onChange={e => setBody(e.target.value)}
|
||||
placeholder="用 Markdown 写作..."
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="kb-editor-preview-pane">
|
||||
<div
|
||||
className="kb-markdown-render"
|
||||
dangerouslySetInnerHTML={{ __html: rendered }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="kb-editor-read-pane">
|
||||
<div
|
||||
className="kb-markdown-render"
|
||||
dangerouslySetInnerHTML={{ __html: rendered }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import { api, SearchResult } from '../api';
|
||||
|
||||
interface Props {
|
||||
onSelect: (path: string) => void;
|
||||
}
|
||||
|
||||
export function SearchBar({ onSelect }: Props) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 防抖搜索
|
||||
useEffect(() => {
|
||||
if (!query.trim()) {
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await api.search(query);
|
||||
setResults(r);
|
||||
setOpen(true);
|
||||
} catch {
|
||||
setResults([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timerRef.current);
|
||||
}, [query]);
|
||||
|
||||
// 点击外部关闭
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="kb-search" ref={containerRef}>
|
||||
<div className="kb-search-input-wrap">
|
||||
<span className="kb-search-icon">🔍</span>
|
||||
<input
|
||||
className="kb-search-input"
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="搜索文档..."
|
||||
onFocus={() => results.length > 0 && setOpen(true)}
|
||||
/>
|
||||
{query && (
|
||||
<button className="kb-search-clear" onClick={() => { setQuery(''); setResults([]); setOpen(false); }}>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
{loading && <span className="kb-search-loading">...</span>}
|
||||
</div>
|
||||
|
||||
{open && results.length > 0 && (
|
||||
<div className="kb-search-dropdown">
|
||||
{results.map((r, i) => (
|
||||
<div
|
||||
key={`${r.path}-${i}`}
|
||||
className="kb-search-result"
|
||||
onClick={() => {
|
||||
onSelect(r.path);
|
||||
setOpen(false);
|
||||
setQuery('');
|
||||
}}
|
||||
>
|
||||
<div className="kb-search-result-title">{r.title}</div>
|
||||
<div className="kb-search-result-path">{r.path}</div>
|
||||
<div className="kb-search-result-snippet">{r.snippet}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open && results.length === 0 && !loading && query && (
|
||||
<div className="kb-search-dropdown">
|
||||
<div className="kb-search-empty">没有匹配的文档</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { api, VersionEntry, DiffResult } from '../api';
|
||||
|
||||
interface Props {
|
||||
docPath: string;
|
||||
}
|
||||
|
||||
export function VersionHistory({ docPath }: Props) {
|
||||
const [history, setHistory] = useState<VersionEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selected, setSelected] = useState<[string, string] | null>(null);
|
||||
const [diff, setDiff] = useState<DiffResult | null>(null);
|
||||
const [versionContent, setVersionContent] = useState<string | null>(null);
|
||||
const [viewingHash, setViewingHash] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadHistory();
|
||||
setSelected(null);
|
||||
setDiff(null);
|
||||
setVersionContent(null);
|
||||
setViewingHash(null);
|
||||
}, [docPath]);
|
||||
|
||||
const loadHistory = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const h = await api.getHistory(docPath);
|
||||
setHistory(h);
|
||||
} catch {
|
||||
setHistory([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectForDiff = (hash: string) => {
|
||||
if (!selected) {
|
||||
setSelected([hash, hash]);
|
||||
} else if (selected[0] === selected[1]) {
|
||||
// 选第二个点
|
||||
const sorted = [selected[0], hash].sort((a, b) => {
|
||||
const ai = history.findIndex(h => h.hash === a);
|
||||
const bi = history.findIndex(h => h.hash === b);
|
||||
return ai - bi;
|
||||
});
|
||||
setSelected([sorted[1], sorted[0]]); // [newer, older]
|
||||
loadDiff(sorted[1], sorted[0]);
|
||||
} else {
|
||||
// 重新开始选择
|
||||
setSelected([hash, hash]);
|
||||
setDiff(null);
|
||||
}
|
||||
};
|
||||
|
||||
const loadDiff = async (from: string, to: string) => {
|
||||
try {
|
||||
const d = await api.diffVersions(docPath, from, to);
|
||||
setDiff(d);
|
||||
} catch {
|
||||
setDiff(null);
|
||||
}
|
||||
};
|
||||
|
||||
const viewVersion = async (hash: string) => {
|
||||
setViewingHash(hash);
|
||||
try {
|
||||
const content = await api.getDocAtVersion(docPath, hash);
|
||||
setVersionContent(content);
|
||||
} catch {
|
||||
setVersionContent('加载失败');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="kb-history-loading">加载版本历史...</div>;
|
||||
|
||||
return (
|
||||
<div className="kb-history">
|
||||
<h2 className="kb-history-title">版本历史</h2>
|
||||
<p className="kb-history-hint">
|
||||
点击一个版本查看内容,点击两个版本对比差异
|
||||
</p>
|
||||
|
||||
<div className="kb-history-list">
|
||||
{history.map(entry => {
|
||||
const isSelected = selected && (selected[0] === entry.hash || selected[1] === entry.hash);
|
||||
return (
|
||||
<div
|
||||
key={entry.hash}
|
||||
className={`kb-history-entry ${isSelected ? 'selected' : ''}`}
|
||||
>
|
||||
<div className="kb-history-entry-main" onClick={() => handleSelectForDiff(entry.hash)}>
|
||||
<span className="kb-history-hash">{entry.shortHash}</span>
|
||||
<span className="kb-history-message">{entry.message}</span>
|
||||
<span className="kb-history-date">
|
||||
{new Date(entry.date).toLocaleString('zh-CN')}
|
||||
</span>
|
||||
<span className="kb-history-author">{entry.author}</span>
|
||||
</div>
|
||||
<button
|
||||
className="kb-btn-small"
|
||||
onClick={() => viewVersion(entry.hash)}
|
||||
>
|
||||
查看
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 版本内容预览 */}
|
||||
{viewingHash && versionContent !== null && (
|
||||
<div className="kb-history-preview">
|
||||
<div className="kb-history-preview-header">
|
||||
<span>版本 {viewingHash.substring(0, 7)}</span>
|
||||
<button onClick={() => { setViewingHash(null); setVersionContent(null); }}>关闭</button>
|
||||
</div>
|
||||
<pre className="kb-history-preview-content">{versionContent}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Diff 视图 */}
|
||||
{diff && selected && (
|
||||
<div className="kb-history-diff">
|
||||
<div className="kb-history-diff-header">
|
||||
<span>
|
||||
对比: {selected[1].substring(0, 7)} → {selected[0].substring(0, 7)}
|
||||
</span>
|
||||
<span className="kb-history-diff-stats">
|
||||
+{diff.additions} / -{diff.deletions}
|
||||
</span>
|
||||
<button onClick={() => { setSelected(null); setDiff(null); }}>关闭</button>
|
||||
</div>
|
||||
<div className="kb-history-diff-body">
|
||||
{diff.hunks.map((hunk, i) => (
|
||||
<div key={i} className="kb-diff-hunk">
|
||||
{hunk.lines.map((line, j) => (
|
||||
<div
|
||||
key={j}
|
||||
className={`kb-diff-line ${
|
||||
line.startsWith('+') ? 'add' :
|
||||
line.startsWith('-') ? 'del' : 'ctx'
|
||||
}`}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue