答辩级 UI 改造:指挥台驾驶舱/证据地图/演化生命线/成长路线图/候选审核台

- 驾驶舱改'人才能力变化指挥台':变化事件流主图+可信度卡+行动卡+播报卡
- 图谱子图同心分层布局、技能变化角标、系统发现折叠入右栏、主操作收敛
- 演化页双 Tab(岗位报告/全岗位扫描)+ 能力生命线 Swimlane + 更替流
- 学习路径成长路线图:概要条/示范路线/预计投入/标记完成联动
- 新增候选审核台抽屉 + GET /api/stats/credibility
- 简历裸姓名/专硕提取修复(桌面真实简历验收通过)+ BGE-M3 加载提示
This commit is contained in:
RoyirinRosmontis
2026-08-19 12:14:39 +08:00
parent ac6d08cdf2
commit 0b198e9976
9 changed files with 1002 additions and 238 deletions
+201 -29
View File
@@ -26,6 +26,67 @@
const NOTES_KEY = "cn_notes";
const VIEW_KEY = "cn_graph_view";
const DEFAULT_SOLO_JOB = "job_Java开发工程师"; // 首屏默认岗位子图(局部可读)
/* 变化类型颜色(与 app.js CHANGE_META 一致):新增=青绿 / 增强=蓝紫 / 减弱=琥珀 / 退出=砖红 */
const CHANGE_COLORS = { "新增": "#13c2c2", "增强": "#722ed1", "减弱": "#ff7d00", "退出": "#f53f3f" };
const CHANGE_MARK = { "新增": "新", "增强": "↑", "减弱": "↓", "退出": "退" };
/* 变化角标数据:最近 2 季度有事件的技能(子图模式下按当前岗位过滤) */
function updateRecentChange() {
const wins = [...new Set(G.events.map((e) => e.window))].sort().slice(-2);
const m = new Map();
G.events.forEach((e) => {
if (!wins.includes(e.window)) return;
if (G.soloJob && e.job_id !== G.soloJob) return;
m.set(e.skill, e); // 事件按窗口升序,后者覆盖前者 = 取最近事件
});
G.recentChange = m;
}
/* 同心分层布局(岗位子图):中心岗位 → 第一圈必备技能 → 第二圈加分技能 → 外圈场景/行业 */
function applySoloLayout() {
const job = G.nodes[G.idx[G.soloJob]];
if (!job) return;
const rings = [[], [], []];
G.edges.forEach((e) => {
if (e.source !== G.soloJob) return;
const t = G.nodes[G.idx[e.target]];
if (!t) return;
if (e.rel === "REQUIRES") rings[e.importance === "required" ? 0 : 1].push(t);
else rings[2].push(t); // APPLIES_TO / BELONGS_TO → 外圈
});
const base = [150, 260, 370];
let prevR = 0;
rings.forEach((nodes, ri) => {
nodes.sort((a, b) => String(a.title || a.id).localeCompare(String(b.title || b.id), "zh"));
const R = Math.max(base[ri], prevR + 90, (nodes.length * 34) / (2 * Math.PI));
prevR = R;
nodes.forEach((n, i) => {
const a = (i / Math.max(1, nodes.length)) * Math.PI * 2 - Math.PI / 2;
n.x = job.x + Math.cos(a) * R;
n.y = job.y + Math.sin(a) * R;
n.vx = 0; n.vy = 0;
});
});
}
/* 视野适配子图(只按子图节点的包围盒,而非全图) */
function fitSoloView() {
const c = canvas();
const ids = new Set([G.soloJob, ...neighborsOf(G.soloJob)]);
let minX = 1e9, minY = 1e9, maxX = -1e9, maxY = -1e9;
G.nodes.forEach((n) => {
if (!ids.has(n.id) || !visible(n)) return;
minX = Math.min(minX, n.x); maxX = Math.max(maxX, n.x);
minY = Math.min(minY, n.y); maxY = Math.max(maxY, n.y);
});
if (minX > maxX) return fitView();
const w = maxX - minX || 1, h = maxY - minY || 1;
const W = c.clientWidth, H = c.clientHeight;
G.cam.k = Math.max(0.2, Math.min(W / (w + 160), H / (h + 160), 1.6));
G.cam.x = (W - w * G.cam.k) / 2 - minX * G.cam.k;
G.cam.y = (H - h * G.cam.k) / 2 - minY * G.cam.k;
requestDraw(); drawMinimap();
}
/* ================= 数据装载 ================= */
async function load() {
@@ -62,14 +123,17 @@
buildFilterOptions();
layout();
restoreView() || fitView();
/* 默认进入「Java开发工程师」岗位子图:首屏局部可读,顶栏徽标可退出全图(每次装载仅应用一次) */
/* 默认进入「Java开发工程师」岗位子图:同心分层布局(中心岗位 → 必备 → 加分 → 场景/行业),
* 退出子图即全量「岗位生态总览」(每次装载仅应用一次) */
if (!G._defaultApplied && !G.pendingLocate && G.idx[DEFAULT_SOLO_JOB]) {
G._defaultApplied = true;
G.soloJob = DEFAULT_SOLO_JOB;
updateSoloBadge();
applySoloLayout();
selectNode(DEFAULT_SOLO_JOB, false);
centerOn(G.nodes[G.idx[DEFAULT_SOLO_JOB]], Math.max(G.cam.k, 1.05));
fitSoloView();
}
updateRecentChange();
renderSuggest();
renderChain(G.soloJob || null);
$("#graph-loading").classList.add("hidden");
@@ -311,8 +375,39 @@
ctx.fillStyle = n.type === "Job" ? "#1d2129" : "#4e5969";
ctx.fillText(label, p.x, p.y - r - 7);
}
/* 变化角标:最近 2 季度有事件的技能节点右上角显示 新/↑/↓/退 */
const ch = n.type === "Skill" && G.recentChange ? G.recentChange.get(n.id) : null;
if (ch && !dim) {
const mark = CHANGE_MARK[ch.change_type] || "";
const bc = CHANGE_COLORS[ch.change_type] || "#86909c";
const bx = p.x + r * 0.95, by = p.y - r * 0.95;
ctx.fillStyle = bc;
ctx.beginPath(); ctx.arc(bx, by, 7, 0, 7); ctx.fill();
ctx.strokeStyle = "#fff"; ctx.lineWidth = 1.2;
ctx.beginPath(); ctx.arc(bx, by, 7, 0, 7); ctx.stroke();
ctx.fillStyle = "#fff"; ctx.font = "bold 8px sans-serif";
ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.fillText(mark, bx, by + 0.5);
ctx.textBaseline = "alphabetic";
}
ctx.restore();
});
/* 悬停提示:有近期变化的技能显示变化时间与证据量 */
if (G.hover && G.recentChange && G.recentChange.has(G.hover) && G.idx[G.hover] != null) {
const n = G.nodes[G.idx[G.hover]];
const e = G.recentChange.get(G.hover);
const p = toScreen(n);
const r = Math.max(2.5, nodeRadius(n) * Math.sqrt(G.cam.k));
const txt = `${e.window} ${e.change_type} · 支撑 ${(e.source_ids || []).length} 条 JD`;
ctx.font = "11px sans-serif";
const tw = ctx.measureText(txt).width;
const bx = Math.max(4, Math.min(c.clientWidth - tw - 20, p.x - tw / 2 - 8));
ctx.fillStyle = "rgba(29,33,41,0.92)";
ctx.beginPath(); ctx.roundRect(bx, p.y + r + 10, tw + 16, 20, 4); ctx.fill();
ctx.fillStyle = "#fff"; ctx.textAlign = "center";
ctx.fillText(txt, bx + tw / 2 + 8, p.y + r + 24);
}
}
/* ================= 小地图 ================= */
@@ -460,17 +555,56 @@
$("#fp-category").value = ""; $("#fp-level").value = "";
requestDraw(); drawMinimap();
};
/* 头部按钮 */
/* 头部主操作:查看演化 / 查看证据 / 开始匹配;保存/分享/导出收进「更多」 */
const activeJobId = () => {
const n = G.selected != null ? G.nodes[G.idx[G.selected]] : null;
if (n && n.type === "Job" && !n.pending) return G.selected;
if (G.soloJob) return G.soloJob;
return DEFAULT_SOLO_JOB;
};
$("#btn-goto-evo").onclick = () => {
CN.switchView("evolution");
$("#evo-job-select").value = activeJobId();
$("#evo-job-select").dispatchEvent(new Event("change"));
};
$("#btn-goto-match").onclick = () => {
CN.switchView("match");
$("#match-job-select").value = activeJobId();
$("#match-job-select").dispatchEvent(new Event("change"));
toast("已带入目标岗位,请上传或载入示例简历", "success");
};
$("#btn-goto-evidence").onclick = () => {
const id = G.selected || G.soloJob;
if (!id) return toast("请先在画布中选择一个节点", "info");
const sources = new Set();
G.edges.forEach((e) => {
if (e.source === id || e.target === id) (e.sources || []).forEach((s) => sources.add(s));
});
const list = [...sources];
if (!list.length) return toast("该节点暂无来源证据记录", "info");
const n = G.nodes[G.idx[id]];
CN.showJdEvidence(list, { jobTitle: n.title || id });
};
$("#btn-more-menu").onclick = (e) => {
e.stopPropagation();
$("#more-menu").classList.toggle("hidden");
};
document.addEventListener("click", (e) => {
if (!e.target.closest(".more-wrap")) $("#more-menu").classList.add("hidden");
});
$("#btn-save-view").onclick = () => {
localStorage.setItem(VIEW_KEY, JSON.stringify({ cam: G.cam, filters: G.filters, labels: G.showLabels }));
$("#more-menu").classList.add("hidden");
toast("当前视图(位置/缩放/筛选)已保存", "success");
};
$("#btn-share-graph").onclick = async () => {
const url = `${location.origin}/#view=graph${G.selected ? `&node=${encodeURIComponent(G.selected)}` : ""}`;
$("#more-menu").classList.add("hidden");
try { await navigator.clipboard.writeText(url); toast("分享链接已复制到剪贴板", "success"); }
catch { CN.sysCopy({ title: "分享链接(请复制)", text: url }); }
};
$("#btn-export-graph").onclick = () => {
$("#more-menu").classList.add("hidden");
const c = canvas();
const tmp = document.createElement("canvas");
tmp.width = c.width; tmp.height = c.height;
@@ -487,7 +621,6 @@
toast("图谱 PNG 已导出", "success");
});
};
$("#gs-toggle").onclick = () => $("#graph-suggest").classList.toggle("collapsed");
}
function restoreView() {
@@ -511,34 +644,61 @@
} catch { return false; }
}
/* ================= 智能建议 ================= */
/* ================= 系统发现(原画布浮层「智能建议」,移入右侧详情面板底部可折叠区块) ================= */
const IGNORED_KEY = "cn_ignored_findings";
const ignoredSet = () => new Set(JSON.parse(localStorage.getItem(IGNORED_KEY) || "[]"));
function renderSuggest() {
const ig = ignoredSet();
const items = [];
G.pending.forEach((c) => items.push({
color: "#ff7d00", kind: "pending", id: c.job_id,
color: "#ff7d00", kind: "pending", id: c.job_id, nodeId: c.job_id,
text: `新岗位候选「${esc(c.title)}」待审核`,
sub: `${c.source_count} 条 JD · ${c.company_count} 家企业支撑`,
sub: `原因:聚类发现 ${c.source_count} 条 JD / ${c.company_count} 家企业指向未收录岗位 · 证据 ${c.source_count} 条`,
}));
G.events.slice(-4).reverse().forEach((e) => items.push({
color: "#2f54eb", kind: "event", id: e.job_id,
color: "#2f54eb", kind: "event", id: `${e.job_id}|${e.skill}|${e.window}`, nodeId: 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)}`,
sub: `证据:窗口 ${esc(e.window)} · 需求指数 ${e.prev_weight.toFixed(2)}→${e.curr_weight.toFixed(2)} · ${(e.source_ids || []).length} 条 JD`,
}));
$("#gs-list").innerHTML = items.length ? items.map((it, i) => `
const list = items.filter((it) => !ig.has(`${it.kind}|${it.id}`));
$("#gs-list").innerHTML = list.length ? list.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>`;
<div class="gs-body">
<div class="gs-text">${it.text}</div>
<div class="gs-sub">${it.sub}</div>
<div class="gs-acts">
<button class="link-btn" data-accept="${i}">${it.kind === "pending" ? "采纳(去审核台)" : "采纳(去演化页)"}</button>
<button class="link-btn gs-ignore" data-ignore="${i}">忽略</button>
</div>
</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);
if (ev.target.dataset.accept !== undefined || ev.target.dataset.ignore !== undefined) return;
locateNode(list[+el.dataset.i].nodeId);
};
});
$$("#gs-list [data-review]").forEach((a) => {
a.onclick = () => locateNode(a.dataset.review);
$$("#gs-list [data-accept]").forEach((b) => {
b.onclick = () => {
const it = list[+b.dataset.accept];
if (it.kind === "pending") {
if (CN.openReviewDesk) CN.openReviewDesk(it.id);
} else {
CN.switchView("evolution");
$("#evo-job-select").value = it.nodeId;
$("#evo-job-select").dispatchEvent(new Event("change"));
}
};
});
$$("#gs-list [data-ignore]").forEach((b) => {
b.onclick = () => {
const it = list[+b.dataset.ignore];
const s = ignoredSet();
s.add(`${it.kind}|${it.id}`);
localStorage.setItem(IGNORED_KEY, JSON.stringify([...s]));
renderSuggest();
};
});
}
@@ -605,10 +765,11 @@
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();
updateRecentChange();
fitView();
}
selectNode(id);
@@ -626,7 +787,13 @@
}
}
const soloExit = $("#solo-exit");
if (soloExit) soloExit.onclick = () => { G.soloJob = null; updateSoloBadge(); fitView(); };
if (soloExit) soloExit.onclick = () => {
G.soloJob = null;
updateSoloBadge();
updateRecentChange();
fitView();
toast("已退出子图,当前为岗位生态总览(全量图)", "info");
};
/* ================= 备注(localStorage) ================= */
const getNotes = () => JSON.parse(localStorage.getItem(NOTES_KEY) || "{}");
@@ -722,7 +889,7 @@
? 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 EV_TAG = { "新增": "tag-cyan", "增强": "tag-purple", "减弱": "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>
@@ -789,8 +956,8 @@
<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-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>
@@ -860,9 +1027,12 @@
G.soloJob = G.soloJob === id ? null : id;
G._defaultApplied = true;
updateSoloBadge();
requestDraw();
if (G.soloJob) toast(`已进入「${n.title || id}」岗位子图(只看该岗位与一跳技能/场景/行业)`, "success");
else fitView();
updateRecentChange();
if (G.soloJob) {
applySoloLayout();
fitSoloView();
toast(`已进入「${n.title || id}」岗位子图(同心分层:中心岗位 → 必备 → 加分 → 场景/行业)`, "success");
} else fitView();
};
const matchBtn = $("#dt-match");
if (matchBtn) matchBtn.onclick = () => {
@@ -988,19 +1158,21 @@
/* 数据装载完成 Promise(演示导览等需要等图谱就绪后定位) */
ready() { return this._p || Promise.resolve(); },
has(id) { return id in G.idx; },
/* 进入指定岗位的局部子图(演示导览第 3 步) */
/* 进入指定岗位的局部子图(演示导览第 3 步):同心分层布局 */
solo(id) {
if (!(id in G.idx)) return;
G._defaultApplied = true;
G.soloJob = id;
updateSoloBadge();
updateRecentChange();
applySoloLayout();
selectNode(id, false);
centerOn(G.nodes[G.idx[id]], Math.max(G.cam.k, 1.05));
requestDraw();
fitSoloView();
},
onShow() { requestDraw(); drawMinimap(); },
async reload() {
await load();
if (G.soloJob && G.idx[G.soloJob]) { applySoloLayout(); fitSoloView(); } // 子图模式下重排同心分层
if (G.selected && !(G.selected in G.idx)) selectNode(null, false);
else if (G.selected) renderDetail(G.selected);
},