`;
- $("#trust-retry").onclick = renderTrust;
+ $("#trust-retry").onclick = () => renderTrust();
}
}
@@ -473,14 +505,20 @@ function renderTaskTable(audit) {
}).join("");
}
-/* 变化播报卡:技能图标 + 变化类型 + 影响岗位 + 证据数 + 跳转演化页(审核动作归入台账) */
+/* 变化播报:最新 5 条卡片翻页轮播(3 秒淡入淡出自动切换,悬停暂停,点击跳演化页) */
function renderFeed(events) {
const verb = { "新增": "成为新要求", "增强": "需求显著增强", "减弱": "需求明显减弱", "退出": "退出能力画像" };
- const items = (events || []).slice(-6).reverse();
+ const items = (events || []).slice(-5).reverse();
+ const box = $("#activity-feed");
+ if (CN._bcastTimer) { clearInterval(CN._bcastTimer); CN._bcastTimer = null; }
const jobTitle = (id) => ((CN.jobs || []).find((j) => j.job_id === id) || {}).title || id.replace(/^job_/, "");
- $("#activity-feed").innerHTML = items.length ? items.map((e, i) => {
+ if (!items.length) {
+ box.innerHTML = `
${items.map((e, i) => {
const m = CHANGE_META[e.change_type] || { tag: "tag-gray", color: "#86909c" };
- return `
+ return `
@@ -489,8 +527,24 @@ function renderFeed(events) {
${esc(e.change_type)}
`;
- }).join("") : `
暂无变化播报
`;
- $$("#activity-feed .feed-item").forEach((el) => {
+ }).join("")}
+
${items.map((_, i) => ``).join("")}
`;
+ const cards = $$("#activity-feed .bcast-card");
+ const dots = $$("#activity-feed .bcast-dots i");
+ let idx = 0;
+ const show = (k) => {
+ idx = k;
+ cards.forEach((c, j) => c.classList.toggle("on", j === k));
+ dots.forEach((d, j) => d.classList.toggle("on", j === k));
+ };
+ const start = () => {
+ if (CN._bcastTimer) clearInterval(CN._bcastTimer);
+ CN._bcastTimer = setInterval(() => show((idx + 1) % items.length), 3000);
+ };
+ start();
+ box.onmouseenter = () => { if (CN._bcastTimer) { clearInterval(CN._bcastTimer); CN._bcastTimer = null; } };
+ box.onmouseleave = start;
+ cards.forEach((el) => {
el.onclick = () => {
const e = items[+el.dataset.i];
switchView("evolution");
@@ -498,13 +552,17 @@ function renderFeed(events) {
loadEvolution();
};
});
+ dots.forEach((d) => { d.onclick = () => { show(+d.dataset.i); start(); } });
}
/* 待办行动卡(最多 3 张:结论 + 证据强度 + 处理按钮;完整台账见审核台/下方台账) */
function renderPending(pending) {
const box = $("#pending-list");
if (!pending.length) {
- box.innerHTML = `
暂无待审核候选(可在图谱页触发新岗位发现)
`;
+ box.innerHTML = `
${EMPTY_SVG}
+
暂无待审核候选(可在图谱页触发新岗位发现)
+
`;
+ $("#pending-goto-graph").onclick = () => switchView("graph");
return;
}
box.innerHTML = pending.slice(0, 3).map((c) => {
@@ -608,7 +666,13 @@ async function renderReviewDesk(focusId) {
return;
}
if (!pending.length) {
- box.innerHTML = `
暂无待审核候选(可在图谱页触发新岗位发现)
`;
+ box.innerHTML = `
${EMPTY_SVG}
+
暂无待审核候选
可在图谱页触发新岗位发现,候选经人工审核后才会入图
+
`;
+ $("#desk-goto-graph").onclick = () => {
+ $("#review-desk-mask").classList.add("hidden");
+ switchView("graph");
+ };
return;
}
const levelTag = { "高": "tag-green", "较高": "tag-cyan", "中": "tag-orange", "低": "tag-red" };
@@ -692,55 +756,116 @@ function fitCanvas(canvas, cssH) {
return { ctx, W, H };
}
-/* 变化事件流:近 N 季度全岗位口径 新增/增强/减弱/退出 堆叠柱,点击柱段跳演化页对应岗位 */
-function drawEventFlow(canvas, events, n) {
- const { ctx, W, H } = fitCanvas(canvas, 220);
+/* 季度变化事件序列(纯逻辑,node 可测):近 n 个季度,每季总量 + 四类变化计数 */
+function trendSeries(events, n) {
+ const windows = [...new Set(events.map((e) => e.window))].sort().slice(-n);
+ return windows.map((w) => {
+ const byType = {};
+ Object.keys(CHANGE_META).forEach((t) => { byType[t] = 0; });
+ let total = 0;
+ events.forEach((e) => {
+ if (e.window !== w) return;
+ total += 1;
+ if (e.change_type in byType) byType[e.change_type] += 1;
+ });
+ return { window: w, total, byType };
+ });
+}
+
+/* Catmull-Rom 转三次贝塞尔:过点平滑曲线(canvas 绘制辅助) */
+function smoothPath(ctx, pts) {
+ ctx.moveTo(pts[0].x, pts[0].y);
+ for (let i = 0; i < pts.length - 1; i++) {
+ const p0 = pts[Math.max(0, i - 1)], p1 = pts[i];
+ const p2 = pts[i + 1], p3 = pts[Math.min(pts.length - 1, i + 2)];
+ ctx.bezierCurveTo(
+ p1.x + (p2.x - p0.x) / 6, p1.y + (p2.y - p0.y) / 6,
+ p2.x - (p3.x - p1.x) / 6, p2.y - (p3.y - p1.y) / 6,
+ p2.x, p2.y);
+ }
+}
+
+/* 人才能力变化趋势:平滑折线 + 面积渐变 + hover 数值 tooltip,点击定位该季度变化最多的岗位 */
+function drawTrendLine(canvas, events, n) {
+ const { ctx, W, H } = fitCanvas(canvas, 240);
ctx.clearRect(0, 0, W, H);
$("#cmd-stack-legend").innerHTML = Object.entries(CHANGE_META).map(([t, m]) =>
`
${t}`).join("");
- const windows = [...new Set(events.map((e) => e.window))].sort().slice(-n);
- if (!windows.length) return;
- const types = Object.keys(CHANGE_META);
- const counts = windows.map((w) => {
- const row = {};
- types.forEach((t) => { row[t] = events.filter((e) => e.window === w && e.change_type === t).length; });
- return row;
- });
- const maxTotal = Math.max(1, ...counts.map((r) => types.reduce((a, t) => a + r[t], 0)));
- const bw = W / windows.length;
- const baseY = H - 22;
- ctx.font = "10px sans-serif"; ctx.textAlign = "center";
- windows.forEach((w, i) => {
- let y = baseY;
- types.forEach((t) => {
- const v = counts[i][t];
- if (!v) return;
- const h = (v / maxTotal) * (H - 52);
- ctx.fillStyle = CHANGE_META[t].color;
- ctx.fillRect(i * bw + bw * 0.2, y - h, bw * 0.6, h);
- y -= h;
- });
- const total = types.reduce((a, t) => a + counts[i][t], 0);
- if (total) { ctx.fillStyle = "#4e5969"; ctx.fillText(total, i * bw + bw / 2, y - 4); }
+ const kv = trendSeries(events, n);
+ if (!kv.length) return;
+ const pad = { l: 34, r: 14, t: 14, b: 26 };
+ const max = Math.max(1, ...kv.map((p) => p.total));
+ /* y 轴网格与刻度 */
+ ctx.strokeStyle = "#f0f0f0"; ctx.fillStyle = "#86909c"; ctx.font = "10px sans-serif";
+ for (let g = 0; g <= 4; g++) {
+ const y = pad.t + ((H - pad.t - pad.b) * g) / 4;
+ ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(W - pad.r, y); ctx.stroke();
+ ctx.textAlign = "right";
+ ctx.fillText(Math.round(max * (1 - g / 4)), pad.l - 6, y + 3);
+ }
+ const px = (i) => pad.l + (i / Math.max(1, kv.length - 1)) * (W - pad.l - pad.r);
+ const py = (v) => pad.t + (1 - v / max) * (H - pad.t - pad.b);
+ const pts = kv.map((p, i) => ({ x: px(i), y: py(p.total) }));
+ const hover = canvas._hoverIdx;
+ /* 平滑折线 + 面积渐变 */
+ ctx.beginPath();
+ smoothPath(ctx, pts);
+ ctx.strokeStyle = "#1890ff"; ctx.lineWidth = 2; ctx.stroke();
+ ctx.lineTo(pts[pts.length - 1].x, H - pad.b); ctx.lineTo(pts[0].x, H - pad.b); ctx.closePath();
+ const grad = ctx.createLinearGradient(0, pad.t, 0, H - pad.b);
+ grad.addColorStop(0, "rgba(24,144,255,0.22)"); grad.addColorStop(1, "rgba(24,144,255,0.02)");
+ ctx.fillStyle = grad; ctx.fill();
+ /* 数据点 + x 轴季度标签 */
+ ctx.textAlign = "center";
+ pts.forEach((p, i) => {
+ ctx.fillStyle = i === hover ? "#096dd9" : "#1890ff";
+ ctx.beginPath(); ctx.arc(p.x, p.y, i === hover ? 4.5 : 3, 0, 7); ctx.fill();
ctx.fillStyle = "#86909c";
- ctx.fillText(w.slice(2), i * bw + bw / 2, H - 6);
+ ctx.fillText(kv[i].window.slice(2), p.x, H - 8);
});
- canvas._bars = windows.map((w, i) => ({ w, x0: i * bw, x1: (i + 1) * bw }));
+
+ const nearestIdx = (clientX) => {
+ const x = clientX - canvas.getBoundingClientRect().left;
+ let best = null, bd = 1e9;
+ pts.forEach((p, i) => { const d = Math.abs(p.x - x); if (d < bd) { bd = d; best = i; } });
+ return { best, bd };
+ };
+ canvas.onmousemove = (ev) => {
+ const tip = $("#trend-tip");
+ const { best, bd } = nearestIdx(ev.clientX);
+ if (best == null || bd > 48) {
+ if (canvas._hoverIdx != null) { canvas._hoverIdx = null; drawTrendLine(canvas, events, n); }
+ tip.classList.add("hidden");
+ return;
+ }
+ if (canvas._hoverIdx !== best) { canvas._hoverIdx = best; drawTrendLine(canvas, events, n); }
+ const p = kv[best];
+ const tip2 = $("#trend-tip");
+ tip2.innerHTML = `
${esc(p.window)} · 变化事件 ${p.total} 项
` +
+ (Object.entries(p.byType).filter(([, v]) => v).map(([t, v]) => `${t} ${v}`).join(" · ") || "本季度无四类变化");
+ tip2.classList.remove("hidden");
+ const ox = canvas.offsetLeft, oy = canvas.offsetTop;
+ tip2.style.left = `${Math.min(Math.max(pts[best].x + ox + 12, 4), W + ox - 150)}px`;
+ tip2.style.top = `${Math.max(pts[best].y + oy - 12, 4)}px`;
+ };
+ canvas.onmouseleave = () => {
+ $("#trend-tip").classList.add("hidden");
+ if (canvas._hoverIdx != null) { canvas._hoverIdx = null; drawTrendLine(canvas, events, n); }
+ };
canvas.onclick = (ev) => {
- const rect = canvas.getBoundingClientRect();
- const x = ev.clientX - rect.left;
- const bar = (canvas._bars || []).find((b) => x >= b.x0 && x < b.x1);
- if (!bar) return;
+ const { best } = nearestIdx(ev.clientX);
+ if (best == null) return;
+ const w = kv[best].window;
/* 跳到该季度事件最多的岗位演化报告 */
const byJob = {};
- events.filter((e) => e.window === bar.w).forEach((e) => { byJob[e.job_id] = (byJob[e.job_id] || 0) + 1; });
+ events.filter((e) => e.window === w).forEach((e) => { byJob[e.job_id] = (byJob[e.job_id] || 0) + 1; });
const top = Object.entries(byJob).sort((a, b) => b[1] - a[1])[0];
switchView("evolution");
if (top && $("#evo-job-select").querySelector(`option[value="${top[0]}"]`)) {
$("#evo-job-select").value = top[0];
loadEvolution();
}
- toast(`已定位 ${bar.w} 季度变化最多的岗位`, "info");
+ toast(`已定位 ${w} 季度变化最多的岗位`, "info");
};
}
@@ -1037,15 +1162,15 @@ function drawHeatLine(canvas, kv) {
/* 面积 + 折线 */
ctx.beginPath();
kv.forEach(([, v], i) => ctx[i ? "lineTo" : "moveTo"](px(i), py(v)));
- ctx.strokeStyle = "#2f54eb"; ctx.lineWidth = 2; ctx.stroke();
+ ctx.strokeStyle = "#1890ff"; ctx.lineWidth = 2; ctx.stroke();
ctx.lineTo(px(kv.length - 1), H - pad.b); ctx.lineTo(px(0), H - pad.b); ctx.closePath();
const grad = ctx.createLinearGradient(0, pad.t, 0, H - pad.b);
- grad.addColorStop(0, "rgba(47,84,235,0.25)"); grad.addColorStop(1, "rgba(47,84,235,0.02)");
+ grad.addColorStop(0, "rgba(24,144,255,0.25)"); grad.addColorStop(1, "rgba(24,144,255,0.02)");
ctx.fillStyle = grad; ctx.fill();
/* 点与 x 轴 */
ctx.textAlign = "center";
kv.forEach(([k, v], i) => {
- ctx.fillStyle = "#2f54eb";
+ ctx.fillStyle = "#1890ff";
ctx.beginPath(); ctx.arc(px(i), py(v), 3, 0, 7); ctx.fill();
if (kv.length <= 14 || i % Math.ceil(kv.length / 14) === 0) {
ctx.fillStyle = "#86909c"; ctx.fillText(k, px(i), H - 8);
@@ -1093,22 +1218,65 @@ function drawStackBars(canvas, events) {
/* 能力生命线(Swimlane):每个核心技能一行(近 8 季度),线段=生效区间,圆点=变更事件。
* 行状态取该技能最近一次事件:新增=青绿 / 增强=蓝紫 / 减弱=琥珀 / 退出=砖红 / 持续=蓝灰 */
+/* 能力更替分桶(纯逻辑,node 可测):旧核心(已退出) → 持续能力 → 新核心(近 4 季度进入) */
+function computeReplaceBuckets(edges, windows) {
+ const wins = (windows || []).slice().sort();
+ const recent4 = new Set(wins.slice(-4));
+ const alive = (e) => e.intervals.some(([s, en]) => en === null);
+ const start = (e) => e.intervals.map((i) => i[0]).sort()[0];
+ return {
+ oldCore: edges.filter((e) => !alive(e)),
+ cont: edges.filter((e) => alive(e) && !recent4.has(start(e))),
+ newCore: edges.filter((e) => alive(e) && recent4.has(start(e))),
+ };
+}
+
+/* 驾驶舱「岗位能力更替」卡:全岗位口径近 4 季度(与演化页更替流共用分桶逻辑) */
+function renderCmdReplace() {
+ const box = $("#cmd-replace");
+ const g = CN.graphData || {};
+ const wins = [...new Set((g.events || []).map((e) => e.window))].sort();
+ const edges = (g.edges || []).filter((e) => e.rel === "REQUIRES" && (e.intervals || []).length);
+ if (!edges.length) {
+ box.innerHTML = `
`;
+ return;
+ }
+ const buckets = computeReplaceBuckets(edges, wins);
+ const col = (title, list, cls, note, tagCls) => {
+ const skills = [...new Set(list.map((e) => e.target))];
+ return `
+
${title}(${skills.length})
+
${note}
+
${skills.slice(0, 10).map((s) =>
+ `${esc(s)}`).join("") ||
+ '无'}
+ ${skills.length > 10 ? `+${skills.length - 10}` : ""}
+
`;
+ };
+ box.innerHTML = `
+ ${col("旧核心(已退出)", buckets.oldCore, "old", "曾生效,当前已退出画像", "tag-red")}
+
→
+ ${col("持续能力", buckets.cont, "keep", "跨周期稳定生效的岗位基本盘", "tag-blue")}
+
→
+ ${col("新核心(近 4 季度)", buckets.newCore, "new", "本期新增或升级的能力", "tag-cyan")}
+
`;
+ box.querySelectorAll(".rp-chip").forEach((el) => {
+ el.onclick = () => { switchView("graph"); if (CN.gotoNode) CN.gotoNode(el.dataset.skill); };
+ });
+}
+
/* 能力更替流:旧核心(已退出必备) → 持续能力 → 新核心(近 4 季度新进入) */
function renderEvoReplace(jobId) {
const box = $("#evo-replace");
const g = CN.graphData || {};
const wins = [...new Set((g.events || []).map((e) => e.window))].sort();
- const recent4 = new Set(wins.slice(-4));
const edges = (g.edges || []).filter((e) => e.source === jobId && e.rel === "REQUIRES" && (e.intervals || []).length);
if (!edges.length) {
box.innerHTML = `
暂无能力更替数据
`;
return;
}
const alive = (e) => e.intervals.some(([s, en]) => en === null);
- const start = (e) => e.intervals.map((i) => i[0]).sort()[0];
- const oldCore = edges.filter((e) => !alive(e));
- const cont = edges.filter((e) => alive(e) && !recent4.has(start(e)));
- const newCore = edges.filter((e) => alive(e) && recent4.has(start(e)));
+ const { oldCore, cont, newCore } = computeReplaceBuckets(edges, wins);
const chip = (e) =>
`
${esc(e.target)}`;
const col = (title, list, cls, note) => `
@@ -1289,6 +1457,23 @@ $("#btn-goto-path").onclick = () => {
loadPathJob($("#match-job-select").value, matchState.skills).then(() => $("#btn-path").click());
};
+/* 缺失技能「去学习」:跳学习路径页,带入岗位与已掌握技能,生成路线并在技能清单中闪烁定位目标技能 */
+function gotoLearnSkill(skill) {
+ const jobId = $("#match-job-select").value;
+ $("#path-job-select").value = jobId;
+ switchView("path");
+ loadPathJob(jobId, matchState.skills).then(() => {
+ $("#btn-path").click();
+ if (!skill) return;
+ const chip = $$("#path-skill-pool .pool-chip").find((el) => el.dataset.skill === skill);
+ if (chip) {
+ chip.scrollIntoView({ block: "center", behavior: "smooth" });
+ chip.classList.add("flash");
+ setTimeout(() => chip.classList.remove("flash"), 1800);
+ }
+ });
+}
+
/* 总分 → 自然语言结论(答辩口径:分数只是结论的辅助) */
function matchAdvice(score) {
if (score >= 80) return "强烈推荐投递:能力画像与目标岗位高度匹配,可优先安排面试。";
@@ -1307,24 +1492,37 @@ function renderMatchResult(m) {
$("#matched-chips").innerHTML =
[...m.matched_required.map((s) => chip(s, "tag-green")), ...m.matched_preferred.map((s) => chip(s, "tag-cyan"))].join("") ||
`
无命中(简历中未识别到本岗位所需技能)`;
- /* 缺口技能:超过 5 项默认折叠,避免清单压垮用户 */
+ /* 缺口技能:chips 可点击「去学习」(跳学习路径页并闪烁定位该技能);超过 5 项默认折叠 */
+ const learnChip = (s, cls) =>
+ `
${esc(s)}`;
const missing = [
- ...m.missing_required.map((s) => chip(s, "tag-red")),
- ...m.missing_preferred.map((s) => chip(s, "tag-orange")),
+ ...m.missing_required.map((s) => learnChip(s, "tag-red")),
+ ...m.missing_preferred.map((s) => learnChip(s, "tag-orange")),
];
+ const missSkills = [...m.missing_required, ...m.missing_preferred];
const missBox = $("#missing-chips");
+ const learnFoot = missing.length ? `
` : "";
+ const bindLearn = () => {
+ missBox.querySelectorAll(".miss-learn").forEach((el) => {
+ el.onclick = () => gotoLearnSkill(el.dataset.skill);
+ });
+ const all = $("#miss-learn-all");
+ if (all) all.onclick = () => gotoLearnSkill(missSkills[0]);
+ };
if (!missing.length) {
missBox.innerHTML = `
无`;
} else if (missing.length <= 5) {
- missBox.innerHTML = missing.join("");
+ missBox.innerHTML = missing.join("") + learnFoot;
} else {
const renderMiss = (expanded) => {
missBox.innerHTML = (expanded ? missing : missing.slice(0, 5)).join("") +
- `
`;
+ `
` + learnFoot;
$("#miss-toggle").onclick = () => renderMiss(!expanded);
+ bindLearn();
};
renderMiss(false);
}
+ bindLearn();
$("#transfer-chips").innerHTML = m.transferable_skills.map((s) => chip(s, "tag-purple")).join("") ||
`
暂无可迁移技能`;
// 声明掌握但与本岗位无关的技能:显式告知"不计入评分",避免用户疑惑
@@ -1435,19 +1633,28 @@ function drawGauge(canvas, score) {
ctx.lineWidth = 14; ctx.lineCap = "round";
ctx.strokeStyle = "#f0f1f5";
ctx.beginPath(); ctx.arc(cx, cy, R, Math.PI, 2 * Math.PI); ctx.stroke();
- const color = score >= 80 ? "#00b42a" : score >= 60 ? "#2f54eb" : "#ff7d00";
+ const color = score >= 80 ? "#00b42a" : score >= 60 ? "#1890ff" : "#ff7d00";
ctx.strokeStyle = color;
ctx.beginPath(); ctx.arc(cx, cy, R, Math.PI, Math.PI * (1 + Math.min(100, score) / 100)); ctx.stroke();
$("#gauge-score").textContent = Math.round(score);
$("#gauge-score").style.color = color;
}
+const RADAR_KEYS = ["required", "preferred", "experience", "scenario"];
+const RADAR_LABELS = { required: "必备技能", preferred: "加分技能", experience: "经验年限", scenario: "场景经验" };
+
+/* 雷达四维归一化(纯逻辑,node 可测):缺失键按 0,超界截断到 [0,1] */
+function radarValues(dims) {
+ return RADAR_KEYS.map((k) => Math.max(0, Math.min(100, (dims || {})[k] || 0)) / 100);
+}
+
+/* 双系列雷达:实线=岗位基准(四维 100% 轮廓),虚线=候选人四维得分(图例见画布下方 DOM) */
function drawRadar(canvas, dims) {
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, 340, 280);
const cx = 170, cy = 145, R = 100;
- const labels = { required: "必备技能", preferred: "加分技能", experience: "经验年限", scenario: "场景经验" };
- const keys = Object.keys(labels);
+ const keys = RADAR_KEYS;
+ const labels = RADAR_LABELS;
const angle = (i) => -Math.PI / 2 + (i * 2 * Math.PI) / keys.length;
for (let ring = 1; ring <= 4; ring++) {
ctx.beginPath();
@@ -1462,20 +1669,31 @@ function drawRadar(canvas, dims) {
ctx.lineTo(cx + R * Math.cos(angle(i)), cy + R * Math.sin(angle(i)));
ctx.strokeStyle = "#f0f0f0"; ctx.stroke();
});
+ /* 岗位基准:四维 100% 实线轮廓 */
ctx.beginPath();
- keys.forEach((k, i) => {
- const r = (R * (dims[k] || 0)) / 100;
+ keys.forEach((_, i) => {
+ ctx[i ? "lineTo" : "moveTo"](cx + R * Math.cos(angle(i)), cy + R * Math.sin(angle(i)));
+ });
+ ctx.closePath();
+ ctx.strokeStyle = "#1890ff"; ctx.lineWidth = 2; ctx.stroke();
+ /* 候选人:虚线 + 面积填充 */
+ const vals = radarValues(dims);
+ ctx.beginPath();
+ keys.forEach((_, i) => {
+ const r = R * vals[i];
ctx[i ? "lineTo" : "moveTo"](cx + r * Math.cos(angle(i)), cy + r * Math.sin(angle(i)));
});
ctx.closePath();
- ctx.fillStyle = "rgba(47,84,235,0.18)"; ctx.fill();
- ctx.strokeStyle = "#2f54eb"; ctx.lineWidth = 2; ctx.stroke();
+ ctx.fillStyle = "rgba(19,194,194,0.15)"; ctx.fill();
+ ctx.setLineDash([5, 4]);
+ ctx.strokeStyle = "#13c2c2"; ctx.lineWidth = 2; ctx.stroke();
+ ctx.setLineDash([]);
keys.forEach((k, i) => {
- const r = (R * (dims[k] || 0)) / 100;
- ctx.fillStyle = "#2f54eb";
+ const r = R * vals[i];
+ ctx.fillStyle = "#13c2c2";
ctx.beginPath(); ctx.arc(cx + r * Math.cos(angle(i)), cy + r * Math.sin(angle(i)), 3.5, 0, 7); ctx.fill();
ctx.fillStyle = "#4e5969"; ctx.font = "12px sans-serif"; ctx.textAlign = "center";
- ctx.fillText(`${labels[k]} ${Math.round(dims[k] || 0)}`,
+ ctx.fillText(`${labels[k]} ${Math.round((dims || {})[k] || 0)}`,
cx + (R + 36) * Math.cos(angle(i)), cy + (R + 22) * Math.sin(angle(i)) + 4);
});
}
@@ -2039,7 +2257,7 @@ async function openConfigModal(needToken, firstRun) {
? `
离线回退` : `
联网就绪`;
openModal(firstRun ? "欢迎使用 CareerNexus 职联 · 初始设置" : "系统设置", `
${firstRun ? `
- 首次使用,建议先完成基础配置(均可稍后通过右上角 ⚙ 修改):
+ 首次使用,建议先完成基础配置(均可稍后通过右上角设置按钮修改):
· 配置 LLM API 密钥可启用大模型抽取(不配则离线确定性方案兜底)
· 配置语义向量模型(本地 BGE-M3 或联网 API)可启用语义相似匹配
· 需要对外提供服务时,再调整监听地址与访问令牌
@@ -2109,7 +2327,7 @@ async function openConfigModal(needToken, firstRun) {
-
+
自动完成全部配置,
推荐首次使用:
① 安装 sentence-transformers 依赖(PyPI 镜像站)
@@ -2290,7 +2508,7 @@ $("#btn-config").onclick = () => openConfigModal(false);
/* ================= 启动 ================= */
window.addEventListener("resize", () => {
- if (CN._flowEvents) drawEventFlow($("#trend-canvas"), CN._flowEvents, 8);
+ if (CN._flowEvents) drawTrendLine($("#trend-canvas"), CN._flowEvents, 8);
drawHeat();
});
(async function boot() {
diff --git a/webui/graph.js b/webui/graph.js
index 08be7cb..a38c2a8 100644
--- a/webui/graph.js
+++ b/webui/graph.js
@@ -458,7 +458,7 @@
ctx.save();
LEVEL_BANDS.forEach((b, i) => {
const sy = b.y * G.cam.k + G.cam.y;
- ctx.fillStyle = i % 2 ? "rgba(47,84,235,0.025)" : "rgba(19,194,194,0.03)";
+ ctx.fillStyle = i % 2 ? "rgba(24,144,255,0.025)" : "rgba(19,194,194,0.03)";
ctx.fillRect(0, sy - 175 * G.cam.k, c.clientWidth, 350 * G.cam.k);
ctx.fillStyle = "rgba(78,89,105,0.85)";
ctx.font = "bold 13px sans-serif"; ctx.textAlign = "left";
@@ -481,10 +481,10 @@
const label = `${a.cat}(${n})`;
ctx.font = "bold 13px sans-serif";
const tw = ctx.measureText(label).width;
- ctx.fillStyle = "rgba(47,84,235,0.08)";
- ctx.strokeStyle = "rgba(47,84,235,0.35)";
+ ctx.fillStyle = "rgba(24,144,255,0.08)";
+ ctx.strokeStyle = "rgba(24,144,255,0.35)";
ctx.beginPath(); ctx.roundRect(p.x - tw / 2 - 10, p.y - 14, tw + 20, 24, 12); ctx.fill(); ctx.stroke();
- ctx.fillStyle = "#2f54eb"; ctx.textAlign = "center";
+ ctx.fillStyle = "#1890ff"; ctx.textAlign = "center";
ctx.fillText(label, p.x, p.y + 3);
});
ctx.restore();
@@ -505,7 +505,7 @@
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.strokeStyle = dim ? "rgba(78,89,105,0.05)" : isHot ? "rgba(24,144,255,0.75)" : "rgba(78,89,105,0.28)";
ctx.setLineDash([]);
ctx.lineWidth = isHot ? 1.8 : 1;
} else {
@@ -534,7 +534,7 @@
/* 关系标签(颜色 + 文字双重编码) */
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.fillStyle = isReq ? "rgba(24,144,255,0.85)" : "rgba(9,151,156,0.9)";
ctx.fillText(isReq ? "必备" : "加分", (pa.x + pb.x) / 2, (pa.y + pb.y) / 2 - 3);
}
ctx.restore();
@@ -550,7 +550,7 @@
const dim = sel && !isSel && !isHop;
const meta = TYPE_META[n.type] || { color: "#86909c" };
ctx.save();
- ctx.globalAlpha = dim ? 0.15 : 1;
+ ctx.globalAlpha = dim ? 0.2 : 1;
ctx.fillStyle = meta.color;
if (n.type === "Skill") {
const s = r * 1.7;
@@ -568,7 +568,7 @@
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.strokeStyle = "rgba(24,144,255,0.55)"; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(p.x, p.y, r + 2.5, 0, 7); ctx.stroke();
}
/* NEW 标记:待审核候选与近一年入库新岗位,节点右上方脉动圆点 + NEW 文字(canvas 绘制,alpha 呼吸复用 requestDraw 循环) */
@@ -582,7 +582,7 @@
ctx.beginPath(); ctx.arc(bx, by, 4, 0, 7); ctx.stroke();
ctx.font = "bold 9px sans-serif"; ctx.textAlign = "left";
ctx.fillText("NEW", bx + 6, by + 3);
- ctx.globalAlpha = dim ? 0.15 : 1;
+ ctx.globalAlpha = dim ? 0.2 : 1;
G._newPulse = true; // 有 NEW 节点在画面上:持续请求重绘形成呼吸
}
/* 标签按需显示(文字重叠治理):岗位常显;技能/场景/行业仅在选中/悬停/选中节点一跳邻居时显示;
@@ -666,7 +666,7 @@
/* 视口框 */
const c = canvas();
const tl = toWorld(0, 0), br = toWorld(c.clientWidth, c.clientHeight);
- ctx.strokeStyle = "#2f54eb"; ctx.lineWidth = 1.4;
+ ctx.strokeStyle = "#1890ff"; 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 };
}
@@ -736,6 +736,15 @@
const node = pickNode(e.clientX - rect.left, e.clientY - rect.top);
selectNode(node ? node.id : null);
});
+ /* 节点右键菜单:选中节点 + 弹出自定义菜单(查看证据 / 时点对比 / 人岗诊断 / 子图开关) */
+ c.addEventListener("contextmenu", (e) => {
+ e.preventDefault();
+ const rect = c.getBoundingClientRect();
+ const node = pickNode(e.clientX - rect.left, e.clientY - rect.top);
+ if (!node) { hideCtxMenu(); return; }
+ if (G.selected !== node.id) selectNode(node.id);
+ showCtxMenu(node, e.clientX, e.clientY);
+ });
c.addEventListener("wheel", (e) => {
e.preventDefault();
const rect = c.getBoundingClientRect();
@@ -744,8 +753,71 @@
window.addEventListener("resize", () => { requestDraw(); drawMinimap(); });
}
+ /* ================= 节点右键菜单(CSS 定位 DOM,点空白 / Esc 关闭) ================= */
+ function hideCtxMenu() {
+ const m = $("#ctx-menu");
+ if (m) m.classList.add("hidden");
+ }
+ function showCtxMenu(n, clientX, clientY) {
+ const menu = $("#ctx-menu");
+ const wrap = $("#graph-canvas-wrap");
+ const wr = wrap.getBoundingClientRect();
+ const isJob = n.type === "Job" && !(n.pending || n.status === "pending");
+ const items = [
+ { key: "evidence", label: "查看证据 JD 原文" },
+ { key: "compare", label: "能力画像时点对比", disabled: !isJob },
+ { key: "match", label: "开始人岗诊断", disabled: !isJob },
+ { key: "solo", label: G.soloJob === n.id ? "退出岗位子图" : "岗位子图开关", disabled: !isJob },
+ ];
+ menu.innerHTML = `
${esc(n.title || n.id)}
` + items.map((it) =>
+ `
`).join("");
+ menu.classList.remove("hidden");
+ /* 先渲染量尺寸,再定位并防止溢出画布容器 */
+ let x = clientX - wr.left, y = clientY - wr.top;
+ x = Math.max(4, Math.min(x, wr.width - menu.offsetWidth - 4));
+ y = Math.max(4, Math.min(y, wr.height - menu.offsetHeight - 4));
+ menu.style.left = `${x}px`;
+ menu.style.top = `${y}px`;
+ menu.querySelectorAll(".ctx-item:not(:disabled)").forEach((b) => {
+ b.onclick = () => { hideCtxMenu(); ctxAction(b.dataset.key, n); };
+ });
+ }
+ function ctxAction(key, n) {
+ if (key === "evidence") {
+ const sources = new Set();
+ G.edges.forEach((e) => {
+ if (e.source === n.id || e.target === n.id) (e.sources || []).forEach((s) => sources.add(s));
+ });
+ const list = [...sources];
+ if (!list.length) return toast("该节点暂无来源证据记录", "info");
+ CN.showJdEvidence(list, { jobTitle: n.title || n.id });
+ } else if (key === "compare") {
+ CN.switchView("evolution");
+ $("#evo-job-select").value = n.id;
+ $("#evo-job-select").dispatchEvent(new Event("change"));
+ setTimeout(() => { const d = $("#evo-diff"); if (d) d.scrollIntoView({ block: "center" }); }, 450);
+ } else if (key === "match") {
+ CN.switchView("match");
+ $("#match-job-select").value = n.id;
+ $("#match-job-select").dispatchEvent(new Event("change"));
+ toast("已带入目标岗位,请上传或载入示例简历", "success");
+ } else if (key === "solo") {
+ G.soloJob = G.soloJob === n.id ? null : n.id;
+ G._defaultApplied = true;
+ updateSoloBadge();
+ updateRecentChange();
+ if (G.soloJob) {
+ applySoloLayout();
+ fitSoloView();
+ toast(`已进入「${n.title || n.id}」岗位子图(同心分层:中心岗位 → 必备 → 加分 → 场景/行业)`, "success");
+ } else fitView();
+ }
+ }
+
/* ================= 工具条 / 筛选 ================= */
function bindToolbar() {
+ document.addEventListener("click", (e) => { if (!e.target.closest("#ctx-menu")) hideCtxMenu(); });
+ document.addEventListener("keydown", (e) => { if (e.key === "Escape") hideCtxMenu(); });
$("#tool-zoom-in").onclick = () => zoomAt(1.25);
$("#tool-zoom-out").onclick = () => zoomAt(0.8);
$("#tool-fit").onclick = fitView;
@@ -903,7 +975,7 @@
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}|${e.skill}|${e.window}`, nodeId: e.job_id,
+ color: "#1890ff", 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)} · ${(e.source_ids || []).length} 条 JD`,
}));
@@ -1328,7 +1400,7 @@
values.forEach((v, i) => {
const bw = c.width / values.length;
const h = (v / max) * (c.height - 16);
- ctx.fillStyle = "#2f54eb";
+ ctx.fillStyle = "#1890ff";
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";
diff --git a/webui/index.html b/webui/index.html
index ba52627..38b3773 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -4,7 +4,7 @@
CareerNexus 职联 · 岗位与能力图谱平台
-
+
@@ -81,6 +81,9 @@
+
+
+
人才能力变化概览
@@ -93,18 +96,37 @@
-
-
+
+
-
近 8 季度变化事件流(全岗位)
- 点击柱段进入该季度演化详情
+ 人才能力变化趋势
+ 近 8 季度 · 悬停看明细 · 点击定位岗位
-
+
+
+
岗位能力更替(全岗位 · 近 4 季度)
+ 旧核心 → 持续 → 新核心
+
+
+
+
+
+
+
+
变化播报
+ 自动轮播 · 悬停暂停 · 点击跳转演化详情
+
+
+
+
+
数据可信度
@@ -112,12 +134,6 @@
-
-
-
待办行动
@@ -266,8 +282,21 @@
—
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
图谱布局计算中…
@@ -466,6 +495,10 @@
+
+ 岗位基准(四维 100%)
+ 候选人得分
+
@@ -631,7 +664,7 @@
-
-
+
+