Personal-Tech-Tree / index.html
OctopusFlying's picture
Upload 4 files
2963e60 verified
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>个人科技树 V6.0</title>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/vis-network@9.1.2/standalone/umd/vis-network.min.js"></script>
<style>
body { font-family: 'Segoe UI', sans-serif; background-color: #0d0d12; color: #fff; margin: 0; display: flex; height: 100vh; overflow: hidden; }
.sidebar { width: 340px; background: #15151e; border-right: 2px solid #2a2a35; display: flex; flex-direction: column; padding: 16px; box-sizing: border-box; z-index: 100; box-shadow: 5px 0 15px rgba(0,0,0,0.5); overflow-y: auto; }
.logo { font-size: 18px; font-weight: bold; color: #00d2ff; margin-bottom: 16px; border-bottom: 1px solid #333; padding-bottom: 8px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.section { margin-bottom: 18px; }
.section-title { font-size: 11px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; display: block; }
textarea, .cfg-input { width: 100%; background-color: #050508; color: #e0e0e0; border: 1px solid #333; border-radius: 6px; padding: 8px; font-size: 12px; box-sizing: border-box; }
textarea { height: 72px; resize: vertical; margin-bottom: 8px; }
.cfg-input { margin-bottom: 8px; padding: 8px; }
.cfg-label { font-size: 11px; color: #888; display: block; margin-bottom: 4px; }
.search-row { display: flex; gap: 6px; align-items: center; margin-bottom: 8px; }
.search-box { flex: 1; padding: 8px; background: #000; border: 1px solid #444; color: #fff; border-radius: 4px; font-size: 13px; box-sizing: border-box; }
.btn-group { display: flex; flex-direction: column; gap: 6px; }
button { width: 100%; padding: 9px; border: none; border-radius: 6px; font-weight: bold; cursor: pointer; transition: 0.2s; font-size: 12px; text-align: left; padding-left: 12px; }
button.inline { width: auto; padding: 8px 10px; flex-shrink: 0; }
button.primary { background: #00d2ff; color: #000; }
button.success { background: #00e676; color: #000; }
button.warning { background: #fac858; color: #000; }
button.danger { background: #ff4757; color: #fff; }
button.secondary { background: #2a2a35; color: #ddd; }
button:hover { opacity: 0.85; transform: translateX(3px); }
details.api-details { background: #0a0a10; border: 1px solid #2a2a35; border-radius: 8px; padding: 8px 10px; margin-bottom: 10px; }
details.api-details summary { cursor: pointer; color: #00d2ff; font-size: 12px; font-weight: bold; }
#main-stage { flex: 1; position: relative; min-width: 0; }
#network-canvas { width: 100%; height: 100%; background-color: #0d0d12; background-image: radial-gradient(#222 1px, transparent 1px); background-size: 40px 40px; }
.stats-panel { margin-top: auto; padding: 12px; background: #050508; border-radius: 8px; border: 1px solid #222; flex-shrink: 0; }
.stat-item { font-size: 11px; color: #aaa; margin-bottom: 4px; display: flex; justify-content: space-between; }
#context-menu { display: none; position: absolute; background: rgba(20,20,25,0.95); border: 1px solid #00d2ff; border-radius: 8px; z-index: 1000; padding: 5px 0; min-width: 180px; box-shadow: 0 0 20px rgba(0,210,255,0.3); }
.menu-item { padding: 9px 14px; font-size: 12px; cursor: pointer; color: #ddd; }
.menu-item:hover { background: #00d2ff; color: #000; }
#edit-modal, #import-modal { display: none; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: #15151e; border: 2px solid #00d2ff; padding: 24px; border-radius: 12px; z-index: 101; min-width: 320px; max-width: 90vw; box-shadow: 0 0 50px rgba(0,0,0,0.8); }
#overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 100; }
.hint { font-size: 10px; color: #555; margin-top: 4px; line-height: 1.4; }
#toast { position: fixed; bottom: 20px; right: 20px; max-width: 360px; padding: 12px 16px; background: #1a1a24; border: 1px solid #444; border-radius: 8px; font-size: 13px; z-index: 2000; display: none; box-shadow: 0 8px 24px rgba(0,0,0,0.5); }
#toast.err { border-color: #ff4757; color: #ffb4b4; }
#toast.ok { border-color: #00e676; color: #b8f5d0; }
</style>
</head>
<body>
<div class="sidebar">
<div class="logo">🚀 TECH TREE <span style="font-size: 10px; background: #333; padding: 2px 6px; border-radius: 3px;">V6.0</span></div>
<details class="api-details" open>
<summary>⚙️ 连接与模型配置(保存在本地浏览器)</summary>
<label class="cfg-label" for="cfgBackend">后端地址(本机 API)</label>
<input type="text" class="cfg-input" id="cfgBackend" placeholder="http://127.0.0.1:8000" autocomplete="off">
<label class="cfg-label" for="cfgLlmBase">LLM Base URL(OpenAI 兼容)</label>
<input type="text" class="cfg-input" id="cfgLlmBase" placeholder="https://api.example.com/v1" autocomplete="off">
<label class="cfg-label" for="cfgApiKey">API Key</label>
<input type="password" class="cfg-input" id="cfgApiKey" placeholder="sk-..." autocomplete="off">
<label class="cfg-label" for="cfgModelGen">生成树所用模型</label>
<input type="text" class="cfg-input" id="cfgModelGen" placeholder="例如 DeepSeek-V3.2">
<label class="cfg-label" for="cfgModelExpand">拓展分支所用模型</label>
<input type="text" class="cfg-input" id="cfgModelExpand" placeholder="可与上相同或另选">
<button type="button" class="secondary" onclick="saveApiConfig()">💾 保存配置到本地</button>
<p class="hint">API Key 仅存于本机 localStorage;请勿在公共电脑上保存敏感 Key。也可直接运行 <code>python main.py</code> 后打开本页并访问同一后端。</p>
</details>
<div class="section">
<span class="section-title">🔍 快速定位</span>
<div class="search-row">
<input type="search" class="search-box" id="nodeSearch" placeholder="搜索技能名称..." aria-label="搜索技能" oninput="searchNode(false)">
<button type="button" class="secondary inline" onclick="searchNode(true)" title="下一个匹配">下一个</button>
</div>
</div>
<div class="section">
<span class="section-title">🧠 AI 助手</span>
<textarea id="userInput" placeholder="输入你学过的课程、技能等生成个人科技树..."></textarea>
<button type="button" class="primary" onclick="generateTree()">✨ 重新生成</button>
<div id="loading" style="display:none; font-size:11px; color:#00d2ff; margin-top:6px;">正在连接大模型...</div>
</div>
<div class="section">
<span class="section-title">🛠️ 编辑工具</span>
<div class="btn-group">
<button type="button" class="success" onclick="addNewNode()">➕ 新增技能点</button>
<button type="button" class="warning" onclick="enableDrawEdge()">🔗 手动建立连接</button>
<button type="button" class="secondary" onclick="runPhysicsLayout()">🌐 力导向整理(约 2.5 秒)</button>
<button type="button" class="secondary" onclick="undo()" title="Ctrl+Z">↩️ 撤销</button>
<button type="button" class="secondary" onclick="redo()" title="Ctrl+Y">↪️ 重做</button>
<button type="button" class="secondary" onclick="exportJson()">📄 导出 JSON</button>
<button type="button" class="secondary" onclick="openImportModal()">📥 导入 JSON</button>
<button type="button" class="secondary" onclick="exportToImage()">📸 导出高清 PNG</button>
<button type="button" class="danger" onclick="clearAll()">🗑️ 清空当前画布</button>
</div>
</div>
<div class="stats-panel">
<span class="section-title">📊 状态看板</span>
<div class="stat-item"><span>总技能数</span> <b id="stat-total">0</b></div>
<div class="stat-item"><span>边数</span> <b id="stat-edges">0</b></div>
<div class="stat-item"><span>叶子节点</span> <b id="stat-leaves">0</b></div>
<div class="stat-item"><span>连通分量</span> <b id="stat-components">0</b></div>
<div class="stat-item"><span>已点亮 (&gt;80%)</span> <b id="stat-mastered" style="color:#00e676;">0</b></div>
<div class="stat-item"><span>平均熟练度</span> <b id="stat-avg">0%</b></div>
</div>
<div id="save-status" style="font-size: 10px; color: #555; margin-top: 8px; text-align: center;">存档已同步到本地</div>
</div>
<div id="main-stage">
<div id="network-canvas"></div>
</div>
<div id="context-menu" role="menu" aria-label="节点菜单">
<div class="menu-item" role="menuitem" onclick="handleMenuExpand()">✨ AI 向下拓展分支</div>
<div class="menu-item" role="menuitem" onclick="handleMenuToggleCollapse()">📂 折叠 / 展开子项</div>
<div class="menu-item" role="menuitem" onclick="handleMenuEdit()">⚙️ 修改属性</div>
<div class="menu-item" style="color:#ff4757" role="menuitem" onclick="handleMenuDelete()">🗑️ 删除节点</div>
</div>
<div id="overlay" onclick="closeAllModals()"></div>
<div id="edit-modal" role="dialog" aria-modal="true" aria-labelledby="edit-title">
<h3 id="edit-title" style="margin-top:0">配置技能属性</h3>
<input type="hidden" id="edit-id">
<div style="margin-bottom:12px">
<label class="cfg-label" for="edit-name">技能名称</label>
<input type="text" id="edit-name" class="cfg-input" style="margin-bottom:0">
</div>
<div style="margin-bottom:12px">
<label class="cfg-label" for="edit-mastery">熟练度 (<span id="mastery-value" style="color:#00d2ff">0</span>%)</label>
<input type="range" id="edit-mastery" min="0" max="100" style="width:100%; margin-top:6px;" oninput="document.getElementById('mastery-value').innerText = this.value" aria-valuemin="0" aria-valuemax="100">
</div>
<div style="margin-bottom:12px">
<label class="cfg-label" for="edit-note">备注</label>
<textarea id="edit-note" rows="2" style="height:auto; margin-bottom:0"></textarea>
</div>
<div style="margin-bottom:12px">
<label class="cfg-label" for="edit-link">相关链接</label>
<input type="url" id="edit-link" class="cfg-input" style="margin-bottom:0" placeholder="https://">
</div>
<div style="text-align:right">
<button type="button" class="primary" onclick="saveNode()" style="width:auto; padding:8px 20px;">💾 确认保存</button>
</div>
</div>
<div id="import-modal" role="dialog" aria-modal="true" aria-labelledby="import-title">
<h3 id="import-title" style="margin-top:0">导入 JSON</h3>
<p class="hint" style="color:#888">将替换当前画布(可先导出备份)。支持旧版仅含 nodes/edges 的存档。</p>
<input type="file" id="import-file" accept="application/json,.json" style="margin-bottom:12px; color:#ccc;">
<div style="text-align:right; display:flex; gap:8px; justify-content:flex-end;">
<button type="button" class="secondary" onclick="closeImportModal()" style="width:auto;">取消</button>
<button type="button" class="primary" onclick="confirmImportJson()" style="width:auto;">导入</button>
</div>
</div>
<div id="toast" role="status"></div>
<script>
var nodesDataset, edgesDataset, network = null;
var activeContextNode = null;
var collapsedSubtrees = {};
var undoStack = [];
var redoStack = [];
var MAX_HISTORY = 40;
var searchMatches = [];
var searchMatchIdx = -1;
var toastTimer = null;
var API_CFG_KEY = 'techTree_api_cfg';
var DATA_KEY = 'techTree_data';
function toast(msg, isErr) {
var el = document.getElementById('toast');
el.textContent = msg;
el.className = isErr ? 'err' : 'ok';
el.style.display = 'block';
clearTimeout(toastTimer);
toastTimer = setTimeout(function() { el.style.display = 'none'; }, 4500);
}
function escapeXml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function nodeTitle(cd) {
var parts = [cd.name || ''];
if (cd.note) parts.push(cd.note);
if (cd.link) parts.push(cd.link);
return parts.join('\n');
}
function createNodeSvg(name, mastery) {
mastery = parseInt(mastery, 10) || 0;
var safe = escapeXml(name);
var width = 240, height = 100, barWidth = 200;
var progressWidth = (mastery / 100) * barWidth;
var color = (mastery >= 80) ? '#00d2ff' : (mastery >= 40 ? '#fac858' : '#555');
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + width + '" height="' + height + '">' +
'<rect x="5" y="5" width="230" height="90" rx="10" fill="#15151e" stroke="' + color + '" stroke-width="3"/>' +
'<text x="120" y="40" font-family="Segoe UI,Arial" font-size="16" font-weight="bold" fill="#fff" text-anchor="middle">' + safe + '</text>' +
'<rect x="20" y="70" width="' + barWidth + '" height="10" rx="5" fill="#2a2a35"/>' +
'<rect x="20" y="70" width="' + progressWidth + '" height="10" rx="5" fill="' + color + '"/>' +
'<text x="120" y="62" font-family="Segoe UI,Arial" font-size="12" fill="#888" text-anchor="middle">' + mastery + '%</text>' +
'</svg>';
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
}
function normalizeCustomData(cd) {
cd = cd || {};
var m = parseInt(cd.mastery, 10);
if (isNaN(m)) m = 0;
return {
name: cd.name != null ? String(cd.name) : '未命名',
mastery: m,
note: cd.note != null ? String(cd.note) : '',
link: cd.link != null ? String(cd.link) : ''
};
}
function buildVisNode(raw) {
var cd = normalizeCustomData(raw.customData || raw);
var x = raw.x != null ? raw.x : 0;
var y = raw.y != null ? raw.y : 0;
return {
id: raw.id,
shape: 'image',
image: raw.image || createNodeSvg(cd.name, cd.mastery),
customData: cd,
x: x,
y: y,
title: nodeTitle(cd)
};
}
function serializeNodeForSave(n) {
var x = n.x, y = n.y;
try {
if (network) {
var p = network.getPosition(n.id);
x = p.x;
y = p.y;
}
} catch (e) {}
return {
id: n.id,
x: x,
y: y,
customData: normalizeCustomData(n.customData)
};
}
function getSnapshot() {
return {
version: 2,
nodes: nodesDataset.get().map(serializeNodeForSave),
edges: edgesDataset.get().map(function(e) {
return { id: e.id, from: e.from, to: e.to, arrows: e.arrows || 'to' };
}),
collapsed: JSON.parse(JSON.stringify(collapsedSubtrees))
};
}
function applySnapshot(snap) {
nodesDataset.clear();
edgesDataset.clear();
collapsedSubtrees = snap.collapsed && typeof snap.collapsed === 'object' ? snap.collapsed : {};
(snap.nodes || []).forEach(function(raw) {
nodesDataset.add(buildVisNode(raw));
});
(snap.edges || []).forEach(function(e) {
edgesDataset.add({ from: e.from, to: e.to, arrows: e.arrows || 'to' });
});
updateStats();
}
function pushHistory() {
if (!nodesDataset) return;
undoStack.push(getSnapshot());
if (undoStack.length > MAX_HISTORY) undoStack.shift();
redoStack = [];
}
function undo() {
if (!undoStack.length) return;
redoStack.push(getSnapshot());
applySnapshot(undoStack.pop());
autoSave();
}
function redo() {
if (!redoStack.length) return;
undoStack.push(getSnapshot());
applySnapshot(redoStack.pop());
autoSave();
}
function apiBase() {
var b = document.getElementById('cfgBackend').value.trim().replace(/\/$/, '');
return b || 'http://127.0.0.1:8000';
}
function readLlmConfig() {
return {
api_key: document.getElementById('cfgApiKey').value.trim(),
base_url: document.getElementById('cfgLlmBase').value.trim(),
model_gen: document.getElementById('cfgModelGen').value.trim(),
model_expand: document.getElementById('cfgModelExpand').value.trim()
};
}
function saveApiConfig() {
var o = {
backend: document.getElementById('cfgBackend').value.trim(),
llmBase: document.getElementById('cfgLlmBase').value.trim(),
apiKey: document.getElementById('cfgApiKey').value,
modelGen: document.getElementById('cfgModelGen').value.trim(),
modelExpand: document.getElementById('cfgModelExpand').value.trim()
};
localStorage.setItem(API_CFG_KEY, JSON.stringify(o));
toast('配置已保存到本地', false);
}
function loadApiConfig() {
try {
var raw = localStorage.getItem(API_CFG_KEY);
if (!raw) return;
var o = JSON.parse(raw);
if (o.backend) document.getElementById('cfgBackend').value = o.backend;
if (o.llmBase) document.getElementById('cfgLlmBase').value = o.llmBase;
if (o.apiKey) document.getElementById('cfgApiKey').value = o.apiKey;
if (o.modelGen) document.getElementById('cfgModelGen').value = o.modelGen;
if (o.modelExpand) document.getElementById('cfgModelExpand').value = o.modelExpand;
} catch (e) {}
}
function parseApiError(res, result) {
var msg = (result && result.message) ? result.message : '';
if (!msg && result && result.detail) {
if (typeof result.detail === 'string') msg = result.detail;
else if (Array.isArray(result.detail))
msg = result.detail.map(function(x) { return (x.msg || '') + (x.loc ? ' @' + x.loc.join('.') : ''); }).join('; ');
else if (result.detail.message) msg = result.detail.message;
else msg = JSON.stringify(result.detail);
}
if (!msg) msg = res.statusText || ('HTTP ' + res.status);
return msg;
}
function initNetwork() {
var container = document.getElementById('network-canvas');
var options = {
physics: { enabled: false },
edges: { smooth: { type: 'cubicBezier', forceDirection: 'horizontal' }, color: '#444', arrows: 'to', width: 2 },
interaction: { hover: true, dragNodes: true },
manipulation: {
enabled: false,
addEdge: function(d, c) {
pushHistory();
d.arrows = 'to';
edgesDataset.add(d);
c(null);
container.style.cursor = 'default';
}
}
};
network = new vis.Network(container, { nodes: nodesDataset, edges: edgesDataset }, options);
network.on('oncontext', function(p) {
p.event.preventDefault();
var nodeId = network.getNodeAt(p.pointer.DOM);
if (nodeId) {
activeContextNode = nodeId;
var menu = document.getElementById('context-menu');
menu.style.display = 'block';
menu.style.left = p.event.clientX + 'px';
menu.style.top = p.event.clientY + 'px';
}
});
network.on('click', function() {
document.getElementById('context-menu').style.display = 'none';
});
}
function searchNode(advance) {
var input = document.getElementById('nodeSearch');
var term = (input.value || '').trim().toLowerCase();
if (!term) {
searchMatches = [];
searchMatchIdx = -1;
return;
}
if (!advance || !searchMatches.length) {
searchMatches = nodesDataset.get({
filter: function(n) {
var name = (n.customData && n.customData.name) ? n.customData.name : '';
return name.toLowerCase().indexOf(term) !== -1;
}
});
searchMatchIdx = -1;
}
if (!searchMatches.length) {
toast('未找到匹配节点', true);
return;
}
searchMatchIdx = (searchMatchIdx + 1) % searchMatches.length;
var node = searchMatches[searchMatchIdx];
network.focus(node.id, { scale: 1.1, animation: { duration: 450, easingFunction: 'easeInOutQuad' } });
network.selectNodes([node.id]);
}
function countLeaves() {
var hasOut = {};
edgesDataset.get().forEach(function(e) { hasOut[e.from] = true; });
return nodesDataset.get().filter(function(n) { return !hasOut[n.id]; }).length;
}
function countComponents() {
var ids = nodesDataset.getIds();
var adj = {};
ids.forEach(function(id) { adj[id] = []; });
edgesDataset.get().forEach(function(e) {
adj[e.from].push(e.to);
adj[e.to].push(e.from);
});
var seen = {};
var c = 0;
ids.forEach(function(id) {
if (seen[id]) return;
c++;
var q = [id];
seen[id] = true;
for (var i = 0; i < q.length; i++) {
var u = q[i];
(adj[u] || []).forEach(function(v) {
if (!seen[v]) { seen[v] = true; q.push(v); }
});
}
});
return c;
}
function updateStats() {
var all = nodesDataset.get();
document.getElementById('stat-total').innerText = all.length;
document.getElementById('stat-edges').innerText = edgesDataset.length;
document.getElementById('stat-leaves').innerText = all.length ? String(countLeaves()) : '0';
document.getElementById('stat-components').innerText = all.length ? String(countComponents()) : '0';
var mastered = all.filter(function(n) { return (parseInt(n.customData.mastery, 10) || 0) >= 80; }).length;
document.getElementById('stat-mastered').innerText = mastered;
var avg = all.length ? Math.round(all.reduce(function(s, n) { return s + (parseInt(n.customData.mastery, 10) || 0); }, 0) / all.length) : 0;
document.getElementById('stat-avg').innerText = avg + '%';
}
function applyTreeFromAi(data) {
data.nodes.forEach(function(n, i) {
var cd = normalizeCustomData({ name: n.name, mastery: n.mastery });
nodesDataset.add({
id: n.id,
shape: 'image',
image: createNodeSvg(cd.name, cd.mastery),
customData: cd,
x: (i % 3) * 300,
y: Math.floor(i / 3) * 200,
title: nodeTitle(cd)
});
});
data.edges.forEach(function(e) {
edgesDataset.add({ from: e.source, to: e.target, arrows: 'to' });
});
}
async function generateTree() {
var text = document.getElementById('userInput').value.trim();
if (!text) return;
var cfg = readLlmConfig();
if (!cfg.api_key || !cfg.base_url || !cfg.model_gen) {
toast('请填写 API Key、LLM Base URL 与生成模型', true);
return;
}
document.getElementById('loading').style.display = 'block';
try {
var res = await fetch(apiBase() + '/generate_tree', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: text,
api_key: cfg.api_key,
base_url: cfg.base_url,
model: cfg.model_gen
})
});
var result = await res.json().catch(function() { return {}; });
if (!res.ok || result.status !== 'success') {
toast(parseApiError(res, result), true);
return;
}
pushHistory();
nodesDataset.clear();
edgesDataset.clear();
collapsedSubtrees = {};
applyTreeFromAi(result.data);
toast('生成成功', false);
} catch (e) {
toast('请求失败:' + (e.message || String(e)), true);
} finally {
document.getElementById('loading').style.display = 'none';
}
}
function uniquifyExpandPayload(data, parentId) {
var existing = {};
nodesDataset.getIds().forEach(function(id) { existing[id] = true; });
var idMap = {};
data.nodes.forEach(function(n) {
var oid = n.id;
var nid = oid;
if (existing[nid]) {
var k = 1;
do { nid = oid + '_' + k++; } while (existing[nid]);
}
existing[nid] = true;
if (nid !== oid) idMap[oid] = nid;
});
var nodesOut = data.nodes.map(function(n) {
return {
id: idMap[n.id] || n.id,
name: n.name,
mastery: n.mastery
};
});
var edgesOut = data.edges.map(function(e) {
var s = e.source === parentId ? parentId : (idMap[e.source] || e.source);
var t = idMap[e.target] || e.target;
return { source: s, target: t };
});
return { nodes: nodesOut, edges: edgesOut };
}
async function handleMenuExpand() {
document.getElementById('context-menu').style.display = 'none';
if (!activeContextNode) return;
var cfg = readLlmConfig();
if (!cfg.api_key || !cfg.base_url || !cfg.model_expand) {
toast('请填写 API Key、LLM Base URL 与拓展模型', true);
return;
}
var node = nodesDataset.get(activeContextNode);
var name = node.customData.name;
document.getElementById('loading').style.display = 'block';
try {
var res = await fetch(apiBase() + '/expand_node', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
node_id: String(activeContextNode),
node_name: name,
api_key: cfg.api_key,
base_url: cfg.base_url,
model: cfg.model_expand
})
});
var result = await res.json().catch(function() { return {}; });
if (!res.ok || result.status !== 'success') {
toast(parseApiError(res, result), true);
return;
}
var merged = uniquifyExpandPayload(result.data, String(activeContextNode));
pushHistory();
var pos = network.getPosition(activeContextNode);
merged.nodes.forEach(function(n, i) {
var cd = normalizeCustomData({ name: n.name, mastery: n.mastery });
nodesDataset.add({
id: n.id,
shape: 'image',
image: createNodeSvg(cd.name, cd.mastery),
customData: cd,
x: pos.x + 280 + (i % 3) * 30,
y: pos.y + (i - 1) * 130,
title: nodeTitle(cd)
});
});
merged.edges.forEach(function(e) {
edgesDataset.add({ from: e.source, to: e.target, arrows: 'to' });
});
toast('已拓展分支', false);
} catch (e) {
toast('拓展失败:' + (e.message || String(e)), true);
} finally {
document.getElementById('loading').style.display = 'none';
}
}
function getDescendantsToCollapse(rootId) {
var edges = edgesDataset.get();
var out = {};
edges.forEach(function(e) {
if (!out[e.from]) out[e.from] = [];
out[e.from].push(e.to);
});
var reachable = {};
var q = [rootId];
reachable[rootId] = true;
for (var i = 0; i < q.length; i++) {
var u = q[i];
(out[u] || []).forEach(function(v) {
if (!reachable[v]) { reachable[v] = true; q.push(v); }
});
}
var pred = {};
edges.forEach(function(e) {
if (!pred[e.to]) pred[e.to] = [];
pred[e.to].push(e.from);
});
var toRemove = {};
Object.keys(reachable).forEach(function(v) {
if (v === rootId) return;
var ps = pred[v] || [];
var ok = ps.every(function(p) { return reachable[p]; });
if (ok) toRemove[v] = true;
});
return Object.keys(toRemove);
}
function handleMenuToggleCollapse() {
document.getElementById('context-menu').style.display = 'none';
if (!activeContextNode) return;
var rid = activeContextNode;
if (collapsedSubtrees[rid]) {
pushHistory();
var pack = collapsedSubtrees[rid];
delete collapsedSubtrees[rid];
pack.nodes.forEach(function(raw) { nodesDataset.add(buildVisNode(raw)); });
pack.edges.forEach(function(e) { edgesDataset.add({ from: e.from, to: e.to, arrows: 'to' }); });
toast('已展开子项', false);
return;
}
var removeIds = getDescendantsToCollapse(rid);
if (!removeIds.length) {
toast('没有可折叠的后继子图(或存在外部父节点指向的共享节点)', true);
return;
}
var removeSet = {};
removeIds.forEach(function(id) { removeSet[id] = true; });
var nodesToStore = [];
var edgesToStore = [];
removeIds.forEach(function(id) {
nodesToStore.push(serializeNodeForSave(nodesDataset.get(id)));
});
edgesDataset.get().forEach(function(e) {
if (removeSet[e.from] || removeSet[e.to]) {
edgesToStore.push({ id: e.id, from: e.from, to: e.to, arrows: e.arrows || 'to' });
}
});
pushHistory();
var edgeIds = edgesDataset.get({
filter: function(e) { return removeSet[e.from] || removeSet[e.to]; }
});
edgeIds.forEach(function(e) { edgesDataset.remove(e.id); });
removeIds.forEach(function(id) { nodesDataset.remove(id); });
collapsedSubtrees[rid] = { nodes: nodesToStore, edges: edgesToStore.map(function(e) {
return { from: e.from, to: e.to, arrows: e.arrows || 'to' };
}) };
toast('已折叠子项', false);
}
function handleMenuDelete() {
document.getElementById('context-menu').style.display = 'none';
if (!activeContextNode) return;
var id = activeContextNode;
pushHistory();
edgesDataset.get({
filter: function(e) { return e.from === id || e.to === id; }
}).forEach(function(e) { edgesDataset.remove(e.id); });
nodesDataset.remove(id);
delete collapsedSubtrees[id];
Object.keys(collapsedSubtrees).forEach(function(k) {
if (k === id) delete collapsedSubtrees[k];
});
activeContextNode = null;
}
function handleMenuEdit() {
var n = nodesDataset.get(activeContextNode);
document.getElementById('edit-id').value = n.id;
document.getElementById('edit-name').value = n.customData.name;
document.getElementById('edit-mastery').value = n.customData.mastery;
document.getElementById('mastery-value').innerText = n.customData.mastery;
document.getElementById('edit-note').value = n.customData.note || '';
document.getElementById('edit-link').value = n.customData.link || '';
document.getElementById('overlay').style.display = 'block';
document.getElementById('edit-modal').style.display = 'block';
document.getElementById('context-menu').style.display = 'none';
}
function saveNode() {
var id = document.getElementById('edit-id').value;
var name = document.getElementById('edit-name').value.trim() || '未命名';
var m = document.getElementById('edit-mastery').value;
var note = document.getElementById('edit-note').value;
var link = document.getElementById('edit-link').value.trim();
pushHistory();
var old = nodesDataset.get(id);
var cd = normalizeCustomData({ name: name, mastery: m, note: note, link: link });
nodesDataset.update({
id: id,
image: createNodeSvg(cd.name, cd.mastery),
customData: cd,
x: old.x,
y: old.y,
title: nodeTitle(cd)
});
closeAllModals();
}
function closeAllModals() {
document.getElementById('overlay').style.display = 'none';
document.getElementById('edit-modal').style.display = 'none';
document.getElementById('import-modal').style.display = 'none';
}
function closeImportModal() {
document.getElementById('import-modal').style.display = 'none';
document.getElementById('overlay').style.display = 'none';
}
function openImportModal() {
document.getElementById('overlay').style.display = 'block';
document.getElementById('import-modal').style.display = 'block';
}
function autoSave() {
if (!nodesDataset || !network) return;
try {
var snap = getSnapshot();
localStorage.setItem(DATA_KEY, JSON.stringify(snap));
var el = document.getElementById('save-status');
el.textContent = '存档已同步到本地 ' + new Date().toLocaleTimeString();
el.style.color = '#666';
} catch (e) {}
}
function loadFromLocal() {
try {
var raw = localStorage.getItem(DATA_KEY);
if (!raw) return;
var saved = JSON.parse(raw);
if (!saved || !saved.nodes) return;
collapsedSubtrees = saved.collapsed && typeof saved.collapsed === 'object' ? saved.collapsed : {};
saved.nodes.forEach(function(raw) { nodesDataset.add(buildVisNode(raw)); });
(saved.edges || []).forEach(function(e) {
edgesDataset.add({ from: e.from, to: e.to, arrows: e.arrows || 'to' });
});
} catch (e) {
console.warn(e);
}
}
function exportToImage() {
var canvas = document.querySelector('#network-canvas canvas');
if (!canvas) {
toast('画布尚未就绪', true);
return;
}
var temp = document.createElement('canvas');
temp.width = canvas.width;
temp.height = canvas.height;
var ctx = temp.getContext('2d');
ctx.fillStyle = '#0d0d12';
ctx.fillRect(0, 0, temp.width, temp.height);
ctx.drawImage(canvas, 0, 0);
var a = document.createElement('a');
a.href = temp.toDataURL('image/png');
a.download = 'MyTechTree.png';
a.click();
}
function exportJson() {
var blob = new Blob([JSON.stringify(getSnapshot(), null, 2)], { type: 'application/json' });
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'tech-tree-backup.json';
a.click();
URL.revokeObjectURL(a.href);
toast('已导出 JSON', false);
}
function confirmImportJson() {
var f = document.getElementById('import-file').files[0];
if (!f) {
toast('请选择文件', true);
return;
}
var reader = new FileReader();
reader.onload = function() {
try {
var data = JSON.parse(reader.result);
if (!data.nodes || !Array.isArray(data.nodes)) {
toast('JSON 缺少 nodes 数组', true);
return;
}
pushHistory();
nodesDataset.clear();
edgesDataset.clear();
collapsedSubtrees = data.collapsed && typeof data.collapsed === 'object' ? data.collapsed : {};
data.nodes.forEach(function(raw) { nodesDataset.add(buildVisNode(raw)); });
(data.edges || []).forEach(function(e) {
edgesDataset.add({ from: e.from, to: e.to, arrows: e.arrows || 'to' });
});
updateStats();
autoSave();
closeImportModal();
toast('导入成功', false);
} catch (e) {
toast('解析失败:' + (e.message || String(e)), true);
}
};
reader.readAsText(f);
}
function runPhysicsLayout() {
if (!network) return;
network.setOptions({
physics: {
enabled: true,
solver: 'forceAtlas2Based',
forceAtlas2Based: { gravitationalConstant: -80, springLength: 200 }
}
});
setTimeout(function() {
network.setOptions({ physics: { enabled: false } });
autoSave();
}, 2500);
}
function addNewNode() {
pushHistory();
var id = 'n' + Date.now();
var cd = normalizeCustomData({ name: '新技能', mastery: 10 });
nodesDataset.add({
id: id,
shape: 'image',
image: createNodeSvg(cd.name, cd.mastery),
customData: cd,
x: 0,
y: 0,
title: nodeTitle(cd)
});
}
function enableDrawEdge() {
if (!network) return;
network.addEdgeMode();
}
function clearAll() {
if (!confirm('确定清空画布与本地存档中的图数据?')) return;
pushHistory();
nodesDataset.clear();
edgesDataset.clear();
collapsedSubtrees = {};
localStorage.removeItem(DATA_KEY);
toast('已清空', false);
}
document.addEventListener('keydown', function(e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
if (e.key === 'Escape') closeAllModals();
return;
}
if (e.ctrlKey && e.key === 'z') {
e.preventDefault();
undo();
}
if (e.ctrlKey && (e.key === 'y' || (e.shiftKey && e.key === 'Z'))) {
e.preventDefault();
redo();
}
if (e.key === 'Escape') {
closeAllModals();
document.getElementById('context-menu').style.display = 'none';
}
});
window.onload = function() {
loadApiConfig();
nodesDataset = new vis.DataSet();
edgesDataset = new vis.DataSet();
initNetwork();
loadFromLocal();
nodesDataset.on('*', function() { updateStats(); autoSave(); });
edgesDataset.on('*', function() { updateStats(); autoSave(); });
updateStats();
};
</script>
</body>
</html>