concept-atlas-dense-explain 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -3
- package/skill/SKILL.md +84 -59
- package/skill/assets/template/content/compile-runtime.mdx +158 -158
- package/skill/assets/template/index.html +16 -16
- package/skill/assets/template/package.json +20 -20
- package/skill/assets/template/scripts/build.mjs +37 -37
- package/skill/assets/template/scripts/clean-temp.mjs +27 -27
- package/skill/assets/template/src/app/App.jsx +239 -239
- package/skill/assets/template/src/components/MDXComponents.jsx +503 -477
- package/skill/assets/template/src/components/index.js +1 -1
- package/skill/assets/template/src/main.jsx +16 -16
- package/skill/assets/template/src/model/concept-schema.js +169 -169
- package/skill/assets/template/src/model/normalize-content.js +241 -236
- package/skill/assets/template/src/model/relation-types.js +90 -90
- package/skill/assets/template/src/styles/concept-explain.css +290 -9
- package/skill/assets/template/src/views/NodeExplorer.jsx +59 -20
- package/skill/assets/template/src/views/RelationGraph.jsx +16 -1
- package/skill/assets/template/vite.config.js +21 -21
- package/skill/references/components.md +74 -64
- package/skill/references/prompting.md +77 -77
|
@@ -1,239 +1,239 @@
|
|
|
1
|
-
import React, { useState, useEffect, useMemo } from 'react';
|
|
2
|
-
import { Network, Compass, Sun, Moon, Search, X, Link as LinkIcon } from 'lucide-react';
|
|
3
|
-
import { buildGraphModel } from '../model/concept-schema.js';
|
|
4
|
-
import { extractConceptData } from '../model/normalize-content.js';
|
|
5
|
-
import { NodeExplorer } from '../views/NodeExplorer.jsx';
|
|
6
|
-
import { RelationGraph } from '../views/RelationGraph.jsx';
|
|
7
|
-
import '../styles/concept-explain.css';
|
|
8
|
-
|
|
9
|
-
export function App({ mdxContent, initialData }) {
|
|
10
|
-
// Extract graph model from MDX JSX Element or raw data
|
|
11
|
-
const [graph] = useState(() => {
|
|
12
|
-
if (initialData) {
|
|
13
|
-
return buildGraphModel(initialData);
|
|
14
|
-
}
|
|
15
|
-
if (mdxContent) {
|
|
16
|
-
const extracted = extractConceptData(mdxContent);
|
|
17
|
-
return buildGraphModel(extracted);
|
|
18
|
-
}
|
|
19
|
-
return buildGraphModel({ meta: {}, nodes: [], relations: [] });
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
// Theme state ('light' | 'dark')
|
|
23
|
-
const [theme, setTheme] = useState(() => {
|
|
24
|
-
return localStorage.getItem('concept_atlas_theme') || 'dark';
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
useEffect(() => {
|
|
28
|
-
document.documentElement.setAttribute('data-theme', theme);
|
|
29
|
-
localStorage.setItem('concept_atlas_theme', theme);
|
|
30
|
-
}, [theme]);
|
|
31
|
-
|
|
32
|
-
const toggleTheme = () => {
|
|
33
|
-
setTheme(prev => (prev === 'dark' ? 'light' : 'dark'));
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
// Global shared state
|
|
37
|
-
const [currentView, setCurrentView] = useState('explore'); // 'explore' | 'graph'
|
|
38
|
-
const [currentNodeId, setCurrentNodeId] = useState(() => {
|
|
39
|
-
const hashNode = new URLSearchParams(window.location.hash.replace(/^#/, '')).get('node');
|
|
40
|
-
return hashNode && graph.nodes.has(hashNode) ? hashNode : (graph.meta.rootId || '');
|
|
41
|
-
});
|
|
42
|
-
const [selectedLevel, setSelectedLevel] = useState(null);
|
|
43
|
-
const [history, setHistory] = useState(() => {
|
|
44
|
-
const initial = new URLSearchParams(window.location.hash.replace(/^#/, '')).get('node');
|
|
45
|
-
return initial && graph.nodes.has(initial) ? [initial] : [graph.meta.rootId].filter(Boolean);
|
|
46
|
-
});
|
|
47
|
-
const [historyIndex, setHistoryIndex] = useState(0);
|
|
48
|
-
const [globalQuery, setGlobalQuery] = useState('');
|
|
49
|
-
const [linkCopied, setLinkCopied] = useState(false);
|
|
50
|
-
|
|
51
|
-
const searchResults = useMemo(() => {
|
|
52
|
-
const query = globalQuery.trim().toLowerCase();
|
|
53
|
-
if (!query) return [];
|
|
54
|
-
return Array.from(graph.nodes.values()).map(node => {
|
|
55
|
-
const searchable = [
|
|
56
|
-
node.title, node.id, node.summary, node.definition, node.overview,
|
|
57
|
-
node.mechanism, node.input, node.output,
|
|
58
|
-
...(node.examples || []).flatMap(item => [item.title, item.content]),
|
|
59
|
-
...(node.glossary || []).flatMap(item => [item.term, item.definition]),
|
|
60
|
-
...(node.boundaries || []).flatMap(item => [item.title, item.content]),
|
|
61
|
-
].filter(value => typeof value === 'string').join(' ').toLowerCase();
|
|
62
|
-
return searchable.includes(query) ? node : null;
|
|
63
|
-
}).filter(Boolean).slice(0, 8);
|
|
64
|
-
}, [globalQuery, graph.nodes]);
|
|
65
|
-
|
|
66
|
-
const navigateToNode = (nodeId, { replace = false } = {}) => {
|
|
67
|
-
if (!nodeId || !graph.nodes.has(nodeId)) return;
|
|
68
|
-
setCurrentNodeId(nodeId);
|
|
69
|
-
setHistory(previous => {
|
|
70
|
-
const base = previous.slice(0, historyIndex + 1);
|
|
71
|
-
if (base[base.length - 1] === nodeId) return previous;
|
|
72
|
-
const next = [...base, nodeId];
|
|
73
|
-
setHistoryIndex(next.length - 1);
|
|
74
|
-
return next;
|
|
75
|
-
});
|
|
76
|
-
const nextHash = `#node=${encodeURIComponent(nodeId)}`;
|
|
77
|
-
if (replace) window.history.replaceState({}, '', nextHash);
|
|
78
|
-
else window.history.pushState({}, '', nextHash);
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
const moveHistory = (direction) => {
|
|
82
|
-
const nextIndex = Math.max(0, Math.min(history.length - 1, historyIndex + direction));
|
|
83
|
-
if (nextIndex === historyIndex) return;
|
|
84
|
-
setHistoryIndex(nextIndex);
|
|
85
|
-
const nodeId = history[nextIndex];
|
|
86
|
-
setCurrentNodeId(nodeId);
|
|
87
|
-
window.history.pushState({}, '', `#node=${encodeURIComponent(nodeId)}`);
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
// Keyboard navigation shortcuts
|
|
91
|
-
useEffect(() => {
|
|
92
|
-
const handleKeyDown = (e) => {
|
|
93
|
-
// Toggle views with 1 and 2 or 'g' and 'e' if not focused on input
|
|
94
|
-
if (e.isComposing || e.metaKey || e.ctrlKey || e.altKey || ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName) || document.activeElement?.isContentEditable) {
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
const key = e.key.toLowerCase();
|
|
98
|
-
if (e.key === '1' || key === 'e') {
|
|
99
|
-
setCurrentView('explore');
|
|
100
|
-
} else if (e.key === '2' || key === 'g') {
|
|
101
|
-
setCurrentView('graph');
|
|
102
|
-
} else if (key === 't') {
|
|
103
|
-
toggleTheme();
|
|
104
|
-
} else if (e.key === 'Escape') {
|
|
105
|
-
// Alt + arrows move through browsing history.
|
|
106
|
-
if (e.altKey && e.key === 'ArrowLeft') {
|
|
107
|
-
e.preventDefault();
|
|
108
|
-
moveHistory(-1);
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
if (e.altKey && e.key === 'ArrowRight') {
|
|
112
|
-
e.preventDefault();
|
|
113
|
-
moveHistory(1);
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
// Return to root or parent
|
|
117
|
-
const curr = graph.nodes.get(currentNodeId);
|
|
118
|
-
if (curr && curr.parent) {
|
|
119
|
-
navigateToNode(curr.parent);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
window.addEventListener('keydown', handleKeyDown);
|
|
125
|
-
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
126
|
-
}, [graph, currentNodeId, historyIndex, history]);
|
|
127
|
-
|
|
128
|
-
useEffect(() => {
|
|
129
|
-
const handlePopState = () => {
|
|
130
|
-
const nodeId = new URLSearchParams(window.location.hash.replace(/^#/, '')).get('node');
|
|
131
|
-
if (!nodeId || !graph.nodes.has(nodeId)) return;
|
|
132
|
-
setCurrentNodeId(nodeId);
|
|
133
|
-
setHistory(previous => {
|
|
134
|
-
const index = previous.lastIndexOf(nodeId);
|
|
135
|
-
if (index >= 0) {
|
|
136
|
-
setHistoryIndex(index);
|
|
137
|
-
return previous;
|
|
138
|
-
}
|
|
139
|
-
const next = [...previous, nodeId];
|
|
140
|
-
setHistoryIndex(next.length - 1);
|
|
141
|
-
return next;
|
|
142
|
-
});
|
|
143
|
-
};
|
|
144
|
-
window.addEventListener('popstate', handlePopState);
|
|
145
|
-
return () => window.removeEventListener('popstate', handlePopState);
|
|
146
|
-
}, [graph]);
|
|
147
|
-
|
|
148
|
-
return (
|
|
149
|
-
<div className="app-container">
|
|
150
|
-
{/* Main workspace */}
|
|
151
|
-
<main className="app-main">
|
|
152
|
-
<div className="app-toolbar">
|
|
153
|
-
<div className="view-tabs">
|
|
154
|
-
<button
|
|
155
|
-
className={`view-tab ${currentView === 'explore' ? 'active' : ''}`}
|
|
156
|
-
onClick={() => setCurrentView('explore')}
|
|
157
|
-
title="切换到节点探索视图 (按快捷键 E / 1)"
|
|
158
|
-
>
|
|
159
|
-
<Compass size={15} />
|
|
160
|
-
<span>概念探索</span>
|
|
161
|
-
</button>
|
|
162
|
-
<button
|
|
163
|
-
className={`view-tab ${currentView === 'graph' ? 'active' : ''}`}
|
|
164
|
-
onClick={() => setCurrentView('graph')}
|
|
165
|
-
title="切换到全局拓扑图谱 (按快捷键 G / 2)"
|
|
166
|
-
>
|
|
167
|
-
<Network size={15} />
|
|
168
|
-
<span>知识网络</span>
|
|
169
|
-
</button>
|
|
170
|
-
</div>
|
|
171
|
-
|
|
172
|
-
<div className="global-search">
|
|
173
|
-
<Search size={14} />
|
|
174
|
-
<input
|
|
175
|
-
value={globalQuery}
|
|
176
|
-
onChange={event => setGlobalQuery(event.target.value)}
|
|
177
|
-
onKeyDown={event => { if (event.key === 'Escape') setGlobalQuery(''); }}
|
|
178
|
-
placeholder="搜索所有节点…"
|
|
179
|
-
aria-label="搜索所有节点"
|
|
180
|
-
/>
|
|
181
|
-
{globalQuery && <button type="button" onClick={() => setGlobalQuery('')} aria-label="清除搜索"><X size={13} /></button>}
|
|
182
|
-
{searchResults.length > 0 && (
|
|
183
|
-
<div className="global-search-results" role="listbox">
|
|
184
|
-
{searchResults.map(node => (
|
|
185
|
-
<button type="button" key={node.id} onClick={() => { navigateToNode(node.id); setGlobalQuery(''); }} role="option">
|
|
186
|
-
<span>{node.title}</span><small>{node.level} · {node.summary || node.id}</small>
|
|
187
|
-
</button>
|
|
188
|
-
))}
|
|
189
|
-
</div>
|
|
190
|
-
)}
|
|
191
|
-
</div>
|
|
192
|
-
|
|
193
|
-
<button
|
|
194
|
-
className="theme-toggle-btn"
|
|
195
|
-
onClick={toggleTheme}
|
|
196
|
-
title={`切换主题 (当前: ${theme === 'dark' ? '暗色' : '亮色'},快捷键 T)`}
|
|
197
|
-
aria-label="切换主题"
|
|
198
|
-
>
|
|
199
|
-
{theme === 'dark' ? <Sun size={17} /> : <Moon size={17} />}
|
|
200
|
-
</button>
|
|
201
|
-
<button
|
|
202
|
-
className="theme-toggle-btn"
|
|
203
|
-
onClick={async () => {
|
|
204
|
-
try {
|
|
205
|
-
await navigator.clipboard.writeText(window.location.href);
|
|
206
|
-
setLinkCopied(true);
|
|
207
|
-
window.setTimeout(() => setLinkCopied(false), 1400);
|
|
208
|
-
} catch {
|
|
209
|
-
setLinkCopied(false);
|
|
210
|
-
}
|
|
211
|
-
}}
|
|
212
|
-
title={linkCopied ? '已复制当前节点链接' : '复制当前节点链接'}
|
|
213
|
-
aria-label={linkCopied ? '已复制当前节点链接' : '复制当前节点链接'}
|
|
214
|
-
>
|
|
215
|
-
<LinkIcon size={16} />
|
|
216
|
-
</button>
|
|
217
|
-
</div>
|
|
218
|
-
{currentView === 'explore' ? (
|
|
219
|
-
<NodeExplorer
|
|
220
|
-
graph={graph}
|
|
221
|
-
currentNodeId={currentNodeId}
|
|
222
|
-
onSelectNode={navigateToNode}
|
|
223
|
-
onSwitchView={setCurrentView}
|
|
224
|
-
selectedLevel={selectedLevel}
|
|
225
|
-
onSelectLevel={setSelectedLevel}
|
|
226
|
-
/>
|
|
227
|
-
) : (
|
|
228
|
-
<RelationGraph
|
|
229
|
-
graph={graph}
|
|
230
|
-
currentNodeId={currentNodeId}
|
|
231
|
-
onSelectNode={navigateToNode}
|
|
232
|
-
onSwitchView={setCurrentView}
|
|
233
|
-
theme={theme}
|
|
234
|
-
/>
|
|
235
|
-
)}
|
|
236
|
-
</main>
|
|
237
|
-
</div>
|
|
238
|
-
);
|
|
239
|
-
}
|
|
1
|
+
import React, { useState, useEffect, useMemo } from 'react';
|
|
2
|
+
import { Network, Compass, Sun, Moon, Search, X, Link as LinkIcon } from 'lucide-react';
|
|
3
|
+
import { buildGraphModel } from '../model/concept-schema.js';
|
|
4
|
+
import { extractConceptData } from '../model/normalize-content.js';
|
|
5
|
+
import { NodeExplorer } from '../views/NodeExplorer.jsx';
|
|
6
|
+
import { RelationGraph } from '../views/RelationGraph.jsx';
|
|
7
|
+
import '../styles/concept-explain.css';
|
|
8
|
+
|
|
9
|
+
export function App({ mdxContent, initialData }) {
|
|
10
|
+
// Extract graph model from MDX JSX Element or raw data
|
|
11
|
+
const [graph] = useState(() => {
|
|
12
|
+
if (initialData) {
|
|
13
|
+
return buildGraphModel(initialData);
|
|
14
|
+
}
|
|
15
|
+
if (mdxContent) {
|
|
16
|
+
const extracted = extractConceptData(mdxContent);
|
|
17
|
+
return buildGraphModel(extracted);
|
|
18
|
+
}
|
|
19
|
+
return buildGraphModel({ meta: {}, nodes: [], relations: [] });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Theme state ('light' | 'dark')
|
|
23
|
+
const [theme, setTheme] = useState(() => {
|
|
24
|
+
return localStorage.getItem('concept_atlas_theme') || 'dark';
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
document.documentElement.setAttribute('data-theme', theme);
|
|
29
|
+
localStorage.setItem('concept_atlas_theme', theme);
|
|
30
|
+
}, [theme]);
|
|
31
|
+
|
|
32
|
+
const toggleTheme = () => {
|
|
33
|
+
setTheme(prev => (prev === 'dark' ? 'light' : 'dark'));
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Global shared state
|
|
37
|
+
const [currentView, setCurrentView] = useState('explore'); // 'explore' | 'graph'
|
|
38
|
+
const [currentNodeId, setCurrentNodeId] = useState(() => {
|
|
39
|
+
const hashNode = new URLSearchParams(window.location.hash.replace(/^#/, '')).get('node');
|
|
40
|
+
return hashNode && graph.nodes.has(hashNode) ? hashNode : (graph.meta.rootId || '');
|
|
41
|
+
});
|
|
42
|
+
const [selectedLevel, setSelectedLevel] = useState(null);
|
|
43
|
+
const [history, setHistory] = useState(() => {
|
|
44
|
+
const initial = new URLSearchParams(window.location.hash.replace(/^#/, '')).get('node');
|
|
45
|
+
return initial && graph.nodes.has(initial) ? [initial] : [graph.meta.rootId].filter(Boolean);
|
|
46
|
+
});
|
|
47
|
+
const [historyIndex, setHistoryIndex] = useState(0);
|
|
48
|
+
const [globalQuery, setGlobalQuery] = useState('');
|
|
49
|
+
const [linkCopied, setLinkCopied] = useState(false);
|
|
50
|
+
|
|
51
|
+
const searchResults = useMemo(() => {
|
|
52
|
+
const query = globalQuery.trim().toLowerCase();
|
|
53
|
+
if (!query) return [];
|
|
54
|
+
return Array.from(graph.nodes.values()).map(node => {
|
|
55
|
+
const searchable = [
|
|
56
|
+
node.title, node.id, node.summary, node.definition, node.overview,
|
|
57
|
+
node.mechanism, node.input, node.output,
|
|
58
|
+
...(node.examples || []).flatMap(item => [item.title, item.content]),
|
|
59
|
+
...(node.glossary || []).flatMap(item => [item.term, item.definition]),
|
|
60
|
+
...(node.boundaries || []).flatMap(item => [item.title, item.content]),
|
|
61
|
+
].filter(value => typeof value === 'string').join(' ').toLowerCase();
|
|
62
|
+
return searchable.includes(query) ? node : null;
|
|
63
|
+
}).filter(Boolean).slice(0, 8);
|
|
64
|
+
}, [globalQuery, graph.nodes]);
|
|
65
|
+
|
|
66
|
+
const navigateToNode = (nodeId, { replace = false } = {}) => {
|
|
67
|
+
if (!nodeId || !graph.nodes.has(nodeId)) return;
|
|
68
|
+
setCurrentNodeId(nodeId);
|
|
69
|
+
setHistory(previous => {
|
|
70
|
+
const base = previous.slice(0, historyIndex + 1);
|
|
71
|
+
if (base[base.length - 1] === nodeId) return previous;
|
|
72
|
+
const next = [...base, nodeId];
|
|
73
|
+
setHistoryIndex(next.length - 1);
|
|
74
|
+
return next;
|
|
75
|
+
});
|
|
76
|
+
const nextHash = `#node=${encodeURIComponent(nodeId)}`;
|
|
77
|
+
if (replace) window.history.replaceState({}, '', nextHash);
|
|
78
|
+
else window.history.pushState({}, '', nextHash);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const moveHistory = (direction) => {
|
|
82
|
+
const nextIndex = Math.max(0, Math.min(history.length - 1, historyIndex + direction));
|
|
83
|
+
if (nextIndex === historyIndex) return;
|
|
84
|
+
setHistoryIndex(nextIndex);
|
|
85
|
+
const nodeId = history[nextIndex];
|
|
86
|
+
setCurrentNodeId(nodeId);
|
|
87
|
+
window.history.pushState({}, '', `#node=${encodeURIComponent(nodeId)}`);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Keyboard navigation shortcuts
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
const handleKeyDown = (e) => {
|
|
93
|
+
// Toggle views with 1 and 2 or 'g' and 'e' if not focused on input
|
|
94
|
+
if (e.isComposing || e.metaKey || e.ctrlKey || e.altKey || ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName) || document.activeElement?.isContentEditable) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const key = e.key.toLowerCase();
|
|
98
|
+
if (e.key === '1' || key === 'e') {
|
|
99
|
+
setCurrentView('explore');
|
|
100
|
+
} else if (e.key === '2' || key === 'g') {
|
|
101
|
+
setCurrentView('graph');
|
|
102
|
+
} else if (key === 't') {
|
|
103
|
+
toggleTheme();
|
|
104
|
+
} else if (e.key === 'Escape') {
|
|
105
|
+
// Alt + arrows move through browsing history.
|
|
106
|
+
if (e.altKey && e.key === 'ArrowLeft') {
|
|
107
|
+
e.preventDefault();
|
|
108
|
+
moveHistory(-1);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (e.altKey && e.key === 'ArrowRight') {
|
|
112
|
+
e.preventDefault();
|
|
113
|
+
moveHistory(1);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
// Return to root or parent
|
|
117
|
+
const curr = graph.nodes.get(currentNodeId);
|
|
118
|
+
if (curr && curr.parent) {
|
|
119
|
+
navigateToNode(curr.parent);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
125
|
+
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
126
|
+
}, [graph, currentNodeId, historyIndex, history]);
|
|
127
|
+
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
const handlePopState = () => {
|
|
130
|
+
const nodeId = new URLSearchParams(window.location.hash.replace(/^#/, '')).get('node');
|
|
131
|
+
if (!nodeId || !graph.nodes.has(nodeId)) return;
|
|
132
|
+
setCurrentNodeId(nodeId);
|
|
133
|
+
setHistory(previous => {
|
|
134
|
+
const index = previous.lastIndexOf(nodeId);
|
|
135
|
+
if (index >= 0) {
|
|
136
|
+
setHistoryIndex(index);
|
|
137
|
+
return previous;
|
|
138
|
+
}
|
|
139
|
+
const next = [...previous, nodeId];
|
|
140
|
+
setHistoryIndex(next.length - 1);
|
|
141
|
+
return next;
|
|
142
|
+
});
|
|
143
|
+
};
|
|
144
|
+
window.addEventListener('popstate', handlePopState);
|
|
145
|
+
return () => window.removeEventListener('popstate', handlePopState);
|
|
146
|
+
}, [graph]);
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<div className="app-container">
|
|
150
|
+
{/* Main workspace */}
|
|
151
|
+
<main className="app-main">
|
|
152
|
+
<div className="app-toolbar">
|
|
153
|
+
<div className="view-tabs">
|
|
154
|
+
<button
|
|
155
|
+
className={`view-tab ${currentView === 'explore' ? 'active' : ''}`}
|
|
156
|
+
onClick={() => setCurrentView('explore')}
|
|
157
|
+
title="切换到节点探索视图 (按快捷键 E / 1)"
|
|
158
|
+
>
|
|
159
|
+
<Compass size={15} />
|
|
160
|
+
<span>概念探索</span>
|
|
161
|
+
</button>
|
|
162
|
+
<button
|
|
163
|
+
className={`view-tab ${currentView === 'graph' ? 'active' : ''}`}
|
|
164
|
+
onClick={() => setCurrentView('graph')}
|
|
165
|
+
title="切换到全局拓扑图谱 (按快捷键 G / 2)"
|
|
166
|
+
>
|
|
167
|
+
<Network size={15} />
|
|
168
|
+
<span>知识网络</span>
|
|
169
|
+
</button>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<div className="global-search">
|
|
173
|
+
<Search size={14} />
|
|
174
|
+
<input
|
|
175
|
+
value={globalQuery}
|
|
176
|
+
onChange={event => setGlobalQuery(event.target.value)}
|
|
177
|
+
onKeyDown={event => { if (event.key === 'Escape') setGlobalQuery(''); }}
|
|
178
|
+
placeholder="搜索所有节点…"
|
|
179
|
+
aria-label="搜索所有节点"
|
|
180
|
+
/>
|
|
181
|
+
{globalQuery && <button type="button" onClick={() => setGlobalQuery('')} aria-label="清除搜索"><X size={13} /></button>}
|
|
182
|
+
{searchResults.length > 0 && (
|
|
183
|
+
<div className="global-search-results" role="listbox">
|
|
184
|
+
{searchResults.map(node => (
|
|
185
|
+
<button type="button" key={node.id} onClick={() => { navigateToNode(node.id); setGlobalQuery(''); }} role="option">
|
|
186
|
+
<span>{node.title}</span><small>{node.level} · {node.summary || node.id}</small>
|
|
187
|
+
</button>
|
|
188
|
+
))}
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<button
|
|
194
|
+
className="theme-toggle-btn"
|
|
195
|
+
onClick={toggleTheme}
|
|
196
|
+
title={`切换主题 (当前: ${theme === 'dark' ? '暗色' : '亮色'},快捷键 T)`}
|
|
197
|
+
aria-label="切换主题"
|
|
198
|
+
>
|
|
199
|
+
{theme === 'dark' ? <Sun size={17} /> : <Moon size={17} />}
|
|
200
|
+
</button>
|
|
201
|
+
<button
|
|
202
|
+
className="theme-toggle-btn"
|
|
203
|
+
onClick={async () => {
|
|
204
|
+
try {
|
|
205
|
+
await navigator.clipboard.writeText(window.location.href);
|
|
206
|
+
setLinkCopied(true);
|
|
207
|
+
window.setTimeout(() => setLinkCopied(false), 1400);
|
|
208
|
+
} catch {
|
|
209
|
+
setLinkCopied(false);
|
|
210
|
+
}
|
|
211
|
+
}}
|
|
212
|
+
title={linkCopied ? '已复制当前节点链接' : '复制当前节点链接'}
|
|
213
|
+
aria-label={linkCopied ? '已复制当前节点链接' : '复制当前节点链接'}
|
|
214
|
+
>
|
|
215
|
+
<LinkIcon size={16} />
|
|
216
|
+
</button>
|
|
217
|
+
</div>
|
|
218
|
+
{currentView === 'explore' ? (
|
|
219
|
+
<NodeExplorer
|
|
220
|
+
graph={graph}
|
|
221
|
+
currentNodeId={currentNodeId}
|
|
222
|
+
onSelectNode={navigateToNode}
|
|
223
|
+
onSwitchView={setCurrentView}
|
|
224
|
+
selectedLevel={selectedLevel}
|
|
225
|
+
onSelectLevel={setSelectedLevel}
|
|
226
|
+
/>
|
|
227
|
+
) : (
|
|
228
|
+
<RelationGraph
|
|
229
|
+
graph={graph}
|
|
230
|
+
currentNodeId={currentNodeId}
|
|
231
|
+
onSelectNode={navigateToNode}
|
|
232
|
+
onSwitchView={setCurrentView}
|
|
233
|
+
theme={theme}
|
|
234
|
+
/>
|
|
235
|
+
)}
|
|
236
|
+
</main>
|
|
237
|
+
</div>
|
|
238
|
+
);
|
|
239
|
+
}
|