feat(graph+info): 重新设计文档关联板块(玻璃卡片+双向提示)与知识图谱显示(关联高亮+分组色点+克制配色)

This commit is contained in:
hunter-dev
2026-09-26 00:23:45 +08:00
parent 3f2f6a4e37
commit 3f80e3270f
3 changed files with 36 additions and 18 deletions
@@ -8,8 +8,8 @@ export interface GraphNode { id: string; title: string; basePath: string; star:
export interface GraphEdge { source: string; target: string; }
export interface GraphData { nodes: GraphNode[]; edges: GraphEdge[]; }
// 12 色轮换
const COLORS = ['#e6194b','#3cb44b','#ffe119','#4363d8','#f58231','#911eb4','#46f0f0','#f032e6','#bcf60c','#fabebe','#008080','#e6beff'];
// 分组着色——克制的墨灰阶(契合黑白灰玻璃设计体系,避免彩虹色破坏高级感)
const COLORS = ['#9aa1ad','#6d7480','#b6bcc6','#3c414b','#c8ccd4','#5c616b','#7c8491','#4a4f58','#aab1bd','#565b66','#8b929d','#626a75'];
export async function buildGraphData(root: string, tree: TreeNode[]): Promise<GraphData> {
const docs = tree.filter(n => n.type === 'doc');
@@ -73,10 +73,12 @@ export function DocInfoPanel() {
{links.length === 0 ? (
<div style={{ color: 'var(--text-tertiary)', opacity: 0.6, marginBottom: 8 }}>暂无关联,可在下方快速关联或批量关联</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 10 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 10 }}>
{links.map(l => (
<div key={l} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', background: 'var(--bg-secondary)', borderRadius: 6, border: '1px solid var(--control-border)' }}>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--text-secondary)' }} title={l}>{linkNames.get(l) ?? l}</span>
<div key={l} title={l} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '7px 10px', background: 'var(--bg-secondary)', borderRadius: 'var(--radius-sm)', border: '1px solid var(--control-border)', boxShadow: 'var(--shadow-soft)', transition: 'border-color .15s var(--ease), box-shadow .15s var(--ease)' }} onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--line-strong)'; e.currentTarget.style.boxShadow = 'var(--shadow-lift)'; }} onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--control-border)'; e.currentTarget.style.boxShadow = 'var(--shadow-soft)'; }}>
<span style={{ display: 'inline-flex', color: 'var(--text-tertiary)', flexShrink: 0 }}><Link2 size={14} /></span>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--text-primary)', fontWeight: 500 }}>{linkNames.get(l) ?? l}</span>
<span style={{ fontSize: 10, color: 'var(--text-tertiary)', flexShrink: 0, display: 'inline-flex', alignItems: 'center', gap: 3 }}><ArrowLeftRight size={11} />双向</span>
<button style={iconSmall} title="打开" onClick={() => openDoc(l)}><FileText size={13} /></button>
<button style={iconSmall} title="解除关联(双向)" onClick={() => void linkBatch([l], true)}><X size={13} /></button>
</div>
@@ -91,8 +91,18 @@ export function LinkGraph({ onSwitchToEditor }: { onSwitchToEditor?: () => void
.map((e) => ({ source: idx.get(e.source)!, target: idx.get(e.target)! }));
// 曲线连线(贝塞尔弧):比直线更优雅、更符合"图谱"观感
const linkSel = g.append('g').attr('stroke', 'var(--line)').attr('stroke-opacity', 0.6)
.selectAll('path').data(links).join('path').attr('fill', 'none').attr('stroke-width', 1.1);
// 与当前文档直接相连的节点集合——用于高亮"关联"关系
const curPath = useStore.getState().currentPath;
const linkedIds = new Set<string>();
for (const e of data.edges) {
if (e.source === curPath) linkedIds.add(e.target);
if (e.target === curPath) linkedIds.add(e.source);
}
const linkSel = g.append('g')
.selectAll('path').data(links).join('path').attr('fill', 'none')
.attr('stroke', (d: any) => (d.source.id === curPath || d.target.id === curPath) ? 'var(--ink-2)' : 'var(--line)')
.attr('stroke-opacity', (d: any) => (d.source.id === curPath || d.target.id === curPath) ? 0.9 : 0.5)
.attr('stroke-width', (d: any) => (d.source.id === curPath || d.target.id === curPath) ? 2 : 1.1);
// 布局参数:刻意拉大间距,卡片(180 宽)之间留足空白,避免拥挤
const sim = forceSimulation<SimNode>(nodes)
@@ -118,31 +128,37 @@ export function LinkGraph({ onSwitchToEditor }: { onSwitchToEditor?: () => void
const len = Math.max(1, Math.hypot(dx, dy));
return 'M' + sx + ',' + sy + ' Q' + (mx + (nx / len) * bend * dir) + ',' + (my + (ny / len) * bend * dir) + ' ' + tx + ',' + ty;
};
// 当前打开的文档作为深墨焦点节点
const curPath = useStore.getState().currentPath;
// 卡片:深墨焦点(当前文档)+ 玻璃底卡 + 分组色点 + 关联高亮(保持每卡 1 个 rect)
const nodeSel = g.append('g').selectAll('g').data(nodes).join('g').attr('cursor', 'pointer')
// 阴影 filter 加在 g 层:若加在卡片 rect 上会导致该 rect 的 pointer-events 命中失效(点击穿透)
.attr('filter', (d: any) => d.id === curPath ? 'url(#graph-card-focus)' : 'url(#graph-card-shadow)');
// 卡片:圆角底卡 + 标题 + 路径 + 星标(无图标,更简洁);当前文档 = 深墨焦点节点,文字居中
nodeSel.append('rect').attr('class', 'gcard').attr('x', -CARD_W / 2).attr('y', -CARD_H / 2).attr('width', CARD_W).attr('height', CARD_H).attr('rx', 13)
nodeSel.append('rect').attr('class', 'gcard').attr('x', -CARD_W / 2).attr('y', -CARD_H / 2).attr('width', CARD_W).attr('height', CARD_H).attr('rx', 14)
.attr('fill', (d: any) => d.id === curPath ? 'var(--ink)' : 'var(--float)')
.attr('stroke', (d: any) => d.id === curPath ? 'var(--ink)' : 'var(--line)')
.attr('stroke-width', 1.2)
.attr('stroke', (d: any) => d.id === curPath ? 'var(--ink)' : (linkedIds.has(d.id) ? 'var(--ink-2)' : 'var(--line)'))
.attr('stroke-width', (d: any) => (d.id === curPath || linkedIds.has(d.id)) ? 1.6 : 1.1)
.attr('pointer-events', 'all');
// 分组色点(左上角,circle 不增加卡片 rect 计数)
nodeSel.append('circle').attr('cx', -CARD_W / 2 + 10).attr('cy', -CARD_H / 2 + 10).attr('r', 3.4)
.attr('fill', (d: any) => d.id === curPath ? 'var(--space)' : (d.color || 'var(--ink-3)'))
.attr('opacity', 0.9);
// 标题(水平居中)
nodeSel.append('text').attr('x', 0).attr('y', -2).attr('text-anchor', 'middle')
nodeSel.append('text').attr('x', 0).attr('y', -3).attr('text-anchor', 'middle')
.attr('fill', (d: any) => d.id === curPath ? 'var(--space)' : 'var(--ink)')
.attr('font-size', 13).attr('font-weight', (d: any) => d.id === curPath ? 600 : 550).attr('font-family', 'sans-serif')
.attr('font-size', 13).attr('font-weight', (d: any) => d.id === curPath ? 650 : (linkedIds.has(d.id) ? 600 : 550)).attr('font-family', 'sans-serif')
.text((d) => truncate(d.title, 16));
// 路径副文本(水平居中)
nodeSel.append('text').attr('x', 0).attr('y', 13).attr('text-anchor', 'middle')
.attr('fill', (d: any) => d.id === curPath ? 'rgba(255,255,255,0.7)' : 'var(--ink-4)')
nodeSel.append('text').attr('x', 0).attr('y', 14).attr('text-anchor', 'middle')
.attr('fill', (d: any) => d.id === curPath ? 'rgba(255,255,255,0.72)' : 'var(--ink-4)')
.attr('font-size', 10).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', 4)
nodeSel.filter((d) => d.star).append('text').attr('x', CARD_W / 2 - 14).attr('y', 5)
.attr('fill', (d: any) => d.id === curPath ? 'var(--space)' : 'var(--ink-3)').attr('font-size', 14).text('★');
// 与当前文档关联的卡片右上角加一个小圆点提示(circle,不影响 rect 计数)
nodeSel.filter((d: any) => linkedIds.has(d.id) && d.id !== curPath).append('circle')
.attr('cx', CARD_W / 2 - 12).attr('cy', -CARD_H / 2 + 10).attr('r', 3)
.attr('fill', 'var(--ink-2)').attr('opacity', 0.85);
// 悬停高亮(CSS,不增加 rect)
svg.append('style').text('.gcard { transition: stroke-width .16s var(--ease), stroke .16s var(--ease); } g[data-hover] .gcard { stroke: var(--line-strong); stroke-width: 1.7; }');