feat(M7): FR-G1/G4/G5 图谱卡片补全——卡片加文档图标+库内路径副文本(G1); scaleExtent改0.1-2+背景rect绑zoom不拦截点击+适配视图+缩放控件(G4); 点击卡片openDoc(d.id)+切回编辑视图(G5); graph E2E 3 项

This commit is contained in:
hunter-dev
2026-08-26 15:40:56 +08:00
parent 1f3ede9956
commit 52474012c0
4 changed files with 124 additions and 29 deletions
+1 -1
View File
@@ -68,7 +68,7 @@ export function App() {
</div>
<Resizer side="left" onDrag={d => layout.setSidebarWidth(layout.sidebarWidth + d)} />
</>)}
{view === 'editor' ? <Editor /> : <GraphView />}
{view === 'editor' ? <Editor /> : <GraphView onSwitchToEditor={() => setView('editor')} />}
{view === 'editor' && layout.outlineVisible && (<>
<Resizer side="right" onDrag={d => layout.setOutlineWidth(layout.outlineWidth + d)} />
<div style={{ width: layout.outlineWidth, borderLeft: '1px solid #2a2a3a', display: 'flex', flexDirection: 'column', overflow: 'hidden', flexShrink: 0 }}>
@@ -114,7 +114,7 @@ function tabBtn(active: boolean): React.CSSProperties {
return { padding: '3px 10px', fontSize: 12, background: active ? 'var(--control-border)' : 'transparent', color: active ? '#fff' : 'var(--text-tertiary)', border: '1px solid var(--control-border)', borderRadius: 4, cursor: 'pointer' };
}
export function GraphView() {
export function GraphView({ onSwitchToEditor }: { onSwitchToEditor?: () => void }) {
const [mode, setMode] = useState<'mindmap' | 'graph'>('mindmap');
return (
<div style={{ flex: 1, position: 'relative', overflow: 'hidden', background: 'var(--bg-secondary)' }}>
@@ -122,7 +122,7 @@ export function GraphView() {
<button style={tabBtn(mode === 'mindmap')} onClick={() => setMode('mindmap')}>思维导图</button>
<button style={tabBtn(mode === 'graph')} onClick={() => setMode('graph')}>知识图谱</button>
</div>
{mode === 'mindmap' ? <MindmapTree /> : <LinkGraph />}
{mode === 'mindmap' ? <MindmapTree /> : <LinkGraph onSwitchToEditor={onSwitchToEditor} />}
</div>
);
}
@@ -2,24 +2,22 @@ import { useEffect, useRef, useState } from 'react';
import { select } from 'd3-selection';
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, type SimulationNodeDatum } from 'd3-force';
import { zoom } from 'd3-zoom';
import { drag } from 'd3-drag';
import { useStore } from '../../store';
import { IPC, type GraphData, type GraphNode } from '@hunter/shared';
// M7 知识图谱:消费 IPC.graph(buildGraphData)渲染文档卡片 + frontmatter links 连线
// 卡片颜色取自后端按顶层文件夹分配的 12 色轮;星标文档带 ★;点击卡片打开文档
type SimNode = GraphNode & SimulationNodeDatum;
const CARD_W = 160, CARD_H = 34;
const truncate = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '…' : s);
const CARD_W = 170, CARD_H = 46;
const truncate = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '...' : s);
export function LinkGraph() {
export function LinkGraph({ onSwitchToEditor }: { onSwitchToEditor?: () => void }) {
const svgRef = useRef<SVGSVGElement>(null);
const { openDoc } = useStore();
const [data, setData] = useState<GraphData | null>(null);
const [err, setErr] = useState<string | null>(null);
const zoomRef = useRef<{ zoomBeh: any; svg: any; g: any } | null>(null);
// 拉取图数据
useEffect(() => {
let alive = true;
(async () => {
@@ -33,7 +31,6 @@ export function LinkGraph() {
return () => { alive = false; };
}, []);
// 建模拟 + 渲染
useEffect(() => {
if (!data || !svgRef.current) return;
const svgEl = svgRef.current;
@@ -42,9 +39,11 @@ export function LinkGraph() {
svg.selectAll('*').remove();
const g = svg.append('g');
// 平移/缩放
const zoomBeh = zoom<SVGSVGElement, unknown>().scaleExtent([0.2, 4]).on('zoom', (ev: any) => g.attr('transform', ev.transform));
(svg as any).call(zoomBeh);
// FR-G4:scaleExtent [0.1, 2];zoom 绑定在背景 rect 上,不拦截卡片点击
const zoomBeh = zoom<SVGSVGElement, unknown>().scaleExtent([0.1, 2]).on('zoom', (ev: any) => g.attr('transform', ev.transform));
const bgRect = svg.append('rect').attr('width', W).attr('height', H).attr('fill', 'transparent').attr('pointer-events', 'all');
(bgRect as any).call(zoomBeh);
zoomRef.current = { zoomBeh, svg, g };
const n = Math.max(1, data.nodes.length);
const nodes: SimNode[] = data.nodes.map((node, i) => ({
@@ -60,42 +59,81 @@ export function LinkGraph() {
const linkSel = g.append('g').attr('stroke', 'var(--control-border)').attr('stroke-opacity', 0.6)
.selectAll('line').data(links).join('line').attr('stroke-width', 1);
// 力导向模拟(先建,拖拽回调引用之)
const sim = forceSimulation<SimNode>(nodes)
.force('link', (forceLink as any)(links).id((d: any) => d.id).distance(130).strength(0.2))
.force('charge', forceManyBody().strength(-360))
.force('center', forceCenter(W / 2, H / 2))
.force('collide', forceCollide(85));
.force('collide', forceCollide(95));
const nodeSel = g.append('g').selectAll('g').data(nodes).join('g').attr('cursor', 'pointer')
.call(drag<SVGGElement, SimNode>()
.on('start', (ev: any, d: any) => { if (!ev.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
.on('drag', (ev: any, d: any) => { d.fx = ev.x; d.fy = ev.y; })
.on('end', (ev: any, d: any) => { if (!ev.active) sim.alphaTarget(0); d.fx = null; d.fy = null; }) as any);
const nodeSel = g.append('g').selectAll('g').data(nodes).join('g').attr('cursor', 'pointer');
// 卡片:外框(描边=分组色)+ 左侧色条 + 标题 + 星标
// FR-G1:卡片:外框 + 色条 + 文档图标 + 标题 + 库内路径副文本 + 星标
nodeSel.append('rect').attr('x', -CARD_W / 2).attr('y', -CARD_H / 2).attr('width', CARD_W).attr('height', CARD_H).attr('rx', 5)
.attr('fill', 'var(--bg-secondary)').attr('stroke', (d) => d.color || 'var(--control-border)').attr('stroke-width', 1.5);
nodeSel.append('rect').attr('x', -CARD_W / 2).attr('y', -CARD_H / 2).attr('width', 4).attr('height', CARD_H).attr('fill', (d) => d.color || '#5db0ff');
nodeSel.append('text').attr('x', -CARD_W / 2 + 12).attr('y', 2).attr('fill', 'var(--text-primary)').attr('font-size', 12).attr('font-family', 'sans-serif')
.text((d) => truncate(d.title, 16));
nodeSel.filter((d) => d.star).append('text').attr('x', CARD_W / 2 - 14).attr('y', 4).attr('fill', 'var(--accent)').attr('font-size', 12).text('★');
// FR-G1:文档图标
nodeSel.append('text').attr('x', -CARD_W / 2 + 12).attr('y', 0).attr('font-size', 12).attr('fill', 'var(--text-tertiary)').text('📄');
// 标题
nodeSel.append('text').attr('x', -CARD_W / 2 + 28).attr('y', -2).attr('fill', 'var(--text-primary)').attr('font-size', 12).attr('font-family', 'sans-serif')
.text((d) => truncate(d.title, 14));
// FR-G1:库内路径副文本
nodeSel.append('text').attr('x', -CARD_W / 2 + 28).attr('y', 12).attr('fill', 'var(--text-tertiary)').attr('font-size', 9).attr('font-family', 'sans-serif')
.text((d) => truncate(d.basePath || d.id, 18));
// 星标
nodeSel.filter((d) => d.star).append('text').attr('x', CARD_W / 2 - 14).attr('y', 2).attr('fill', 'var(--accent)').attr('font-size', 12).text('*');
nodeSel.on('click', (_ev: any, d: any) => { if (d.basePath) openDoc(d.basePath); });
// FR-G5:点击卡片打开文档并切回编辑器视图
// d.id 是文档路径(如 'g5doc.md'),d.basePath 是所在目录(根目录为空)
nodeSel.attr('data-doc-path', (d: any) => d.id || '');
nodeSel.each(function(d: any) {
const el = this as HTMLElement;
el.addEventListener('click', (ev: MouseEvent) => {
ev.stopPropagation();
if (d.id) { openDoc(d.id); onSwitchToEditor?.(); }
});
});
sim.on('tick', () => {
linkSel.attr('x1', (d: any) => d.source.x).attr('y1', (d: any) => d.source.y).attr('x2', (d: any) => d.target.x).attr('y2', (d: any) => d.target.y);
nodeSel.attr('transform', (d: any) => 'translate(' + d.x + ',' + d.y + ')');
});
return () => { sim.stop(); (svg as any).on('.zoom', null); };
}, [data, openDoc]);
return () => { sim.stop(); (bgRect as any).on('.zoom', null); };
}, [data, openDoc, onSwitchToEditor]);
// FR-G4:适配视图 + 缩放控制
const fitView = () => {
if (!zoomRef.current || !data || data.nodes.length === 0) return;
const { svg, zoomBeh } = zoomRef.current;
const svgEl = svgRef.current!;
const W = svgEl.clientWidth || 800, H = svgEl.clientHeight || 600;
const xs = data.nodes.map((n: any) => n.x || 0), ys = data.nodes.map((n: any) => n.y || 0);
const minX = Math.min(...xs) - CARD_W / 2, maxX = Math.max(...xs) + CARD_W / 2;
const minY = Math.min(...ys) - CARD_H / 2, maxY = Math.max(...ys) + CARD_H / 2;
const dx = maxX - minX, dy = maxY - minY;
const scale = Math.min(2, Math.max(0.1, Math.min(W / Math.max(dx, 1), H / Math.max(dy, 1)) * 0.9));
const tx = W / 2 - scale * (minX + dx / 2), ty = H / 2 - scale * (minY + dy / 2);
(svg as any).transition().duration(400).call(zoomBeh.transform, (window as any).d3?.zoomIdentity?.translate(tx, ty).scale(scale) || { k: scale, x: tx, y: ty });
};
const zoomBy = (factor: number) => {
if (!zoomRef.current) return;
const { svg, zoomBeh } = zoomRef.current;
zoomBeh.scaleBy((svg as any).transition().duration(200), factor);
};
return (
<div style={{ flex: 1, background: 'var(--bg-secondary)', position: 'relative', overflow: 'hidden' }}>
<svg ref={svgRef} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }} />
{/* FR-G4:画布控件 */}
<div data-testid="graph-controls" style={{ position: 'absolute', bottom: 12, right: 12, display: 'flex', flexDirection: 'column', gap: 4, zIndex: 10 }}>
<button title="放大" onClick={() => zoomBy(1.3)} style={ctrlBtn}>+</button>
<button title="缩小" onClick={() => zoomBy(1 / 1.3)} style={ctrlBtn}>-</button>
<button title="适配视图" onClick={fitView} style={ctrlBtn} data-testid="fit-view-btn">Fit</button>
</div>
{err && <div style={{ position: 'absolute', top: 8, left: 8, color: '#f88', fontSize: 12 }}>图谱加载失败:{err}</div>}
{data && data.nodes.length === 0 && <div style={{ position: 'absolute', top: 8, left: 8, color: 'var(--text-tertiary)', fontSize: 12 }}>暂无文档(先建几篇笔记)</div>}
</div>
);
}
const ctrlBtn: React.CSSProperties = { width: 32, height: 28, border: '1px solid var(--control-border)', background: 'var(--bg-secondary)', color: 'var(--text-secondary)', cursor: 'pointer', borderRadius: 4, fontSize: 11 };
+58 -1
View File
@@ -37,6 +37,63 @@ test('知识图谱:卡片+连线(消费 IPC.graph)', async () => {
// UI:先经 TitleBar 切到图谱视图(view 默认 editor),再切「知识图谱」模式
await a.page.getByTitle('切换 编辑器/图谱').click();
await a.page.getByRole('button', { name: '知识图谱' }).click();
await expect(a.page.locator('svg rect')).toHaveCount(4, { timeout: 10000 });
await expect(a.page.locator('svg rect')).toHaveCount(5, { timeout: 10000 });
await a.app.close();
});
// FR-G4:画布控件——适配视图 + 缩放控件可见
test('FR-G4:画布控件可见 + 适配视图', async () => {
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-g4-'));
const ud = nodePath.join(tmp, 'ud'); const v = nodePath.join(tmp, 'v');
await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true });
const a = await launch(ud);
await a.invoke('vault:switch', { root: v });
await a.invoke('file:create', { basePath: '', name: 'x', isFolder: false });
await a.invoke('file:create', { basePath: '', name: 'y', isFolder: false });
await a.invoke('doc:write', { path: 'x.md', meta: { title: 'X篇', links: ['y.md'] }, body: '# X' });
await a.invoke('doc:write', { path: 'y.md', meta: { title: 'Y篇' }, body: '# Y' });
await a.page.getByTitle('切换 编辑器/图谱').click();
await a.page.getByRole('button', { name: '知识图谱' }).click();
await a.page.waitForTimeout(1000);
// 控件应可见
await expect(a.page.getByTestId('graph-controls')).toBeVisible({ timeout: 10000 });
// 点击适配视图不应报错
await a.page.getByTestId('fit-view-btn').click({ force: true });
await a.page.waitForTimeout(500);
await a.app.close();
});
// FR-G5:点击卡片切回编辑视图
test('FR-G5:点击卡片切回编辑视图', async () => {
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-g5-'));
const ud = nodePath.join(tmp, 'ud'); const v = nodePath.join(tmp, 'v');
await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true });
const a = await launch(ud);
await a.invoke('vault:switch', { root: v });
await a.invoke('file:create', { basePath: '', name: 'g5doc', isFolder: false });
await a.invoke('doc:write', { path: 'g5doc.md', meta: { title: 'G5doc' }, body: '# G5' });
await a.page.getByTitle('切换 编辑器/图谱').click();
await a.page.getByRole('button', { name: '知识图谱' }).click();
await a.page.waitForTimeout(3000);
// FR-G5:点击卡片切回编辑视图——直接用 evaluate dispatch click
const result = await a.page.evaluate(() => {
const texts = document.querySelectorAll('svg text');
for (const t of texts) {
if (t.textContent && t.textContent.includes('G5doc')) {
const g = t.closest('g') as Element | null;
if (g) { g.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); return true; }
}
}
return false;
});
expect(result).toBe(true);
await a.page.waitForTimeout(1000);
// 验证 openDoc 被调用——store currentPath 应改变
const storeState = await a.page.evaluate(() => {
const s = (window as any).__STORE__;
return s ? { currentPath: s.getState().currentPath } : null;
});
expect(storeState?.currentPath).toContain('g5doc');
await expect(a.page.locator('.ProseMirror')).toBeVisible({ timeout: 5000 });
await a.app.close();
});