图谱四视图切换(全景/技术栈聚类/职级演进/动态时序)+ 标签按需显示 + NEW 脉动标记(CSS/Canvas 实现,无 emoji)+ 时点对比 Git Style Diff 与证据关键词高亮 + 简历加分项改琥珀色 + 学习路径甘特图

This commit is contained in:
RoyirinRosmontis
2026-08-20 12:10:35 +08:00
parent b9fcfcb82e
commit c58e2e996f
5 changed files with 442 additions and 38 deletions
+80 -24
View File
@@ -178,7 +178,8 @@ CN.jdMetaLine = async function (el, sid) {
/* JD 证据弹窗(演化事件 / 图谱来源共用)
* sids:单条或数组(数组时支持上一条/下一条翻页)
* ctx:{ skill, jobTitle, changeType, window } 用于生成可读结论与命中依据 */
* ctx:{ skill, jobTitle, changeType, window, keyword } 用于生成可读结论与命中依据;
* keyword 命中词在 JD 原文中以 <mark> 高亮 */
async function showJdEvidence(sids, ctx = {}) {
const list = (Array.isArray(sids) ? sids : [sids]).filter(Boolean);
if (!list.length) return toast("该条目暂无关联的公开 JD 证据", "info");
@@ -202,6 +203,13 @@ async function showJdEvidence(sids, ctx = {}) {
}
try {
const rec = await fetchJd(sid);
/* 关键词高亮:转义后的原文上做单次正则替换,避免嵌套/注入 */
let jdHtml = esc(rec.text);
const kw = (ctx.keyword || "").trim();
if (kw) {
const re = new RegExp(kw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
jdHtml = jdHtml.replace(re, (hit) => `<mark>${hit}</mark>`);
}
$("#modal-body").innerHTML = `
${conclusion ? `<div class="ev-conclusion">${esc(conclusion)}${ctx.window ? `<span class="text-weak">(${esc(ctx.window)})</span>` : ""}</div>` : ""}
<div class="jd-meta">
@@ -209,7 +217,7 @@ async function showJdEvidence(sids, ctx = {}) {
<span>${esc(rec.city || "—")}</span><span>${esc(rec.salary || "—")}</span>
</div>
<div class="ev-source">来源:${esc(rec.source || "公开招聘平台")} · ${esc(rec.company || "企业未披露")} · 发布时间:${esc(rec.publish_date || "未知")}</div>
<div class="jd-evidence">${esc(rec.text)}</div>
<div class="jd-evidence">${jdHtml}</div>
${ctx.skill ? `<div class="ev-basis">命中依据:该 JD 对「${esc(ctx.skill)}」提出明确能力要求${ctx.changeType ? `(${esc(ctx.changeType)})` : ""}</div>` : ""}
<div class="ev-sid">${esc(sid)}</div>`;
} catch (e) {
@@ -871,6 +879,18 @@ function renderEvoStats(jobId) {
`<div class="evo-stat"><span>${k}</span><b>${v}</b></div>`).join("");
}
/* 时点画像差集(Git 风格):返回 新增/退出/保留 三组行数据(纯逻辑,node 可测) */
function diffSkillRows(skillsA, skillsB) {
const setA = new Map(skillsA.map((s) => [s.skill, s.importance]));
const setB = new Map(skillsB.map((s) => [s.skill, s.importance]));
return {
add: [...setB.keys()].filter((s) => !setA.has(s)).map((s) => ({ skill: s, imp: setB.get(s) })),
del: [...setA.keys()].filter((s) => !setB.has(s)).map((s) => ({ skill: s, imp: setA.get(s) })),
keep: [...setA.keys()].filter((s) => setB.has(s))
.map((s) => ({ skill: s, imp: setB.get(s), up: setA.get(s) !== setB.get(s) })),
};
}
async function runEvoCompare() {
const id = $("#evo-job-select").value;
if (!id) return;
@@ -890,29 +910,39 @@ async function runEvoCompare() {
api(`/api/jobs/${encodeURIComponent(id)}/snapshot?at=${atA}`),
api(`/api/jobs/${encodeURIComponent(id)}/snapshot?at=${atB}`),
]);
const setA = new Map(sa.skills.map((s) => [s.skill, s.importance]));
const setB = new Map(sb.skills.map((s) => [s.skill, s.importance]));
const onlyA = [...setA.keys()].filter((s) => !setB.has(s));
const both = [...setA.keys()].filter((s) => setB.has(s));
const onlyB = [...setB.keys()].filter((s) => !setA.has(s));
const chip = (s, cls, extra) =>
`<span class="tag ${cls} diff-chip" data-skill="${esc(s)}">${esc(s)}${extra ? `<i>${extra}</i>` : ""}</span>`;
const col = (title, list, cls, note) => `
<div class="diff-col">
<div class="diff-title">${title}(${list.length})</div>
<div class="diff-chips">${list.map((s) => chip(s, cls,
note === "升" && setA.get(s) !== setB.get(s) ? "↑" : "")).join("") ||
'<span class="text-weak text-aux">无</span>'}</div>
const diff = diffSkillRows(sa.skills, sb.skills);
/* Git 风格行式 diff:+=新增(绿) -=退出(红+删除线) 无前缀=保留(灰),点击行查看该技能证据链 */
const row = (r, kind, sign) => `
<div class="diff-row dr-${kind}" data-skill="${esc(r.skill)}" title="点击查看该技能的证据 JD 原文">
<span class="dr-sign">${sign}</span>
<span class="dr-name">${esc(r.skill)}</span>
<span class="dr-imp">${r.imp === "required" ? "必备" : "加分"}${r.up ? `<i class="dr-up">↑</i>` : ""}</span>
</div>`;
const group = (title, list, kind, sign) => `
<div class="diff-group dr-head-${kind}">${title}(${list.length})</div>
${list.map((r) => row(r, kind, sign)).join("") || `<div class="diff-row dr-none">无</div>`}`;
box.innerHTML = `
<div class="diff-meta">${esc(atA)}(${esc(sa.window)}):${setA.size} 项技能 → ${esc(atB)}(${esc(sb.window)}):${setB.size} 项技能</div>
<div class="diff-grid">
${col(`仅 ${esc(atA)} 有(已退出)`, onlyA, "tag-red")}
${col("两个时点共有(保留)", both, "tag-gray", "升")}
${col(`仅 ${esc(atB)} 有(新增)`, onlyB, "tag-green")}
<div class="diff-meta">${esc(atA)}(${esc(sa.window)}):${sa.skills.length} 项技能 → ${esc(atB)}(${esc(sb.window)}):${sb.skills.length} 项技能</div>
<div class="diff-rows">
${group(`仅 ${esc(atB)} 有 · 新增`, diff.add, "add", "+")}
${group(`仅 ${esc(atA)} 有 · 已退出`, diff.del, "del", "-")}
${group("两个时点共有 · 保留", diff.keep, "keep", "")}
</div>`;
box.querySelectorAll(".diff-chip").forEach((el) => {
el.onclick = () => { switchView("graph"); if (CN.gotoNode) CN.gotoNode(el.dataset.skill); };
const jobTitle = (CN.jobs.find((j) => j.job_id === id) || {}).title || id;
box.querySelectorAll(".diff-row[data-skill]").forEach((el) => {
el.onclick = () => {
const skill = el.dataset.skill;
/* 证据链:取该技能相关演化事件的 source_ids(最新事件优先) */
const evs = evoEvents.filter((e) => e.skill === skill)
.sort((a, b) => (a.window < b.window ? 1 : -1));
const sids = [...new Set(evs.flatMap((e) => e.source_ids || []))];
if (!sids.length) return toast("该技能暂无关联的公开 JD 证据", "info");
const latest = evs[0] || {};
showJdEvidence(sids, {
skill, jobTitle, keyword: skill,
changeType: latest.change_type, window: latest.window,
});
};
});
} catch (e) {
box.innerHTML = `<div class="empty small">
@@ -1309,7 +1339,7 @@ function renderMatchResult(m) {
}
function skillClsMap(m) {
// 技能 → 高亮类别:命中必备=绿,命中加分=蓝,其他已识别=灰
// 技能 → 高亮类别:命中必备=绿,命中加分=琥珀黄,其他已识别=灰
const clsOf = new Map();
(m.matched_required || []).forEach((s) => clsOf.set(s.toLowerCase(), "hl-req"));
(m.matched_preferred || []).forEach((s) => clsOf.set(s.toLowerCase(), "hl-pref"));
@@ -1696,6 +1726,17 @@ function stageWeeks(n) {
return Array.from({ length: n }, () => base);
}
/* 路线甘特图:各阶段横条的起始/宽度百分比(纯逻辑,node 可测) */
function ganttBars(weeks) {
const total = weeks.reduce((a, b) => a + b, 0) || 1;
let acc = 0;
return weeks.map((w) => {
const left = (acc / total) * 100;
acc += w;
return { left, width: (w / total) * 100 };
});
}
/* 阶段预计投入文案:按每周约 8 小时有效学习估算 */
function stageEffortText(w) {
return `${w} 周 / 约 ${w * 8} 小时`;
@@ -1745,11 +1786,26 @@ function renderPath(data) {
/* 阶段语义化命名:基础补齐 → 岗位核心(可多个)→ 项目验证 */
const stageName = (i, n) =>
i === 0 ? "基础补齐" : i === n - 1 ? "项目验证" : "岗位核心";
/* 路线甘特图:横轴周数,每阶段一根横条(已完成绿色填充 / 未完成主色描边),纯 CSS 实现 */
const totalW = weeks.reduce((a, b) => a + b, 0);
const bars = ganttBars(weeks);
const ganttHtml = `
<div class="gantt-wrap">
<div class="gantt-title">路线甘特图(共 ${totalW} 周 · 绿色=已完成阶段)</div>
<div class="gantt-track">${data.learning_path.map((st, i) => `
<div class="gantt-bar${pathState.done.has(st.stage) ? " done" : ""}"
style="left:${bars[i].left}%;width:${bars[i].width}%"
title="阶段 ${st.stage} · ${stageName(i, data.learning_path.length)} · ${weeks[i]} 周 · ${st.skills.length} 项技能">
<span class="gb-label">阶段${st.stage} ${stageName(i, data.learning_path.length)} · ${st.skills.length} 技能</span>
</div>`).join("")}</div>
<div class="gantt-axis">${Array.from({ length: totalW + 1 }, (_, i) =>
`<span class="gantt-tick" style="left:${(i / totalW) * 100}%">${i}</span>`).join("")}</div>
</div>`;
box.innerHTML = (data._demo ? `
<div class="path-demo-banner">
<b>示范路线</b>(以零已掌握技能生成):完成一次人岗诊断后,此处将自动替换为你的个人成长路线图
<button class="btn btn-secondary btn-sm" id="path-goto-match-2">去完成诊断</button>
</div>` : "") + data.learning_path.map((st, i) => {
</div>` : "") + ganttHtml + data.learning_path.map((st, i) => {
const done = pathState.done.has(st.stage);
return `
<div class="stage-card${done ? " done" : ""}" data-stage="${st.stage}">