Spaces:
Sleeping
Sleeping
| <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>已点亮 (>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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); | |
| } | |
| 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> | |