前端体验升级专项:驾驶舱T型Hero+趋势折线+播报轮播、图谱骨架屏与右键菜单、双雷达图、主色#1890FF统一、空状态补齐、移除调试探针

This commit is contained in:
RoyirinRosmontis
2026-08-27 16:13:43 +08:00
parent aa4a88c10b
commit 3058fc8aca
5 changed files with 631 additions and 175 deletions
+320 -102
View File
@@ -150,7 +150,7 @@ const CN = window.CN = {
};
const TYPE_META = {
Job: { name: "岗位", color: "#2f54eb", tag: "tag-blue" },
Job: { name: "岗位", color: "#1890ff", tag: "tag-blue" },
Skill: { name: "技能", color: "#00b42a", tag: "tag-green" },
Industry: { name: "行业", color: "#13c2c2", tag: "tag-cyan" },
Scenario: { name: "场景", color: "#722ed1", tag: "tag-purple" },
@@ -223,7 +223,7 @@ async function showJdEvidence(sids, ctx = {}) {
} catch (e) {
const gone = /不存在|非法|格式/.test((e && e.message) || "");
$("#modal-body").innerHTML = `
<div class="empty small">
<div class="empty small">${EMPTY_SVG}
<p>${gone ? "该能力当前没有可展示的公开原文,已进入人工核验队列"
: "暂时无法读取该条证据,请检查服务后重试"}</p>
<button class="btn btn-secondary btn-sm" id="ev-retry">重试</button>
@@ -302,19 +302,43 @@ $$(".nav-item").forEach((btn) => { btn.onclick = () => switchView(btn.dataset.vi
})();
/* ================= 人才能力变化指挥台(数据驾驶舱) ================= */
/* 首屏 KPI:新岗位候选 / 关键技能增强 / 待人工复核 / 影响岗位(图谱规模降级为一行小字) */
const METRIC_DEFS = [
{ key: "candidates", label: "新岗位候选", act: "desk", sub: "聚类发现,待人工决策", icon: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>' },
{ key: "boosted", label: "关键技能增强", act: "evolution", sub: "近 2 季度新增+增强事件", icon: '<svg viewBox="0 0 24 24"><path d="M3 17l5.5-5.5 3.5 3.5L20 7"/><path d="M15 7h5v5"/></svg>' },
{ key: "review", label: "待人工复核", act: "desk", sub: "候选岗位 + 候选技能关系", icon: '<svg viewBox="0 0 24 24"><path d="M9 11l3 3 8-8"/><path d="M21 12v6a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2h11"/></svg>' },
{ key: "affected", label: "影响岗位", act: "evolution", sub: "近 2 季度发生变化的岗位", icon: '<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>' },
/* 空状态统一占位图形(SVG + 引导文案 + 行动按钮中的图形部分) */
const EMPTY_SVG = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01"/></svg>';
/* Hero 指标区:有效 JD / 独立企业 / 技能演化事件 / 人工复核率(浅色渐变卡 + count-up 数字滚动) */
const HERO_DEFS = [
{ key: "jd", label: "有效 JD", unit: " 条", sub: "去重降噪后入图",
icon: '<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>' },
{ key: "companies", label: "独立企业", unit: " 家", sub: "跨企业交叉验证,防单一企业造词",
icon: '<svg viewBox="0 0 24 24"><path d="M3 21h18M5 21V8l6-4 6 4v13M9 12h2M13 12h2M9 16h2M13 16h2"/></svg>' },
{ key: "events", label: "技能演化事件", unit: " 项", sub: "岗位能力动态更新累计", act: "evolution",
icon: '<svg viewBox="0 0 24 24"><path d="M3 17l5.5-5.5 3.5 3.5L20 7"/><path d="M15 7h5v5"/></svg>' },
{ key: "review", label: "人工复核率", unit: "%", sub: "审核留痕覆盖率", act: "desk",
icon: '<svg viewBox="0 0 24 24"><path d="M9 11l3 3 8-8"/><path d="M21 12v6a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2h11"/></svg>' },
];
/* count-up 缓动(纯逻辑,node 可测):easeOutCubic,800ms 完成 */
function easeOutCubic(t) {
return 1 - Math.pow(1 - t, 3);
}
/* 数字滚动微动效:requestAnimationFrame + easeOutCubic,约 800ms */
function countUp(el, target, unit = "", dur = 800) {
const t0 = performance.now();
const step = (now) => {
const p = Math.min(1, (now - t0) / dur);
el.textContent = Math.round(target * easeOutCubic(p)) + unit;
if (p < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
}
async function loadDashboard() {
const [health, graph, pending, audit] = await Promise.all([
const [health, graph, pending, audit, cred] = await Promise.all([
api("/api/health"), api("/api/graph"),
api("/api/review/pending").catch(() => []),
api("/api/audit?limit=8").catch(() => []),
api("/api/stats/credibility").catch(() => null),
]);
CN.graphData = graph;
CN._pendingCache = pending;
@@ -329,10 +353,8 @@ async function loadDashboard() {
const recent = events.filter((e) => wins2.includes(e.window));
const cnt = (t) => recent.filter((e) => e.change_type === t).length;
const readyCount = pending.filter((c) => ["高", "较高"].includes(candidateTrust(c, CN.jobs).level)).length;
const pendingRels = pending.reduce((a, c) =>
a + (c.required_skills || []).length + (c.preferred_skills || []).length, 0);
/* 指挥台首屏:结论 + 图谱规模(降级为小字) */
/* 结论行(弱化):本期变化一句话 + 图谱规模小字 */
$("#cmd-title").textContent = `${lastWin} 人才能力变化概览`;
$("#cmd-sub").textContent = pending.length
? `本期确认 ${recent.length} 项关键变化(新增 ${cnt("新增")} · 增强 ${cnt("增强")} · 减弱 ${cnt("减弱")} · 退出 ${cnt("退出")});${readyCount} 个岗位候选已具备入库条件`
@@ -340,29 +362,38 @@ async function loadDashboard() {
$("#cmd-scale").textContent =
`图谱规模:岗位 ${s.jobs ?? "—"} · 技能 ${s.skills ?? "—"} · 关系边 ${s.edges ?? "—"} · 演化事件 ${s.events ?? "—"}`;
/* KPI 卡(点击直达对应处理界面) */
const values = {
candidates: pending.length,
boosted: cnt("增强") + cnt("新增"),
review: pending.length + pendingRels,
affected: new Set(recent.map((e) => e.job_id)).size,
/* Hero 指标区(count-up 数字滚动;演化事件/复核率两张可点击直达) */
const heroValues = {
jd: cred ? cred.jd_valid : null,
companies: cred ? cred.companies : null,
events: s.events ?? events.length,
review: cred && cred.review_rate != null ? Math.round(cred.review_rate * 100) : null,
};
$("#metric-cards").innerHTML = METRIC_DEFS.map((m) => `
<div class="metric-card" data-act="${m.act}">
<div class="mc-label">${m.icon}${m.label}</div>
<div class="mc-num">${values[m.key] ?? "—"}</div>
<span class="mc-delta flat">${m.sub}</span>
$("#metric-cards").innerHTML = HERO_DEFS.map((m) => `
<div class="hero-kpi${m.act ? " clickable" : ""}"${m.act ? ` data-act="${m.act}"` : ""}>
<div class="hk-label">${m.icon}${m.label}</div>
<div class="hk-num" data-key="${m.key}">—</div>
<div class="hk-sub">${m.sub}</div>
</div>`).join("");
$$("#metric-cards .metric-card").forEach((el) => {
el.onclick = () => (el.dataset.act === "desk" ? openReviewDesk() : switchView("evolution"));
$$("#metric-cards .hero-kpi").forEach((el) => {
if (el.dataset.act === "desk") el.onclick = () => openReviewDesk();
else if (el.dataset.act === "evolution") el.onclick = () => switchView("evolution");
});
HERO_DEFS.forEach((m) => {
const el = $(`#metric-cards .hk-num[data-key="${m.key}"]`);
const v = heroValues[m.key];
if (!el) return;
if (v == null) { el.textContent = "—"; return; }
countUp(el, v, m.unit);
});
$("#pending-count").textContent = `${pending.length} 待办`;
/* 主图:近 8 季度变化事件流(全岗位口径堆叠柱,点击柱段跳演化页) */
/* 主图:人才能力变化趋势(平滑折线 + 面积渐变 + hover tooltip,点击定位岗位) */
CN._flowEvents = events;
drawEventFlow($("#trend-canvas"), events, 8);
drawTrendLine($("#trend-canvas"), events, 8);
renderCmdReplace();
renderTrust();
renderTrust(cred);
renderTaskTable(audit);
$("#btn-import-jd").onclick = () => openImportDialog();
$("#btn-cmd-report").onclick = () => switchView("evolution");
@@ -377,11 +408,12 @@ async function loadDashboard() {
renderPending(pending);
}
/* 数据可信度卡:有效 JD / 独立企业 / 来源类型 / 人工复核率 / 异常剔除率 */
async function renderTrust() {
/* 数据可信度卡:有效 JD / 独立企业 / 来源类型 / 人工复核率 / 异常剔除率
* t 由 loadDashboard 一并取回(避免重复请求);为空时自行拉取(重试用) */
async function renderTrust(t) {
const box = $("#trust-card");
try {
const t = await api("/api/stats/credibility");
if (!t) t = await api("/api/stats/credibility");
const rows = [
["有效 JD", `${t.jd_valid} 条`, `原始 ${t.jd_total} 条,去重降噪后入图`],
["独立企业", `${t.companies} 家`, "跨企业交叉验证,防单一企业造词"],
@@ -394,10 +426,10 @@ async function renderTrust() {
<div class="trust-row"><span class="trust-k">${k}</span><b class="trust-v">${v}</b>
<span class="trust-sub">${esc(sub)}</span></div>`).join("");
} catch (e) {
box.innerHTML = `<div class="empty small">
box.innerHTML = `<div class="empty small">${EMPTY_SVG}
<p>可信度统计暂时加载失败:${esc(friendlyErr(e))}</p>
<button class="btn btn-secondary btn-sm" id="trust-retry">重试</button></div>`;
$("#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 = `<div class="empty small">${EMPTY_SVG}<p>暂无变化播报</p></div>`;
return;
}
box.innerHTML = `<div class="bcast-stage">${items.map((e, i) => {
const m = CHANGE_META[e.change_type] || { tag: "tag-gray", color: "#86909c" };
return `<div class="feed-item bcast" data-i="${i}">
return `<div class="bcast-card feed-item${i === 0 ? " on" : ""}" data-i="${i}">
<span class="fi-icon" style="color:${m.color};background:${m.color}1a">
<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></span>
<div class="bcast-body">
@@ -489,8 +527,24 @@ function renderFeed(events) {
</div>
<span class="tag ${m.tag}">${esc(e.change_type)}</span>
</div>`;
}).join("") : `<div class="empty small">暂无变化播报</div>`;
$$("#activity-feed .feed-item").forEach((el) => {
}).join("")}</div>
<div class="bcast-dots">${items.map((_, i) => `<i data-i="${i}" class="${i === 0 ? "on" : ""}"></i>`).join("")}</div>`;
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 = `<div class="empty small">暂无待审核候选(可在图谱页触发新岗位发现)</div>`;
box.innerHTML = `<div class="empty small">${EMPTY_SVG}
<p>暂无待审核候选(可在图谱页触发新岗位发现)</p>
<button class="btn btn-secondary btn-sm" id="pending-goto-graph">打开岗位图谱</button></div>`;
$("#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 = `<div class="empty small">暂无待审核候选(可在图谱页触发新岗位发现)</div>`;
box.innerHTML = `<div class="empty">${EMPTY_SVG}
<p>暂无待审核候选<br>可在图谱页触发新岗位发现,候选经人工审核后才会入图</p>
<button class="btn btn-secondary btn-sm" id="desk-goto-graph">打开岗位图谱</button></div>`;
$("#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]) =>
`<span><i style="background:${m.color}"></i>${t}</span>`).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 = `<b>${esc(p.window)}</b> · 变化事件 ${p.total} 项<br>` +
(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 = `<div class="empty small">${EMPTY_SVG}<p>暂无能力更替数据</p></div>`;
return;
}
const buckets = computeReplaceBuckets(edges, wins);
const col = (title, list, cls, note, tagCls) => {
const skills = [...new Set(list.map((e) => e.target))];
return `<div class="rp-col ${cls}">
<div class="rp-title">${title}(${skills.length})</div>
<div class="rp-note">${note}</div>
<div class="rp-chips">${skills.slice(0, 10).map((s) =>
`<span class="tag ${tagCls} rp-chip" data-skill="${esc(s)}" title="点击跳转图谱节点">${esc(s)}</span>`).join("") ||
'<span class="text-weak text-aux">无</span>'}
${skills.length > 10 ? `<span class="text-weak text-aux">+${skills.length - 10}</span>` : ""}</div>
</div>`;
};
box.innerHTML = `<div class="rp-grid cmd-rp">
${col("旧核心(已退出)", buckets.oldCore, "old", "曾生效,当前已退出画像", "tag-red")}
<div class="rp-arrow">→</div>
${col("持续能力", buckets.cont, "keep", "跨周期稳定生效的岗位基本盘", "tag-blue")}
<div class="rp-arrow">→</div>
${col("新核心(近 4 季度)", buckets.newCore, "new", "本期新增或升级的能力", "tag-cyan")}
</div>`;
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 = `<div class="empty small">暂无能力更替数据</div>`;
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) =>
`<span class="tag ${alive(e) ? (e.importance === "required" ? "tag-blue" : "tag-cyan") : "tag-red"} rp-chip" data-skill="${esc(e.target)}">${esc(e.target)}</span>`;
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("") ||
`<span class="text-weak text-aux">无命中(简历中未识别到本岗位所需技能)</span>`;
/* 缺口技能:超过 5 项默认折叠,避免清单压垮用户 */
/* 缺口技能:chips 可点击「去学习」(跳学习路径页并闪烁定位该技能);超过 5 项默认折叠 */
const learnChip = (s, cls) =>
`<span class="tag ${cls} miss-learn" data-skill="${esc(s)}" title="点击跳学习路径,专攻该技能">${esc(s)}</span>`;
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 ? `<button class="link-btn" id="miss-learn-all">去学习 →</button>` : "";
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 = `<span class="text-weak text-aux">无</span>`;
} 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("") +
`<button class="link-btn" id="miss-toggle">${expanded ? "收起" : `展开全部(共 ${missing.length} 项)`}</button>`;
`<button class="link-btn" id="miss-toggle">${expanded ? "收起" : `展开全部(共 ${missing.length} 项)`}</button>` + learnFoot;
$("#miss-toggle").onclick = () => renderMiss(!expanded);
bindLearn();
};
renderMiss(false);
}
bindLearn();
$("#transfer-chips").innerHTML = m.transferable_skills.map((s) => chip(s, "tag-purple")).join("") ||
`<span class="text-weak text-aux">暂无可迁移技能</span>`;
// 声明掌握但与本岗位无关的技能:显式告知"不计入评分",避免用户疑惑
@@ -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) {
? `<span class="tag tag-orange">离线回退</span>` : `<span class="tag tag-green">联网就绪</span>`;
openModal(firstRun ? "欢迎使用 CareerNexus 职联 · 初始设置" : "系统设置", `
${firstRun ? `<div class="cfg-welcome">
<b>首次使用,建议先完成基础配置</b>(均可稍后通过右上角 ⚙ 修改):
<b>首次使用,建议先完成基础配置</b>(均可稍后通过右上角设置按钮修改):
<br>· 配置 <b>LLM API 密钥</b>可启用大模型抽取(不配则离线确定性方案兜底)
<br>· 配置<b>语义向量模型</b>(本地 BGE-M3 或联网 API)可启用语义相似匹配
<br>· 需要对外提供服务时,再调整<b>监听地址与访问令牌</b>
@@ -2109,7 +2327,7 @@ async function openConfigModal(needToken, firstRun) {
</div>
<div id="cfg-embed-local" class="${cfg.embed.provider === "api" ? "hidden" : ""}">
<div class="setup-hero">
<button class="btn btn-primary" id="cfg-embed-setup">⚡ 一键下载并配置</button>
<button class="btn btn-primary" id="cfg-embed-setup">一键下载并配置</button>
<div class="setup-hero-desc">
自动完成全部配置,<b>推荐首次使用</b>:
<br>① 安装 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() {