Files
CareerNexus/webui/graph.js
T
RoyirinRosmontis ac6d08cdf2 前端答辩级改造:错误中文化、证据弹窗可读化、图谱默认岗位子图、演化报告化、匹配60/40、六步导览
- cnFetch 统一网络层,业务化错误文案+原位重试,屏蔽内部文件名
- 证据弹窗:结论/来源平台·企业·日期/原文/命中依据+翻页
- 图谱默认 Java 岗位子图;详情面板四区块(结论/能力清单/近期变化/证据操作)
- 演化页本期结论块(需求指数口径)+近8季默认视图
- 匹配页输入区 60/40 + 岗位信息卡 + 结论分档文案 + 缺口折叠
- 学习路径空状态引导+阶段语义化命名;六步导览浮动条
- tests/test_frontend_logic.js 21 用例
2026-08-13 12:33:13 +08:00

1009 lines
47 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* CareerNexus 职联 · 全景图谱工作台(graph.js)
* canvas 力导向 + 缩放平移 + 小地图 + 详情面板 + 节点编辑/备注/审核。
* 依赖 app.js 暴露的 CN 命名空间。 */
"use strict";
(function () {
const { $, $$, api, post, esc, toast, openModal, closeModal, TYPE_META } = CN;
/* ================= 状态 ================= */
const G = {
nodes: [], edges: [], events: [], idx: {},
cam: { x: 0, y: 0, k: 1 }, // 相机:世界 -> 屏幕 screen = (world - cam.pan) * k ... 使用 cam.x/y 为平移(屏幕像素)
showLabels: false, // 默认关闭:悬停/选中/缩放时才显示
filters: { types: { Job: true, Skill: true, Industry: false, Scenario: false }, category: "", level: "",
edges: { required: true, preferred: true, applies: true, belongs: true } },
selected: null, // 选中节点 id
soloJob: null, // 岗位子图模式:只看该岗位及其一跳邻居
hover: null,
navStack: [], // 详情面板导航栈
drag: null, // {mode:'pan'|'node', ...}
pending: [], // 待审核候选
raf: null,
};
const canvas = () => $("#graph-canvas");
const NOTES_KEY = "cn_notes";
const VIEW_KEY = "cn_graph_view";
const DEFAULT_SOLO_JOB = "job_Java开发工程师"; // 首屏默认岗位子图(局部可读)
/* ================= 数据装载 ================= */
async function load() {
$("#graph-loading").classList.remove("hidden");
const [data, pending] = await Promise.all([
api("/api/graph"),
api("/api/review/pending").catch(() => []),
]);
CN.graphData = data;
G.events = data.events || [];
G.pending = pending;
G.nodes = data.nodes.map((n) => ({ ...n, x: 0, y: 0, vx: 0, vy: 0 }));
G.idx = Object.fromEntries(G.nodes.map((n, i) => [n.id, i]));
G.edges = data.edges.filter((e) => e.source in G.idx && e.target in G.idx);
/* 合并待审核候选为虚线节点 */
pending.forEach((c) => {
if (!(c.job_id in G.idx)) {
G.idx[c.job_id] = G.nodes.push({
id: c.job_id, type: "Job", title: c.title, category: c.category,
status: "pending", pending: true, expected_years: c.expected_years,
source_count: c.source_count, company_count: c.company_count,
x: 0, y: 0, vx: 0, vy: 0,
}) - 1;
}
[...c.required_skills.map((s) => [s, "required"]), ...c.preferred_skills.map((s) => [s, "preferred"])].forEach(([s, imp]) => {
if (!(s in G.idx)) {
G.idx[s] = G.nodes.push({ id: s, type: "Skill", category: "", x: 0, y: 0, vx: 0, vy: 0 }) - 1;
}
G.edges.push({ source: c.job_id, target: s, rel: "REQUIRES", importance: imp, weight: 1, intervals: [], sources: [], pending: true });
});
});
buildFilterOptions();
layout();
restoreView() || fitView();
/* 默认进入「Java开发工程师」岗位子图:首屏局部可读,顶栏徽标可退出全图(每次装载仅应用一次) */
if (!G._defaultApplied && !G.pendingLocate && G.idx[DEFAULT_SOLO_JOB]) {
G._defaultApplied = true;
G.soloJob = DEFAULT_SOLO_JOB;
updateSoloBadge();
selectNode(DEFAULT_SOLO_JOB, false);
centerOn(G.nodes[G.idx[DEFAULT_SOLO_JOB]], Math.max(G.cam.k, 1.05));
}
renderSuggest();
renderChain(G.soloJob || null);
$("#graph-loading").classList.add("hidden");
requestDraw();
drawMinimap();
if (G.pendingLocate) { const t = G.pendingLocate; G.pendingLocate = null; locateNode(t); }
}
function buildFilterOptions() {
const cats = [...new Set(G.nodes.filter((n) => n.type === "Job" && n.category).map((n) => n.category))].sort();
$("#fp-category").innerHTML = `<option value="">全部类别</option>` +
cats.map((c) => `<option value="${esc(c)}">${esc(c)}</option>`).join("");
}
/* ================= 力导向布局 ================= */
function layout() {
const N = G.nodes.length;
G.nodes.forEach((n, i) => {
const a = (i / N) * Math.PI * 2;
n.x = Math.cos(a) * 400 + (Math.random() - 0.5) * 60;
n.y = Math.sin(a) * 400 + (Math.random() - 0.5) * 60;
n.vx = 0; n.vy = 0;
});
for (let iter = 0; iter < 320; iter++) {
for (let i = 0; i < N; i++) {
const a = G.nodes[i];
for (let j = i + 1; j < N; j++) {
const b = G.nodes[j];
let dx = a.x - b.x, dy = a.y - b.y;
let d2 = dx * dx + dy * dy || 1;
if (d2 > 640000) continue;
const d = Math.sqrt(d2);
const f = 2600 / d2;
dx = (dx / d) * f; dy = (dy / d) * f;
a.vx += dx; a.vy += dy; b.vx -= dx; b.vy -= dy;
}
}
G.edges.forEach((e) => {
const a = G.nodes[G.idx[e.source]], b = G.nodes[G.idx[e.target]];
let dx = b.x - a.x, dy = b.y - a.y;
const d = Math.sqrt(dx * dx + dy * dy) || 1;
const rest = (a.type === "Job" && b.type === "Job") ? 260 : 130;
const f = (d - rest) * 0.015;
dx = (dx / d) * f; dy = (dy / d) * f;
a.vx += dx; a.vy += dy; b.vx -= dx; b.vy -= dy;
});
G.nodes.forEach((n) => {
n.vx *= 0.55; n.vy *= 0.55;
n.x += Math.max(-24, Math.min(24, n.vx));
n.y += Math.max(-24, Math.min(24, n.vy));
});
}
}
function worldBounds() {
let minX = 1e9, minY = 1e9, maxX = -1e9, maxY = -1e9;
G.nodes.forEach((n) => {
minX = Math.min(minX, n.x); maxX = Math.max(maxX, n.x);
minY = Math.min(minY, n.y); maxY = Math.max(maxY, n.y);
});
return { minX, minY, maxX, maxY, w: maxX - minX || 1, h: maxY - minY || 1 };
}
/* ================= 相机 ================= */
function fitView() {
const c = canvas(), b = worldBounds();
const W = c.clientWidth, H = c.clientHeight;
G.cam.k = Math.min(W / (b.w + 120), H / (b.h + 120), 1.6);
G.cam.x = (W - b.w * G.cam.k) / 2 - b.minX * G.cam.k;
G.cam.y = (H - b.h * G.cam.k) / 2 - b.minY * G.cam.k;
requestDraw(); drawMinimap();
}
const toScreen = (n) => ({ x: n.x * G.cam.k + G.cam.x, y: n.y * G.cam.k + G.cam.y });
const toWorld = (sx, sy) => ({ x: (sx - G.cam.x) / G.cam.k, y: (sy - G.cam.y) / G.cam.k });
function zoomAt(factor, cx, cy) {
const c = canvas();
cx = cx ?? c.clientWidth / 2; cy = cy ?? c.clientHeight / 2;
const w = toWorld(cx, cy);
G.cam.k = Math.max(0.15, Math.min(4, G.cam.k * factor));
G.cam.x = cx - w.x * G.cam.k;
G.cam.y = cy - w.y * G.cam.k;
requestDraw(); drawMinimap();
}
function centerOn(node, k) {
const c = canvas();
if (k) G.cam.k = k;
G.cam.x = c.clientWidth / 2 - node.x * G.cam.k;
G.cam.y = c.clientHeight / 2 - node.y * G.cam.k;
requestDraw(); drawMinimap();
}
/* ================= 可见性(筛选) ================= */
function jobLevel(n) {
const y = n.expected_years;
if (y == null) return "";
return y <= 2 ? "初级" : y <= 5 ? "中级" : "高级";
}
function visible(n) {
if (!G.filters.types[n.type]) return false;
if (G.soloJob) { // 岗位子图:只保留该岗位与一跳邻居
if (n.id !== G.soloJob && !neighborsOf(G.soloJob).has(n.id)) return false;
}
if (n.type === "Job") {
if (G.filters.category && n.category !== G.filters.category) return false;
if (G.filters.level && jobLevel(n) !== G.filters.level) return false;
}
return true;
}
function edgeVisible(e) {
const ef = G.filters.edges || {};
if (e.rel === "REQUIRES") return ef[e.importance === "required" ? "required" : "preferred"] !== false;
if (e.rel === "APPLIES_TO") return ef.applies !== false;
if (e.rel === "BELONGS_TO") return ef.belongs !== false;
return true;
}
function neighborsOf(id) {
const set = new Set();
G.edges.forEach((e) => {
if (!edgeVisible(e)) return;
if (e.source === id) set.add(e.target);
if (e.target === id) set.add(e.source);
});
return set;
}
/* ================= 渲染 ================= */
function requestDraw() {
if (G.raf) return;
G.raf = requestAnimationFrame(() => { G.raf = null; draw(); });
}
function nodeRadius(n) {
return n.type === "Job" ? 13 : n.type === "Scenario" ? 8 : n.type === "Industry" ? 10 : 6.5;
}
function draw() {
const c = canvas();
if (!c.clientWidth) return;
const dpr = window.devicePixelRatio || 1;
c.width = c.clientWidth * dpr; c.height = c.clientHeight * dpr;
const ctx = c.getContext("2d");
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, c.clientWidth, c.clientHeight);
const sel = G.selected;
const hop = sel ? neighborsOf(sel) : null;
/* ---- 边 ---- */
G.edges.forEach((e) => {
const a = G.nodes[G.idx[e.source]], b = G.nodes[G.idx[e.target]];
if (!visible(a) || !visible(b)) return;
if (!edgeVisible(e)) return;
const pa = toScreen(a), pb = toScreen(b);
const isHot = sel && (e.source === sel || e.target === sel);
const dim = sel && !isHot;
const isReq = e.importance === "required";
ctx.save();
if (e.rel === "APPLIES_TO") {
ctx.strokeStyle = dim ? "rgba(114,46,209,0.05)" : isHot ? "rgba(114,46,209,0.7)" : "rgba(114,46,209,0.22)";
ctx.setLineDash([]);
ctx.lineWidth = isHot ? 1.4 : 0.8;
} else if (isReq) {
ctx.strokeStyle = dim ? "rgba(78,89,105,0.05)" : isHot ? "rgba(47,84,235,0.75)" : "rgba(78,89,105,0.28)";
ctx.setLineDash([]);
ctx.lineWidth = isHot ? 1.8 : 1;
} else {
ctx.strokeStyle = dim ? "rgba(19,194,194,0.05)" : isHot ? "rgba(19,194,194,0.8)" : "rgba(19,194,194,0.4)";
ctx.setLineDash([5, 4]);
ctx.lineWidth = isHot ? 1.6 : 1;
}
if (e.pending) ctx.setLineDash([3, 4]);
ctx.beginPath(); ctx.moveTo(pa.x, pa.y); ctx.lineTo(pb.x, pb.y); ctx.stroke();
ctx.setLineDash([]);
/* 箭头(指向 target 节点边缘) */
const dx = pb.x - pa.x, dy = pb.y - pa.y;
const d = Math.sqrt(dx * dx + dy * dy) || 1;
const rB = nodeRadius(b) * G.cam.k + 2;
const ax = pb.x - (dx / d) * rB, ay = pb.y - (dy / d) * rB;
const ang = Math.atan2(dy, dx);
const ah = isHot ? 7 : 5.5;
ctx.fillStyle = ctx.strokeStyle;
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.lineTo(ax - ah * Math.cos(ang - 0.45), ay - ah * Math.sin(ang - 0.45));
ctx.lineTo(ax - ah * Math.cos(ang + 0.45), ay - ah * Math.sin(ang + 0.45));
ctx.closePath(); ctx.fill();
/* 关系标签(颜色 + 文字双重编码) */
if (G.showLabels && e.rel === "REQUIRES" && G.cam.k > 0.55 && !dim) {
ctx.font = "10px sans-serif"; ctx.textAlign = "center";
ctx.fillStyle = isReq ? "rgba(47,84,235,0.85)" : "rgba(9,151,156,0.9)";
ctx.fillText(isReq ? "必备" : "加分", (pa.x + pb.x) / 2, (pa.y + pb.y) / 2 - 3);
}
ctx.restore();
});
/* ---- 节点 ---- */
G.nodes.forEach((n) => {
if (!visible(n)) return;
const p = toScreen(n);
const r = Math.max(2.5, nodeRadius(n) * Math.sqrt(G.cam.k));
const isSel = n.id === sel;
const isHop = hop && hop.has(n.id);
const dim = sel && !isSel && !isHop;
const meta = TYPE_META[n.type] || { color: "#86909c" };
ctx.save();
ctx.globalAlpha = dim ? 0.15 : 1;
ctx.fillStyle = meta.color;
if (n.type === "Skill") {
const s = r * 1.7;
ctx.beginPath(); ctx.roundRect(p.x - s / 2, p.y - s / 2, s, s, 2.5); ctx.fill();
} else {
ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, 7); ctx.fill();
}
if (n.pending || n.status === "pending") {
ctx.setLineDash([3, 3]);
ctx.strokeStyle = "#ff7d00"; ctx.lineWidth = 1.6;
ctx.beginPath(); ctx.arc(p.x, p.y, r + 3.5, 0, 7); ctx.stroke();
ctx.setLineDash([]);
}
if (isSel) {
ctx.strokeStyle = "#1d2129"; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(p.x, p.y, r + 4, 0, 7); ctx.stroke();
} else if (isHop) {
ctx.strokeStyle = "rgba(47,84,235,0.55)"; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(p.x, p.y, r + 2.5, 0, 7); ctx.stroke();
}
/* 标签:岗位常显,其余放大或选中/悬停时显示 */
if (n.type === "Job" || G.cam.k > 1.05 || isSel || isHop || n.id === G.hover) {
ctx.font = `${n.type === "Job" ? 11 : 10}px sans-serif`;
ctx.textAlign = "center";
ctx.fillStyle = "rgba(255,255,255,0.85)";
const label = n.title || n.id;
const tw = ctx.measureText(label).width;
ctx.fillRect(p.x - tw / 2 - 3, p.y - r - 17, tw + 6, 13);
ctx.fillStyle = n.type === "Job" ? "#1d2129" : "#4e5969";
ctx.fillText(label, p.x, p.y - r - 7);
}
ctx.restore();
});
}
/* ================= 小地图 ================= */
function drawMinimap() {
const mm = $("#minimap-canvas");
const ctx = mm.getContext("2d");
const W = mm.width, H = mm.height;
ctx.clearRect(0, 0, W, H);
if (!G.nodes.length) return;
const b = worldBounds();
const k = Math.min((W - 10) / b.w, (H - 10) / b.h);
const ox = (W - b.w * k) / 2 - b.minX * k;
const oy = (H - b.h * k) / 2 - b.minY * k;
G.edges.forEach((e) => {
const a = G.nodes[G.idx[e.source]], bb = G.nodes[G.idx[e.target]];
if (!visible(a) || !visible(bb)) return;
ctx.strokeStyle = "rgba(120,130,150,0.25)";
ctx.beginPath();
ctx.moveTo(a.x * k + ox, a.y * k + oy);
ctx.lineTo(bb.x * k + ox, bb.y * k + oy);
ctx.stroke();
});
G.nodes.forEach((n) => {
if (!visible(n)) return;
const meta = TYPE_META[n.type] || { color: "#86909c" };
ctx.fillStyle = n.pending ? "#ff7d00" : meta.color;
ctx.beginPath();
ctx.arc(n.x * k + ox, n.y * k + oy, n.type === "Job" ? 2.6 : 1.6, 0, 7);
ctx.fill();
});
/* 视口框 */
const c = canvas();
const tl = toWorld(0, 0), br = toWorld(c.clientWidth, c.clientHeight);
ctx.strokeStyle = "#2f54eb"; ctx.lineWidth = 1.4;
ctx.strokeRect(tl.x * k + ox, tl.y * k + oy, (br.x - tl.x) * k, (br.y - tl.y) * k);
mm._proj = { k, ox, oy };
}
$("#minimap-canvas").addEventListener("click", (e) => {
const mm = e.currentTarget, p = mm._proj;
if (!p) return;
const rect = mm.getBoundingClientRect();
const wx = (e.clientX - rect.left - p.ox) / p.k;
const wy = (e.clientY - rect.top - p.oy) / p.k;
centerOn({ x: wx, y: wy });
});
/* ================= 交互:平移 / 缩放 / 点选 / 拖拽节点 ================= */
function pickNode(sx, sy) {
let best = null, bd = 1e9;
G.nodes.forEach((n) => {
if (!visible(n)) return;
const p = toScreen(n);
const r = Math.max(8, nodeRadius(n) * Math.sqrt(G.cam.k) + 4);
const d = (p.x - sx) ** 2 + (p.y - sy) ** 2;
if (d < r * r && d < bd) { bd = d; best = n; }
});
return best;
}
function bindCanvas() {
const c = canvas();
c.addEventListener("mousedown", (e) => {
const rect = c.getBoundingClientRect();
const sx = e.clientX - rect.left, sy = e.clientY - rect.top;
const node = pickNode(sx, sy);
G.drag = node
? { mode: "node", node, moved: false }
: { mode: "pan", sx, sy, camX: G.cam.x, camY: G.cam.y, moved: false };
c.classList.add("dragging");
});
window.addEventListener("mousemove", (e) => {
const rect = c.getBoundingClientRect();
const sx = e.clientX - rect.left, sy = e.clientY - rect.top;
if (!G.drag) {
const h = pickNode(sx, sy);
const id = h ? h.id : null;
if (id !== G.hover) { G.hover = id; requestDraw(); }
c.classList.toggle("hovering", !!h);
return;
}
G.drag.moved = true;
if (G.drag.mode === "pan") {
G.cam.x = G.drag.camX + (sx - G.drag.sx);
G.cam.y = G.drag.camY + (sy - G.drag.sy);
} else {
const w = toWorld(sx, sy);
G.drag.node.x = w.x; G.drag.node.y = w.y;
}
requestDraw();
});
window.addEventListener("mouseup", () => {
if (G.drag && G.drag.mode === "node" && G.drag.moved) drawMinimap();
if (G.drag && G.drag.mode === "pan" && G.drag.moved) drawMinimap();
G.drag = null;
c.classList.remove("dragging");
});
c.addEventListener("click", (e) => {
if (G.drag && G.drag.moved) return;
const rect = c.getBoundingClientRect();
const node = pickNode(e.clientX - rect.left, e.clientY - rect.top);
selectNode(node ? node.id : null);
});
c.addEventListener("wheel", (e) => {
e.preventDefault();
const rect = c.getBoundingClientRect();
zoomAt(e.deltaY < 0 ? 1.12 : 0.89, e.clientX - rect.left, e.clientY - rect.top);
}, { passive: false });
window.addEventListener("resize", () => { requestDraw(); drawMinimap(); });
}
/* ================= 工具条 / 筛选 ================= */
function bindToolbar() {
$("#tool-zoom-in").onclick = () => zoomAt(1.25);
$("#tool-zoom-out").onclick = () => zoomAt(0.8);
$("#tool-fit").onclick = fitView;
$("#tool-relayout").onclick = () => {
$("#graph-loading").classList.remove("hidden");
setTimeout(() => { layout(); fitView(); $("#graph-loading").classList.add("hidden"); toast("布局已整理", "success"); }, 30);
};
$("#tool-labels").onclick = (e) => {
G.showLabels = !G.showLabels;
e.currentTarget.classList.toggle("active", G.showLabels);
requestDraw();
};
$("#tool-filter").onclick = () => $("#graph-filter-panel").classList.toggle("hidden");
document.addEventListener("click", (e) => {
if (!e.target.closest("#graph-filter-panel") && !e.target.closest("#tool-filter")) {
$("#graph-filter-panel").classList.add("hidden");
}
});
$$("#graph-filter-panel input[data-ftype]").forEach((cb) => {
cb.onchange = () => { G.filters.types[cb.dataset.ftype] = cb.checked; requestDraw(); drawMinimap(); };
});
$$("#graph-filter-panel input[data-etype]").forEach((cb) => {
cb.onchange = () => { G.filters.edges[cb.dataset.etype] = cb.checked; requestDraw(); drawMinimap(); };
});
$("#fp-category").onchange = (e) => { G.filters.category = e.target.value; requestDraw(); drawMinimap(); };
$("#fp-level").onchange = (e) => { G.filters.level = e.target.value; requestDraw(); drawMinimap(); };
$("#fp-reset").onclick = () => {
G.filters = { types: { Job: true, Skill: true, Industry: false, Scenario: false }, category: "", level: "",
edges: { required: true, preferred: true, applies: true, belongs: true } };
$$("#graph-filter-panel input[data-ftype]").forEach((cb) => { cb.checked = true; });
$$("#graph-filter-panel input[data-etype]").forEach((cb) => { cb.checked = true; });
$("#fp-category").value = ""; $("#fp-level").value = "";
requestDraw(); drawMinimap();
};
/* 头部按钮 */
$("#btn-save-view").onclick = () => {
localStorage.setItem(VIEW_KEY, JSON.stringify({ cam: G.cam, filters: G.filters, labels: G.showLabels }));
toast("当前视图(位置/缩放/筛选)已保存", "success");
};
$("#btn-share-graph").onclick = async () => {
const url = `${location.origin}/#view=graph${G.selected ? `&node=${encodeURIComponent(G.selected)}` : ""}`;
try { await navigator.clipboard.writeText(url); toast("分享链接已复制到剪贴板", "success"); }
catch { CN.sysCopy({ title: "分享链接(请复制)", text: url }); }
};
$("#btn-export-graph").onclick = () => {
const c = canvas();
const tmp = document.createElement("canvas");
tmp.width = c.width; tmp.height = c.height;
const tctx = tmp.getContext("2d");
tctx.fillStyle = "#ffffff";
tctx.fillRect(0, 0, tmp.width, tmp.height);
tctx.drawImage(c, 0, 0);
tmp.toBlob((blob) => {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `CareerNexus_岗位能力图谱_${new Date().toISOString().slice(0, 10)}.png`;
a.click();
URL.revokeObjectURL(a.href);
toast("图谱 PNG 已导出", "success");
});
};
$("#gs-toggle").onclick = () => $("#graph-suggest").classList.toggle("collapsed");
}
function restoreView() {
try {
const v = JSON.parse(localStorage.getItem(VIEW_KEY) || "null");
if (!v) return false;
G.cam = v.cam;
G.filters = Object.assign(
{ types: { Job: true, Skill: true, Industry: false, Scenario: false }, category: "", level: "",
edges: { required: true, preferred: true, applies: true, belongs: true } },
v.filters);
G.filters.edges = Object.assign({ required: true, preferred: true, applies: true, belongs: true },
(v.filters && v.filters.edges) || {});
G.showLabels = v.labels !== false;
$("#tool-labels").classList.toggle("active", G.showLabels);
$$("#graph-filter-panel input[data-ftype]").forEach((cb) => { cb.checked = !!G.filters.types[cb.dataset.ftype]; });
$$("#graph-filter-panel input[data-etype]").forEach((cb) => { cb.checked = G.filters.edges[cb.dataset.etype] !== false; });
$("#fp-category").value = G.filters.category || "";
$("#fp-level").value = G.filters.level || "";
return true;
} catch { return false; }
}
/* ================= 智能建议 ================= */
function renderSuggest() {
const items = [];
G.pending.forEach((c) => items.push({
color: "#ff7d00", kind: "pending", id: c.job_id,
text: `新岗位候选「${esc(c.title)}」待审核`,
sub: `${c.source_count} 条 JD · ${c.company_count} 家企业支撑`,
}));
G.events.slice(-4).reverse().forEach((e) => items.push({
color: "#2f54eb", kind: "event", id: e.job_id,
text: `「${esc(e.job_id.replace(/^job_/, ""))}」${esc(e.change_type)}技能 ${esc(e.skill)}`,
sub: `窗口 ${esc(e.window)} · 需求指数 ${e.prev_weight.toFixed(2)}→${e.curr_weight.toFixed(2)}`,
}));
$("#gs-list").innerHTML = items.length ? items.map((it, i) => `
<div class="gs-item" data-i="${i}">
<span class="gs-dot" style="background:${it.color}"></span>
<div><div class="gs-text">${it.text}</div><div class="gs-sub">${it.sub}</div>
${it.kind === "pending" ? `<a class="link-btn" data-review="${esc(it.id)}">去审核 →</a>` : ""}</div>
</div>`).join("") : `<div class="empty small">暂无建议</div>`;
$$("#gs-list .gs-item").forEach((el) => {
el.onclick = (ev) => {
if (ev.target.dataset.review) return;
const it = items[+el.dataset.i];
locateNode(it.id);
};
});
$$("#gs-list [data-review]").forEach((a) => {
a.onclick = () => locateNode(a.dataset.review);
});
}
/* ================= 演化逻辑链 ================= */
function renderChain(jobId) {
const chips = $("#gc-chips");
if (!jobId) {
$("#gc-job-name").textContent = "选择岗位节点后展示能力演化路径";
chips.innerHTML = `<span class="text-weak text-aux">—</span>`;
return;
}
const job = G.nodes[G.idx[jobId]];
$("#gc-job-name").textContent = job ? `「${job.title || jobId}」能力演化路径` : "";
const evs = G.events.filter((e) => e.job_id === jobId)
.sort((a, b) => (a.window < b.window ? -1 : 1));
const seq = [];
const seen = new Set();
evs.forEach((e) => {
if (seen.has(e.skill)) return;
seen.add(e.skill);
if (e.change_type === "新增") seq.push({ skill: e.skill, exit: false, window: e.window });
if (e.change_type === "退出") {
const found = seq.find((s) => s.skill === e.skill);
if (found) found.exit = true;
}
});
let html = seq.map((s) =>
`<span class="chain-chip${s.exit ? " exit" : ""}" data-skill="${esc(s.skill)}" title="${esc(s.window)}${s.exit ? " · 已退出" : ""}">${esc(s.skill)}${s.exit ? " ✕" : ""}</span>`
).join(`<span class="chain-arrow">→</span>`);
/* 旁列待审核关系 */
const pendRels = G.edges.filter((e) => e.pending && e.source === jobId);
if (pendRels.length) {
html += `<span class="chain-arrow">|待审核:</span>` + pendRels.map((e) =>
`<span class="chain-chip review" data-skill="${esc(e.target)}">⏳ ${esc(e.target)}</span>`).join("");
}
chips.innerHTML = html || `<span class="text-weak text-aux">该岗位暂无演化记录</span>`;
chips.querySelectorAll("[data-skill]").forEach((el) => {
el.onclick = () => locateNode(el.dataset.skill);
});
}
/* ================= 选中 / 定位 ================= */
function selectNode(id, pushNav = true) {
if (pushNav && G.selected && id && id !== G.selected) G.navStack.push(G.selected);
G.selected = id;
if (id) {
renderDetail(id);
const n = G.nodes[G.idx[id]];
renderChain(n && n.type === "Job" ? id : null);
} else {
$("#detail-body").classList.add("hidden");
$("#detail-empty").classList.remove("hidden");
renderChain(null);
}
requestDraw();
}
function locateNode(id) {
/* 定位也记入 URL,刷新/分享后回到同一节点 */
const hash = new URLSearchParams(location.hash.slice(1));
hash.set("view", "graph");
hash.set("node", id);
history.replaceState(null, "", "#" + hash.toString());
const n = G.nodes[G.idx[id]];
if (!n) { G.pendingLocate = id; return; } // 数据未就绪:加载完成后补定位
G.pendingLocate = null;
/* 目标不在当前岗位子图内:自动退出子图,避免"定位到不可见节点" */
if (G.soloJob && id !== G.soloJob && !neighborsOf(G.soloJob).has(id)) {
G.soloJob = null;
updateSoloBadge();
fitView();
}
selectNode(id);
centerOn(n, Math.max(G.cam.k, 0.9));
}
CN.gotoNode = locateNode;
function updateSoloBadge() {
const badge = $("#solo-badge");
if (!badge) return;
badge.classList.toggle("hidden", !G.soloJob);
if (G.soloJob) {
const n = G.nodes[G.idx[G.soloJob]];
$("#solo-badge-text").textContent = `岗位子图:${(n && (n.title || n.id)) || G.soloJob}`;
}
}
const soloExit = $("#solo-exit");
if (soloExit) soloExit.onclick = () => { G.soloJob = null; updateSoloBadge(); fitView(); };
/* ================= 备注(localStorage) ================= */
const getNotes = () => JSON.parse(localStorage.getItem(NOTES_KEY) || "{}");
function nodeNotes(id) { return getNotes()[id] || []; }
function addNote(id, text) {
const all = getNotes();
(all[id] = all[id] || []).push({ text, time: new Date().toLocaleString("zh-CN") });
localStorage.setItem(NOTES_KEY, JSON.stringify(all));
}
function delNote(id, i) {
const all = getNotes();
(all[id] || []).splice(i, 1);
localStorage.setItem(NOTES_KEY, JSON.stringify(all));
}
/* ================= 详情面板 ================= */
const NODE_ICONS = {
Job: '<svg viewBox="0 0 24 24"><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V5a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>',
Skill: '<svg viewBox="0 0 24 24"><path d="M12 2l2.4 4.9 5.4.8-3.9 3.8.9 5.4-4.8-2.5-4.8 2.5.9-5.4L4.2 7.7l5.4-.8L12 2z"/></svg>',
Industry: '<svg viewBox="0 0 24 24"><path d="M3 21h18M5 21V8l6-4 6 4v13M9 12h2M13 12h2M9 16h2M13 16h2"/></svg>',
Scenario: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.5 2.6 3.8 5.7 3.8 9S14.5 18.4 12 21c-2.5-2.6-3.8-5.7-3.8-9S9.5 5.6 12 3z"/></svg>',
};
async function renderDetail(id) {
const n = G.nodes[G.idx[id]];
if (!n) return;
const meta = TYPE_META[n.type] || { name: n.type, tag: "tag-gray", color: "#86909c" };
const isPending = n.pending || n.status === "pending";
const isJob = n.type === "Job" && !isPending;
/* 关联 */
const rels = { Job: [], Skill: [], Scenario: [], Industry: [] };
const sources = new Set();
G.edges.forEach((e) => {
let other = null, imp = "";
if (e.source === id) { other = e.target; imp = e.importance; }
else if (e.target === id) { other = e.source; imp = e.importance; }
else return;
(e.sources || []).forEach((s) => sources.add(s));
const on = G.nodes[G.idx[other]];
if (on && rels[on.type]) rels[on.type].push({ id: other, title: on.title || other, imp, pending: e.pending });
});
/* 岗位定义(结论区块的一句话定义来源;失败不阻塞面板) */
const jobDef = isJob
? await api(`/api/jobs/${encodeURIComponent(id)}`).then((d) => d.definition || d).catch(() => null)
: null;
/* 基础信息(非岗位节点 / 待审核候选保留原始平铺) */
const kv = [];
if (n.type === "Job") {
kv.push(["类别", n.category || "—"], ["期望年限", n.expected_years != null ? `${n.expected_years} 年` : "—"]);
if (n.source_count != null) kv.push(["支撑 JD", `${n.source_count} 条 / ${n.company_count} 家企业`]);
}
if (n.type === "Skill") kv.push(["类别", n.category || "通用技能"]);
kv.push(["级别", n.level || (n.type === "Job" ? jobLevel(n) || "—" : "—")]);
if (n.weight != null) kv.push(["需求指数", Number(n.weight).toFixed(2)]);
if (n.importance) kv.push(["重要性", n.importance === "required" ? "必备" : n.importance]);
const latestWin = G.edges.filter((e) => (e.source === id || e.target === id) && e.intervals?.length)
.flatMap((e) => e.intervals.map((i) => i[0])).sort().at(-1);
if (latestWin) kv.push(["更新时间", latestWin]);
const relHtml = Object.entries(rels).filter(([, l]) => l.length).map(([t, l]) => `
<div class="kv"><span class="k">关联${TYPE_META[t].name}</span></div>
<div class="rel-list">${l.slice(0, 12).map((r) => `
<div class="rel-item" data-id="${esc(r.id)}">
<span class="ri-kind tag ${TYPE_META[t].tag}">${TYPE_META[t].name}</span>
<span class="ri-name">${esc(r.title)}</span>
${r.imp ? `<span class="ri-extra">${r.imp === "required" ? "必备" : "加分"}${r.pending ? "·待审核" : ""}</span>` : ""}
</div>`).join("")}
${l.length > 12 ? `<div class="text-weak text-aux" style="padding:2px 8px">…共 ${l.length} 项</div>` : ""}
</div>`).join("");
const srcList = [...sources];
const notes = nodeNotes(id);
const audit = await api(`/api/audit?node_id=${encodeURIComponent(id)}&limit=6`).catch(() => []);
const skillEvents = n.type === "Skill"
? G.events.filter((e) => e.skill === id).sort((a, b) => (a.window < b.window ? 1 : -1)).slice(0, 3) : [];
/* 证据来源行:可读文案「企业 · 平台 · 日期」异步填充,内部 source_id 不直接展示 */
const srcRowsHtml = srcList.length
? srcList.slice(0, 10).map((s, i) => `
<div class="src-item" data-sid="${esc(s)}">
<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
<span class="src-meta" data-sid="${esc(s)}">证据 JD · 第 ${i + 1} 条(来源信息加载中…)</span></div>`).join("")
+ (srcList.length > 10 ? `<div class="text-weak text-aux" style="padding:2px 8px">…共 ${srcList.length} 条</div>` : "")
: `<div class="text-weak text-aux">暂无来源记录</div>`;
/* 岗位节点四区块:结论 / 能力清单 / 近期变化 / 可信证据与操作 */
const reqSkills = rels.Skill.filter((r) => r.imp === "required");
const prefSkills = rels.Skill.filter((r) => r.imp !== "required");
const jobEvents = isJob
? G.events.filter((e) => e.job_id === id).sort((a, b) => (a.window < b.window ? 1 : -1)).slice(0, 3)
: [];
const dataCutoff = ((G.events.at(-1) || {}).window) || latestWin || "2026Q3";
const EV_TAG = { "新增": "tag-green", "增强": "tag-blue", "减弱": "tag-orange", "退出": "tag-red" };
const skillChip = (r, cls) => `<span class="tag ${cls} dt-skill" data-id="${esc(r.id)}" title="点击聚焦该技能">${esc(r.title)}</span>`;
const jobBlocks = isJob ? `
<div class="dt-sec"><div class="dt-sec-title">结论</div>
${jobDef && (jobDef.responsibilities || [])[0] ? `<div class="dt-def">${esc(jobDef.responsibilities[0])}</div>` : ""}
<div class="kv"><span class="k">级别</span><span class="v">${esc(n.level || jobLevel(n) || "—")}</span></div>
<div class="kv"><span class="k">类别</span><span class="v">${esc(n.category || "—")}</span></div>
<div class="dt-support">支撑 ${n.source_count ?? (jobDef || {}).source_count ?? "—"} 条 JD · ${n.company_count ?? (jobDef || {}).company_count ?? "—"} 家企业 · 数据截止 ${esc(dataCutoff)}</div>
</div>
<div class="dt-sec"><div class="dt-sec-title">能力清单</div>
<div class="kv"><span class="k">必备技能(${reqSkills.length})</span></div>
<div class="dt-chips">${reqSkills.map((r) => skillChip(r, "tag-green")).join("") || '<span class="text-weak text-aux">无</span>'}</div>
<div class="kv"><span class="k">加分技能(${prefSkills.length})</span></div>
<div class="dt-chips">${prefSkills.map((r) => skillChip(r, "tag-cyan")).join("") || '<span class="text-weak text-aux">无</span>'}</div>
</div>
<div class="dt-sec"><div class="dt-sec-title">近期变化</div>
${jobEvents.length ? jobEvents.map((e, i) => `
<div class="dt-ev" data-evi="${i}" title="点击查看证据 JD">
<span class="tag ${EV_TAG[e.change_type] || "tag-gray"}">${esc(e.change_type)}</span>
<span class="dt-ev-skill">${esc(e.skill)}</span>
<span class="text-weak text-aux">${esc(e.window)} · ${(e.source_ids || []).length} 条 JD 支撑</span>
</div>`).join("") : `<div class="text-weak text-aux">近期无变化记录</div>`}
</div>` : "";
$("#detail-empty").classList.add("hidden");
const body = $("#detail-body");
body.classList.remove("hidden");
body.innerHTML = `
<div class="dt-head">
<div class="dt-title-row">
<span class="dt-node-icon" style="background:${meta.color}">${NODE_ICONS[n.type] || NODE_ICONS.Job}</span>
<div>
<div class="dt-name">${esc(n.title || n.id)}</div>
<div class="dt-tags">
<span class="tag ${meta.tag}">${meta.name}</span>
${isPending ? `<span class="tag tag-orange">待审核</span>` : `<span class="tag tag-green">正式入库</span>`}
</div>
</div>
</div>
<button class="icon-btn sm dt-close" id="dt-close" title="关闭"><svg viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12"/></svg></button>
</div>
${G.navStack.length ? `<button class="btn btn-text btn-sm dt-back" id="dt-back">← 返回上一个节点</button>` : ""}
${jobBlocks}
${!isJob ? `<div class="dt-sec"><div class="dt-sec-title">基础信息</div>
${kv.map(([k, v]) => `<div class="kv"><span class="k">${k}</span><span class="v">${esc(v)}</span></div>`).join("") || `<div class="text-weak text-aux">暂无属性</div>`}
</div>` : ""}
${!isJob && relHtml ? `<div class="dt-sec"><div class="dt-sec-title">关联信息(点击跳转)</div>${relHtml}</div>` : ""}
${n.type === "Skill" ? `
<div class="dt-sec"><div class="dt-sec-title">演化信息</div>
<div class="sparkline-box"><canvas id="dt-spark" width="264" height="52"></canvas>
<div class="text-weak text-aux" style="margin-top:4px">近 12 个季度热度(活跃岗位关系数)</div></div>
${skillEvents.map((e) => `<div class="audit-item"><div class="au-head">
<span class="tag ${EV_TAG[e.change_type] || "tag-gray"}">${e.change_type}</span>
<b>${esc(e.job_id.replace(/^job_/, ""))}</b><span class="text-weak">${esc(e.window)}</span></div></div>`).join("")}
</div>` : ""}
<div class="dt-sec"><div class="dt-sec-title">可信证据(支撑 JD,点击查看原文)</div>
${srcRowsHtml}
</div>
<div class="dt-sec"><div class="dt-sec-title">操作</div>
<div class="dt-actions">
${isPending ? `
<button class="btn btn-primary btn-sm" id="dt-accept">确认入库</button>
<button class="btn btn-danger btn-sm" id="dt-reject">驳回</button>` : `
<button class="btn btn-secondary btn-sm" id="dt-edit">编辑节点</button>
<button class="btn btn-secondary btn-sm" id="dt-sources">查看支撑 JD</button>`}
<button class="btn btn-secondary btn-sm" id="dt-note">添加备注</button>
${isJob ? `<button class="btn btn-secondary btn-sm" id="dt-evo">查看演化</button>
<button class="btn btn-secondary btn-sm" id="dt-solo">岗位子图</button>
<button class="btn btn-primary btn-sm full" id="dt-match">开始人岗诊断</button>
<button class="btn btn-secondary btn-sm full" id="dt-learn">生成学习路径</button>` : ""}
</div>
</div>
<div class="dt-sec"><div class="dt-sec-title">备注(本地保存)</div>
<div id="dt-notes">${notes.map((t, i) => `
<div class="note-item"><div class="nt-head"><span>${esc(t.time)}</span>
<button class="nt-del" data-i="${i}">删除</button></div>${esc(t.text)}</div>`).join("") ||
`<div class="text-weak text-aux">暂无备注</div>`}</div>
</div>
<div class="dt-sec"><div class="dt-sec-title">审核留痕</div>
${audit.length ? audit.map((r) => `
<div class="audit-item">
<div class="au-head"><b>${esc(r.operator || "—")}</b><span class="text-weak">${esc(r.time || "")}</span></div>
<div class="au-diff">${auditDiff(r)}${r.reason ? `<br>原因:${esc(r.reason)}` : ""}</div>
</div>`).join("") : `<div class="text-weak text-aux">暂无留痕记录</div>`}
</div>`;
/* 绑定 */
$("#dt-close").onclick = () => selectNode(null, false);
const back = $("#dt-back");
if (back) back.onclick = () => { const prev = G.navStack.pop(); if (prev) selectNode(prev, false); };
body.querySelectorAll(".rel-item").forEach((el) => { el.onclick = () => locateNode(el.dataset.id); });
body.querySelectorAll(".dt-skill").forEach((el) => { el.onclick = () => locateNode(el.dataset.id); });
body.querySelectorAll(".src-item").forEach((el) => {
el.onclick = () => CN.showJdEvidence(el.dataset.sid, { jobTitle: n.title || n.id });
});
/* 来源行异步填充「企业 · 平台 · 日期」 */
body.querySelectorAll(".src-meta").forEach((el) => { CN.jdMetaLine(el, el.dataset.sid); });
body.querySelectorAll(".dt-ev").forEach((el) => {
el.onclick = () => {
const e = jobEvents[+el.dataset.evi];
CN.showJdEvidence(e.source_ids || [], {
skill: e.skill, jobTitle: n.title || n.id, changeType: e.change_type, window: e.window,
});
};
});
body.querySelectorAll(".nt-del").forEach((el) => {
el.onclick = () => { delNote(id, +el.dataset.i); renderDetail(id); };
});
$("#dt-note").onclick = async () => {
const text = await CN.sysPrompt({ title: `为「${n.title || n.id}」添加备注`, label: "备注内容:", placeholder: "如:评审时重点关注的技能" });
if (text && text.trim()) { addNote(id, text.trim()); renderDetail(id); toast("备注已保存", "success"); }
};
const srcBtn = $("#dt-sources");
if (srcBtn) srcBtn.onclick = () => {
if (srcList.length) CN.showJdEvidence(srcList, { jobTitle: n.title || n.id });
else toast("暂无来源记录", "info");
};
const editBtn = $("#dt-edit");
if (editBtn) editBtn.onclick = () => openEditModal(n);
const evoBtn = $("#dt-evo");
if (evoBtn) evoBtn.onclick = () => {
CN.switchView("evolution");
$("#evo-job-select").value = id;
$("#evo-job-select").dispatchEvent(new Event("change"));
};
const soloBtn = $("#dt-solo");
if (soloBtn) soloBtn.onclick = () => {
G.soloJob = G.soloJob === id ? null : id;
G._defaultApplied = true;
updateSoloBadge();
requestDraw();
if (G.soloJob) toast(`已进入「${n.title || id}」岗位子图(只看该岗位与一跳技能/场景/行业)`, "success");
else fitView();
};
const matchBtn = $("#dt-match");
if (matchBtn) matchBtn.onclick = () => {
CN.switchView("match");
$("#match-job-select").value = id;
$("#match-job-select").dispatchEvent(new Event("change"));
toast("已带入目标岗位,请上传或载入示例简历", "success");
};
const learnBtn = $("#dt-learn");
if (learnBtn) learnBtn.onclick = () => {
CN.switchView("path");
$("#path-job-select").value = id;
$("#btn-path").click();
};
const accBtn = $("#dt-accept"), rejBtn = $("#dt-reject");
if (accBtn) accBtn.onclick = () => reviewCandidate(id, "accept");
if (rejBtn) rejBtn.onclick = () => reviewCandidate(id, "reject");
/* 技能 sparkline */
if (n.type === "Skill") drawSkillSpark(id);
}
function auditDiff(r) {
const parts = [];
const before = r.before || {}, after = r.after || {};
Object.keys(after).forEach((k) => {
if (["job_id", "responsibilities", "scenarios", "required_skills", "preferred_skills"].includes(k)) return;
parts.push(`${esc(k)}: ${esc(JSON.stringify(before[k]) ?? "—")} → ${esc(JSON.stringify(after[k]))}`);
});
if (!parts.length && r.action) parts.push(`动作:${esc(r.action)}`);
return parts.join("<br>") || "—";
}
function drawSkillSpark(skillId) {
const c = $("#dt-spark");
if (!c) return;
/* 各季度活跃关系数:intervals 覆盖窗口即活跃 */
const wins = [...new Set(G.edges.flatMap((e) => (e.intervals || []).map((i) => i[0])))].sort();
const last12 = wins.slice(-12);
const values = last12.map((w) => G.edges.filter((e) =>
(e.source === skillId || e.target === skillId) &&
(e.intervals || []).some(([f, t]) => f <= w && (t === null || w < t))).length);
const ctx = c.getContext("2d");
ctx.clearRect(0, 0, c.width, c.height);
const max = Math.max(1, ...values);
values.forEach((v, i) => {
const bw = c.width / values.length;
const h = (v / max) * (c.height - 16);
ctx.fillStyle = "#2f54eb";
ctx.fillRect(i * bw + 2, c.height - h - 12, bw - 4, h);
if (i % 3 === 0) {
ctx.fillStyle = "#86909c"; ctx.font = "9px sans-serif"; ctx.textAlign = "center";
ctx.fillText(last12[i].slice(2), i * bw + bw / 2, c.height - 2);
}
});
}
/* ================= 节点编辑弹窗 ================= */
function openEditModal(n) {
openModal(`编辑节点 · ${esc(n.title || n.id)}`, `
<div class="form-row"><label class="form-label">名称</label>
<input id="ef-title" class="input" style="width:100%" value="${esc(n.title || "")}"></div>
<div class="form-row"><label class="form-label">级别</label>
<select id="ef-level" class="select" style="width:100%">
<option value="">未设置</option>
${["初级", "中级", "高级"].map((l) => `<option ${n.level === l ? "selected" : ""}>${l}</option>`).join("")}
</select></div>
<div class="form-row"><label class="form-label">重要性</label>
<select id="ef-importance" class="select" style="width:100%">
<option value="">未设置</option>
<option value="required" ${n.importance === "required" ? "selected" : ""}>必备</option>
<option value="preferred" ${n.importance === "preferred" ? "selected" : ""}>加分</option>
</select></div>
<div class="form-row"><label class="form-label">需求指数(0-1)</label>
<input id="ef-weight" type="number" min="0" max="1" step="0.05" class="input" style="width:100%" value="${n.weight ?? ""}"></div>
<div class="form-row"><label class="form-label">操作人</label>
<input id="ef-operator" class="input" style="width:100%" value="评审专家"></div>
<div class="form-row"><label class="form-label">修改原因</label>
<input id="ef-reason" class="input" style="width:100%" placeholder="选填,将写入审核留痕"></div>`,
`<button class="btn btn-secondary" id="ef-cancel">取消</button>
<button class="btn btn-primary" id="ef-submit">提交修改</button>`);
$("#ef-cancel").onclick = closeModal;
$("#ef-submit").onclick = async () => {
const updates = {};
const title = $("#ef-title").value.trim();
if (title && title !== (n.title || "")) updates.title = title;
if ($("#ef-level").value) updates.level = $("#ef-level").value;
if ($("#ef-importance").value) updates.importance = $("#ef-importance").value;
if ($("#ef-weight").value !== "") updates.weight = +$("#ef-weight").value;
if (!Object.keys(updates).length) return toast("没有需要提交的修改", "info");
try {
await post("/api/graph/node/update", {
node_id: n.id, updates,
operator: $("#ef-operator").value.trim() || "评审专家",
reason: $("#ef-reason").value.trim(),
});
Object.assign(n, updates);
closeModal();
toast("节点已更新并留痕", "success");
renderDetail(n.id);
requestDraw();
} catch (e) { toast(e.message, "error"); }
};
}
/* ================= 候选审核 ================= */
async function reviewCandidate(id, action) {
await CN.doReview(id, action);
}
/* ================= 对外接口 ================= */
CN.graph = {
inited: false,
_p: null,
async init() {
if (this.inited) return;
this.inited = true;
bindCanvas();
bindToolbar();
this._p = load();
await this._p;
},
/* 数据装载完成 Promise(演示导览等需要等图谱就绪后定位) */
ready() { return this._p || Promise.resolve(); },
has(id) { return id in G.idx; },
/* 进入指定岗位的局部子图(演示导览第 3 步) */
solo(id) {
if (!(id in G.idx)) return;
G._defaultApplied = true;
G.soloJob = id;
updateSoloBadge();
selectNode(id, false);
centerOn(G.nodes[G.idx[id]], Math.max(G.cam.k, 1.05));
requestDraw();
},
onShow() { requestDraw(); drawMinimap(); },
async reload() {
await load();
if (G.selected && !(G.selected in G.idx)) selectNode(null, false);
else if (G.selected) renderDetail(G.selected);
},
};
})();