图谱四视图切换(全景/技术栈聚类/职级演进/动态时序)+ 标签按需显示 + NEW 脉动标记(CSS/Canvas 实现,无 emoji)+ 时点对比 Git Style Diff 与证据关键词高亮 + 简历加分项改琥珀色 + 学习路径甘特图
This commit is contained in:
@@ -93,5 +93,48 @@ eq(weak.distinctness, 0, "技能完全重合时差异度为 0");
|
|||||||
eq(stageEffortText(2), "2 周 / 约 16 小时", "阶段投入文案");
|
eq(stageEffortText(2), "2 周 / 约 16 小时", "阶段投入文案");
|
||||||
eq(stageEffortText(1), "1 周 / 约 8 小时", "单周投入文案");
|
eq(stageEffortText(1), "1 周 / 约 8 小时", "单周投入文案");
|
||||||
|
|
||||||
|
/* ganttBars:甘特图横条位置(左偏移/宽度百分比) */
|
||||||
|
const ganttBars = loadFn("ganttBars");
|
||||||
|
const gb = ganttBars([2, 2, 4]);
|
||||||
|
eq(gb.length, 3, "甘特条数");
|
||||||
|
eq(gb[0].left + "," + gb[0].width, "0,25", "甘特首条位置");
|
||||||
|
eq(gb[2].left + "," + gb[2].width, "50,50", "甘特末条位置");
|
||||||
|
eq(ganttBars([4, 4])[1].left, 50, "等长两阶段第二条起点");
|
||||||
|
|
||||||
|
/* diffSkillRows:时点画像 Git 风格差集 */
|
||||||
|
const diffSkillRows = loadFn("diffSkillRows");
|
||||||
|
const d = diffSkillRows(
|
||||||
|
[{ skill: "Java", importance: "required" }, { skill: "JSP", importance: "preferred" }],
|
||||||
|
[{ skill: "Java", importance: "preferred" }, { skill: "Kubernetes", importance: "required" }]);
|
||||||
|
eq(d.add.map((r) => r.skill).join(","), "Kubernetes", "diff 新增");
|
||||||
|
eq(d.del.map((r) => r.skill).join(","), "JSP", "diff 退出");
|
||||||
|
eq(d.keep.length, 1, "diff 保留数");
|
||||||
|
eq(d.keep[0].up, true, "加分升级必备标记");
|
||||||
|
|
||||||
|
/* graph.js 纯逻辑:时序边生效判定 / 职级带归属(函数在 IIFE 内,按两空格缩进闭合提取) */
|
||||||
|
const gsrc = fs.readFileSync(path.join(__dirname, "..", "webui", "graph.js"), "utf-8");
|
||||||
|
function loadGraphFns(names) {
|
||||||
|
const code = names.map((n) => {
|
||||||
|
const m = gsrc.match(new RegExp(`function ${n}\\([\\s\\S]*?\\n \\}`));
|
||||||
|
if (!m) throw new Error(`graph.js 未找到函数 ${n}`);
|
||||||
|
return m[0];
|
||||||
|
}).join("\n");
|
||||||
|
return new Function(`${code}; return { ${names.join(", ")} };`)();
|
||||||
|
}
|
||||||
|
const gf = loadGraphFns(["jobLevel", "levelBandOf", "edgeActiveAt"]);
|
||||||
|
eq(gf.edgeActiveAt({ intervals: [] }, "2026Q1"), true, "无区间边始终生效");
|
||||||
|
eq(gf.edgeActiveAt({ intervals: [["2025Q1", "2025Q3"]] }, "2025Q2"), true, "区间内生效");
|
||||||
|
eq(gf.edgeActiveAt({ intervals: [["2025Q1", "2025Q3"]] }, "2025Q3"), false, "结束边界半开不生效");
|
||||||
|
eq(gf.edgeActiveAt({ intervals: [["2025Q1", "2025Q3"]] }, "2024Q4"), false, "起点前不生效");
|
||||||
|
eq(gf.edgeActiveAt({ intervals: [["2025Q1", null]] }, "2026Q4"), true, "开放区间持续生效");
|
||||||
|
eq(gf.jobLevel({ expected_years: 1 }), "初级", "≤2年初级");
|
||||||
|
eq(gf.jobLevel({ expected_years: 4 }), "中级", "3-5年中级");
|
||||||
|
eq(gf.jobLevel({ expected_years: 8 }), "高级", ">5年高级");
|
||||||
|
eq(gf.levelBandOf({ type: "Job", expected_years: 1 }), 0, "初级岗位入带0");
|
||||||
|
eq(gf.levelBandOf({ type: "Job", expected_years: 8 }), 2, "高级岗位入带2");
|
||||||
|
eq(gf.levelBandOf({ type: "Skill", level: "L1" }), 0, "L1技能入带0");
|
||||||
|
eq(gf.levelBandOf({ type: "Skill", level: "L3" }), 2, "L3技能入带2");
|
||||||
|
eq(gf.levelBandOf({ type: "Skill" }), 1, "无级别技能默认中级带");
|
||||||
|
|
||||||
console.log(`\n${pass} passed, ${fail} failed`);
|
console.log(`\n${pass} passed, ${fail} failed`);
|
||||||
process.exit(fail ? 1 : 0);
|
process.exit(fail ? 1 : 0);
|
||||||
|
|||||||
+80
-24
@@ -178,7 +178,8 @@ CN.jdMetaLine = async function (el, sid) {
|
|||||||
|
|
||||||
/* JD 证据弹窗(演化事件 / 图谱来源共用)
|
/* JD 证据弹窗(演化事件 / 图谱来源共用)
|
||||||
* sids:单条或数组(数组时支持上一条/下一条翻页)
|
* sids:单条或数组(数组时支持上一条/下一条翻页)
|
||||||
* ctx:{ skill, jobTitle, changeType, window } 用于生成可读结论与命中依据 */
|
* ctx:{ skill, jobTitle, changeType, window, keyword } 用于生成可读结论与命中依据;
|
||||||
|
* keyword 命中词在 JD 原文中以 <mark> 高亮 */
|
||||||
async function showJdEvidence(sids, ctx = {}) {
|
async function showJdEvidence(sids, ctx = {}) {
|
||||||
const list = (Array.isArray(sids) ? sids : [sids]).filter(Boolean);
|
const list = (Array.isArray(sids) ? sids : [sids]).filter(Boolean);
|
||||||
if (!list.length) return toast("该条目暂无关联的公开 JD 证据", "info");
|
if (!list.length) return toast("该条目暂无关联的公开 JD 证据", "info");
|
||||||
@@ -202,6 +203,13 @@ async function showJdEvidence(sids, ctx = {}) {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const rec = await fetchJd(sid);
|
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 = `
|
$("#modal-body").innerHTML = `
|
||||||
${conclusion ? `<div class="ev-conclusion">${esc(conclusion)}${ctx.window ? `<span class="text-weak">(${esc(ctx.window)})</span>` : ""}</div>` : ""}
|
${conclusion ? `<div class="ev-conclusion">${esc(conclusion)}${ctx.window ? `<span class="text-weak">(${esc(ctx.window)})</span>` : ""}</div>` : ""}
|
||||||
<div class="jd-meta">
|
<div class="jd-meta">
|
||||||
@@ -209,7 +217,7 @@ async function showJdEvidence(sids, ctx = {}) {
|
|||||||
<span>${esc(rec.city || "—")}</span><span>${esc(rec.salary || "—")}</span>
|
<span>${esc(rec.city || "—")}</span><span>${esc(rec.salary || "—")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ev-source">来源:${esc(rec.source || "公开招聘平台")} · ${esc(rec.company || "企业未披露")} · 发布时间:${esc(rec.publish_date || "未知")}</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>` : ""}
|
${ctx.skill ? `<div class="ev-basis">命中依据:该 JD 对「${esc(ctx.skill)}」提出明确能力要求${ctx.changeType ? `(${esc(ctx.changeType)})` : ""}</div>` : ""}
|
||||||
<div class="ev-sid">${esc(sid)}</div>`;
|
<div class="ev-sid">${esc(sid)}</div>`;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -871,6 +879,18 @@ function renderEvoStats(jobId) {
|
|||||||
`<div class="evo-stat"><span>${k}</span><b>${v}</b></div>`).join("");
|
`<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() {
|
async function runEvoCompare() {
|
||||||
const id = $("#evo-job-select").value;
|
const id = $("#evo-job-select").value;
|
||||||
if (!id) return;
|
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=${atA}`),
|
||||||
api(`/api/jobs/${encodeURIComponent(id)}/snapshot?at=${atB}`),
|
api(`/api/jobs/${encodeURIComponent(id)}/snapshot?at=${atB}`),
|
||||||
]);
|
]);
|
||||||
const setA = new Map(sa.skills.map((s) => [s.skill, s.importance]));
|
const diff = diffSkillRows(sa.skills, sb.skills);
|
||||||
const setB = new Map(sb.skills.map((s) => [s.skill, s.importance]));
|
/* Git 风格行式 diff:+=新增(绿) -=退出(红+删除线) 无前缀=保留(灰),点击行查看该技能证据链 */
|
||||||
const onlyA = [...setA.keys()].filter((s) => !setB.has(s));
|
const row = (r, kind, sign) => `
|
||||||
const both = [...setA.keys()].filter((s) => setB.has(s));
|
<div class="diff-row dr-${kind}" data-skill="${esc(r.skill)}" title="点击查看该技能的证据 JD 原文">
|
||||||
const onlyB = [...setB.keys()].filter((s) => !setA.has(s));
|
<span class="dr-sign">${sign}</span>
|
||||||
const chip = (s, cls, extra) =>
|
<span class="dr-name">${esc(r.skill)}</span>
|
||||||
`<span class="tag ${cls} diff-chip" data-skill="${esc(s)}">${esc(s)}${extra ? `<i>${extra}</i>` : ""}</span>`;
|
<span class="dr-imp">${r.imp === "required" ? "必备" : "加分"}${r.up ? `<i class="dr-up">↑</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>
|
|
||||||
</div>`;
|
</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 = `
|
box.innerHTML = `
|
||||||
<div class="diff-meta">${esc(atA)}(${esc(sa.window)}):${setA.size} 项技能 → ${esc(atB)}(${esc(sb.window)}):${setB.size} 项技能</div>
|
<div class="diff-meta">${esc(atA)}(${esc(sa.window)}):${sa.skills.length} 项技能 → ${esc(atB)}(${esc(sb.window)}):${sb.skills.length} 项技能</div>
|
||||||
<div class="diff-grid">
|
<div class="diff-rows">
|
||||||
${col(`仅 ${esc(atA)} 有(已退出)`, onlyA, "tag-red")}
|
${group(`仅 ${esc(atB)} 有 · 新增`, diff.add, "add", "+")}
|
||||||
${col("两个时点共有(保留)", both, "tag-gray", "升")}
|
${group(`仅 ${esc(atA)} 有 · 已退出`, diff.del, "del", "-")}
|
||||||
${col(`仅 ${esc(atB)} 有(新增)`, onlyB, "tag-green")}
|
${group("两个时点共有 · 保留", diff.keep, "keep", "")}
|
||||||
</div>`;
|
</div>`;
|
||||||
box.querySelectorAll(".diff-chip").forEach((el) => {
|
const jobTitle = (CN.jobs.find((j) => j.job_id === id) || {}).title || id;
|
||||||
el.onclick = () => { switchView("graph"); if (CN.gotoNode) CN.gotoNode(el.dataset.skill); };
|
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) {
|
} catch (e) {
|
||||||
box.innerHTML = `<div class="empty small">
|
box.innerHTML = `<div class="empty small">
|
||||||
@@ -1309,7 +1339,7 @@ function renderMatchResult(m) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function skillClsMap(m) {
|
function skillClsMap(m) {
|
||||||
// 技能 → 高亮类别:命中必备=绿,命中加分=蓝,其他已识别=灰
|
// 技能 → 高亮类别:命中必备=绿,命中加分=琥珀黄,其他已识别=灰
|
||||||
const clsOf = new Map();
|
const clsOf = new Map();
|
||||||
(m.matched_required || []).forEach((s) => clsOf.set(s.toLowerCase(), "hl-req"));
|
(m.matched_required || []).forEach((s) => clsOf.set(s.toLowerCase(), "hl-req"));
|
||||||
(m.matched_preferred || []).forEach((s) => clsOf.set(s.toLowerCase(), "hl-pref"));
|
(m.matched_preferred || []).forEach((s) => clsOf.set(s.toLowerCase(), "hl-pref"));
|
||||||
@@ -1696,6 +1726,17 @@ function stageWeeks(n) {
|
|||||||
return Array.from({ length: n }, () => base);
|
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 小时有效学习估算 */
|
/* 阶段预计投入文案:按每周约 8 小时有效学习估算 */
|
||||||
function stageEffortText(w) {
|
function stageEffortText(w) {
|
||||||
return `${w} 周 / 约 ${w * 8} 小时`;
|
return `${w} 周 / 约 ${w * 8} 小时`;
|
||||||
@@ -1745,11 +1786,26 @@ function renderPath(data) {
|
|||||||
/* 阶段语义化命名:基础补齐 → 岗位核心(可多个)→ 项目验证 */
|
/* 阶段语义化命名:基础补齐 → 岗位核心(可多个)→ 项目验证 */
|
||||||
const stageName = (i, n) =>
|
const stageName = (i, n) =>
|
||||||
i === 0 ? "基础补齐" : i === n - 1 ? "项目验证" : "岗位核心";
|
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 ? `
|
box.innerHTML = (data._demo ? `
|
||||||
<div class="path-demo-banner">
|
<div class="path-demo-banner">
|
||||||
<b>示范路线</b>(以零已掌握技能生成):完成一次人岗诊断后,此处将自动替换为你的个人成长路线图
|
<b>示范路线</b>(以零已掌握技能生成):完成一次人岗诊断后,此处将自动替换为你的个人成长路线图
|
||||||
<button class="btn btn-secondary btn-sm" id="path-goto-match-2">去完成诊断</button>
|
<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);
|
const done = pathState.done.has(st.stage);
|
||||||
return `
|
return `
|
||||||
<div class="stage-card${done ? " done" : ""}" data-stage="${st.stage}">
|
<div class="stage-card${done ? " done" : ""}" data-stage="${st.stage}">
|
||||||
|
|||||||
+249
-4
@@ -15,6 +15,11 @@
|
|||||||
edges: { required: true, preferred: true, applies: true, belongs: true } },
|
edges: { required: true, preferred: true, applies: true, belongs: true } },
|
||||||
selected: null, // 选中节点 id
|
selected: null, // 选中节点 id
|
||||||
soloJob: null, // 岗位子图模式:只看该岗位及其一跳邻居
|
soloJob: null, // 岗位子图模式:只看该岗位及其一跳邻居
|
||||||
|
viewMode: "overview", // 视图:overview 全景 / tech 技术栈 / level 职级演进 / timeline 动态时序
|
||||||
|
timeWins: [], timeIdx: null, // 时序视图:窗口序列与当前下标
|
||||||
|
activeNodes: null, activeEdges: null,// 时序视图:当前窗口生效的节点/边集合
|
||||||
|
playTimer: null, // 时序视图:播放定时器
|
||||||
|
catAnchors: null, // 技术栈视图:类别锚点 [{cat,x,y}]
|
||||||
hover: null,
|
hover: null,
|
||||||
navStack: [], // 详情面板导航栈
|
navStack: [], // 详情面板导航栈
|
||||||
drag: null, // {mode:'pan'|'node', ...}
|
drag: null, // {mode:'pan'|'node', ...}
|
||||||
@@ -29,6 +34,9 @@
|
|||||||
/* 变化类型颜色(与 app.js CHANGE_META 一致):新增=青绿 / 增强=蓝紫 / 减弱=琥珀 / 退出=砖红 */
|
/* 变化类型颜色(与 app.js CHANGE_META 一致):新增=青绿 / 增强=蓝紫 / 减弱=琥珀 / 退出=砖红 */
|
||||||
const CHANGE_COLORS = { "新增": "#7bc8a4", "增强": "#8f9ff0", "减弱": "#e8c07d", "退出": "#d99a91" };
|
const CHANGE_COLORS = { "新增": "#7bc8a4", "增强": "#8f9ff0", "减弱": "#e8c07d", "退出": "#d99a91" };
|
||||||
const CHANGE_MARK = { "新增": "新", "增强": "↑", "减弱": "↓", "退出": "退" };
|
const CHANGE_MARK = { "新增": "新", "增强": "↑", "减弱": "↓", "退出": "退" };
|
||||||
|
/* 近一年入库的新岗位(NEW 标记):常量名单 + 待审核候选(status=pending) */
|
||||||
|
const NEW_JOB_IDS = new Set(["job_大模型应用工程师", "job_RAG工程师", "job_提示词工程师"]);
|
||||||
|
const isNewJobNode = (n) => n.type === "Job" && (n.pending || n.status === "pending" || NEW_JOB_IDS.has(n.id));
|
||||||
|
|
||||||
/* 变化角标数据:最近 2 季度有事件的技能(子图模式下按当前岗位过滤) */
|
/* 变化角标数据:最近 2 季度有事件的技能(子图模式下按当前岗位过滤) */
|
||||||
function updateRecentChange() {
|
function updateRecentChange() {
|
||||||
@@ -88,6 +96,142 @@
|
|||||||
requestDraw(); drawMinimap();
|
requestDraw(); drawMinimap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ================= 视图切换:全景 / 技术栈 / 职级演进 / 动态时序 ================= */
|
||||||
|
const VIEW_NAMES = { overview: "全景总览", tech: "技术栈视图", level: "职级演进视图", timeline: "动态时序视图" };
|
||||||
|
const LEVEL_BANDS = [
|
||||||
|
{ name: "初级 L1 · ≤2 年", y: -340 },
|
||||||
|
{ name: "中级 L2 · 3-5 年", y: 0 },
|
||||||
|
{ name: "高级 L3 · >5 年", y: 340 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/* 技术栈视图:类别锚点网格分布,岗位聚在锚点旁,技能/场景/行业靠向所属岗位质心 */
|
||||||
|
function applyTechLayout() {
|
||||||
|
const jobs = G.nodes.filter((n) => n.type === "Job");
|
||||||
|
const cats = [...new Set(jobs.map((n) => n.category || "未分类"))].sort((a, b) => a.localeCompare(b, "zh"));
|
||||||
|
const cols = Math.max(1, Math.ceil(Math.sqrt(cats.length)));
|
||||||
|
G.catAnchors = cats.map((cat, i) => ({
|
||||||
|
cat, x: (i % cols) * 980 - ((cols - 1) * 980) / 2, y: Math.floor(i / cols) * 780 - 360,
|
||||||
|
}));
|
||||||
|
const anchorOf = (c) => G.catAnchors.find((a) => a.cat === c) || { x: 0, y: 0 };
|
||||||
|
const byCat = {};
|
||||||
|
jobs.forEach((n) => { (byCat[n.category || "未分类"] = byCat[n.category || "未分类"] || []).push(n); });
|
||||||
|
Object.entries(byCat).forEach(([c, list]) => {
|
||||||
|
const a = anchorOf(c);
|
||||||
|
list.sort((x, y) => String(x.title || x.id).localeCompare(String(y.title || y.id), "zh"));
|
||||||
|
const perRow = Math.max(1, Math.ceil(Math.sqrt(list.length)));
|
||||||
|
list.forEach((n, i) => {
|
||||||
|
n.x = a.x + ((i % perRow) - (perRow - 1) / 2) * 180;
|
||||||
|
n.y = a.y + Math.floor(i / perRow) * 150 + 40;
|
||||||
|
n.vx = 0; n.vy = 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
scatterAroundJobs(G.nodes.filter((n) => n.type !== "Job"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 非岗位节点:靠向相连岗位的质心,按 id 哈希确定性散布 */
|
||||||
|
function scatterAroundJobs(nodes) {
|
||||||
|
nodes.forEach((n) => {
|
||||||
|
const js = [];
|
||||||
|
G.edges.forEach((e) => {
|
||||||
|
if (e.source === n.id) { const t = G.nodes[G.idx[e.target]]; if (t && t.type === "Job") js.push(t); }
|
||||||
|
if (e.target === n.id) { const s = G.nodes[G.idx[e.source]]; if (s && s.type === "Job") js.push(s); }
|
||||||
|
});
|
||||||
|
const cx = js.length ? js.reduce((s, j) => s + j.x, 0) / js.length : 0;
|
||||||
|
const cy = js.length ? js.reduce((s, j) => s + j.y, 0) / js.length : 0;
|
||||||
|
const h = hashStr(n.id);
|
||||||
|
const ang = ((h % 360) * Math.PI) / 180;
|
||||||
|
const R = 95 + (h % 5) * 36;
|
||||||
|
n.x = cx + Math.cos(ang) * R;
|
||||||
|
n.y = cy + Math.sin(ang) * R;
|
||||||
|
n.vx = 0; n.vy = 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 职级演进视图:横向三条级别带(初级→中级→高级 自下而上的成长阶梯),技能落带内、岗位摆带底 */
|
||||||
|
function applyLevelLayout() {
|
||||||
|
[0, 1, 2].forEach((b) => {
|
||||||
|
const yBand = LEVEL_BANDS[b].y;
|
||||||
|
const jl = G.nodes.filter((n) => n.type === "Job" && levelBandOf(n) === b)
|
||||||
|
.sort((x, y) => String(x.title || x.id).localeCompare(String(y.title || y.id), "zh"));
|
||||||
|
jl.forEach((n, i) => {
|
||||||
|
n.x = (i - (jl.length - 1) / 2) * 200;
|
||||||
|
n.y = yBand + 170;
|
||||||
|
n.vx = 0; n.vy = 0;
|
||||||
|
});
|
||||||
|
const sl = G.nodes.filter((n) => n.type !== "Job" && levelBandOf(n) === b)
|
||||||
|
.sort((x, y) => String(x.title || x.id).localeCompare(String(y.title || y.id), "zh"));
|
||||||
|
const perRow = Math.max(4, Math.ceil(Math.sqrt(sl.length) * 1.9));
|
||||||
|
sl.forEach((n, i) => {
|
||||||
|
n.x = ((i % perRow) - (perRow - 1) / 2) * 108;
|
||||||
|
n.y = yBand - 120 + Math.floor(i / perRow) * 72;
|
||||||
|
n.vx = 0; n.vy = 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 动态时序视图:窗口序列(事件窗口 ∪ 边区间起点),拖动滑块只显示该季度已存在节点与生效边 */
|
||||||
|
function timeWindows() {
|
||||||
|
const s = new Set(G.events.map((e) => e.window));
|
||||||
|
G.edges.forEach((e) => (e.intervals || []).forEach(([f]) => s.add(f)));
|
||||||
|
return [...s].filter(Boolean).sort();
|
||||||
|
}
|
||||||
|
function setupTimeline() {
|
||||||
|
G.timeWins = timeWindows();
|
||||||
|
const sl = $("#gt-slider");
|
||||||
|
sl.max = Math.max(0, G.timeWins.length - 1);
|
||||||
|
sl.disabled = !G.timeWins.length;
|
||||||
|
if (G.timeIdx == null || G.timeIdx > G.timeWins.length - 1) G.timeIdx = G.timeWins.length - 1;
|
||||||
|
sl.value = G.timeIdx;
|
||||||
|
applyTimeWindow();
|
||||||
|
}
|
||||||
|
function applyTimeWindow() {
|
||||||
|
const w = G.timeWins[G.timeIdx];
|
||||||
|
G.activeEdges = new Set(G.edges.filter((e) => edgeActiveAt(e, w)));
|
||||||
|
G.activeNodes = new Set();
|
||||||
|
G.activeEdges.forEach((e) => { G.activeNodes.add(e.source); G.activeNodes.add(e.target); });
|
||||||
|
const hasEdge = new Set();
|
||||||
|
G.edges.forEach((e) => { hasEdge.add(e.source); hasEdge.add(e.target); });
|
||||||
|
G.nodes.forEach((n) => { if (n.pending || !hasEdge.has(n.id)) G.activeNodes.add(n.id); });
|
||||||
|
$("#gt-label").textContent = w ? `当前窗口:${w}` : "暂无时序数据";
|
||||||
|
requestDraw(); drawMinimap();
|
||||||
|
}
|
||||||
|
function stopPlay() {
|
||||||
|
if (G.playTimer) { clearInterval(G.playTimer); G.playTimer = null; }
|
||||||
|
const b = $("#gt-play");
|
||||||
|
if (b) b.textContent = "播放";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 视图切换入口:保留筛选/子图状态,仅重排位置(全景来回切换时恢复原力导向位置) */
|
||||||
|
function setViewMode(mode) {
|
||||||
|
if (!VIEW_NAMES[mode] || !G.nodes.length) return;
|
||||||
|
if (mode === G.viewMode && mode === "overview") return;
|
||||||
|
if (G.viewMode === "overview" && mode !== "overview") {
|
||||||
|
G._overviewPos = G.nodes.map((n) => ({ x: n.x, y: n.y }));
|
||||||
|
}
|
||||||
|
G.viewMode = mode;
|
||||||
|
$$("#graph-view-tabs button").forEach((b) => b.classList.toggle("active", b.dataset.gview === mode));
|
||||||
|
$("#graph-timeline").classList.toggle("hidden", mode !== "timeline");
|
||||||
|
stopPlay();
|
||||||
|
if (mode === "overview") {
|
||||||
|
if (G._overviewPos) G._overviewPos.forEach((p, i) => { G.nodes[i].x = p.x; G.nodes[i].y = p.y; G.nodes[i].vx = 0; G.nodes[i].vy = 0; });
|
||||||
|
else layout();
|
||||||
|
G.activeNodes = null; G.activeEdges = null;
|
||||||
|
} else if (mode === "tech") applyTechLayout();
|
||||||
|
else if (mode === "level") applyLevelLayout();
|
||||||
|
else if (mode === "timeline") setupTimeline();
|
||||||
|
if (G.soloJob) { applySoloLayout(); fitSoloView(); }
|
||||||
|
else fitView();
|
||||||
|
requestDraw(); drawMinimap();
|
||||||
|
if (mode !== "overview") toast(`已切换为${VIEW_NAMES[mode]}`, "info");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 按当前视图重排(数据重载 / 一键整理布局后调用) */
|
||||||
|
function relayoutByView() {
|
||||||
|
if (G.viewMode === "tech") applyTechLayout();
|
||||||
|
else if (G.viewMode === "level") applyLevelLayout();
|
||||||
|
else if (G.viewMode === "timeline") setupTimeline();
|
||||||
|
}
|
||||||
|
|
||||||
/* ================= 数据装载 ================= */
|
/* ================= 数据装载 ================= */
|
||||||
async function load() {
|
async function load() {
|
||||||
$("#graph-loading").classList.remove("hidden");
|
$("#graph-loading").classList.remove("hidden");
|
||||||
@@ -122,6 +266,7 @@
|
|||||||
|
|
||||||
buildFilterOptions();
|
buildFilterOptions();
|
||||||
layout();
|
layout();
|
||||||
|
relayoutByView(); // 保持在非全景视图时重载后仍为该视图布局
|
||||||
restoreView() || fitView();
|
restoreView() || fitView();
|
||||||
/* 默认展示全景图谱(全量总览);岗位子图通过详情面板「岗位子图」按钮或演示导览进入 */
|
/* 默认展示全景图谱(全量总览);岗位子图通过详情面板「岗位子图」按钮或演示导览进入 */
|
||||||
updateRecentChange();
|
updateRecentChange();
|
||||||
@@ -224,8 +369,29 @@
|
|||||||
if (y == null) return "";
|
if (y == null) return "";
|
||||||
return y <= 2 ? "初级" : y <= 5 ? "中级" : "高级";
|
return y <= 2 ? "初级" : y <= 5 ? "中级" : "高级";
|
||||||
}
|
}
|
||||||
|
/* 职级演进视图:节点所属级别带(0=初级 1=中级 2=高级)。技能取词表 level(L1/L2/L3),岗位取 level/expected_years */
|
||||||
|
function levelBandOf(n) {
|
||||||
|
if (n.type === "Job") {
|
||||||
|
const l = n.level || jobLevel(n);
|
||||||
|
return l === "初级" ? 0 : l === "高级" ? 2 : 1;
|
||||||
|
}
|
||||||
|
const lv = { L1: 0, L2: 1, L3: 2 }[n.level];
|
||||||
|
return lv == null ? 1 : lv;
|
||||||
|
}
|
||||||
|
/* 动态时序视图:边在窗口 w 是否生效(intervals: [start, end) 半开区间;无区间信息视为始终生效) */
|
||||||
|
function edgeActiveAt(e, w) {
|
||||||
|
if (!w || !e.intervals || !e.intervals.length) return true;
|
||||||
|
return e.intervals.some(([s, en]) => s <= w && (en === null || w < en));
|
||||||
|
}
|
||||||
|
/* 确定性短哈希(技术栈视图技能散布用,避免每次切换位置抖动) */
|
||||||
|
function hashStr(s) {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
function visible(n) {
|
function visible(n) {
|
||||||
if (!G.filters.types[n.type]) return false;
|
if (!G.filters.types[n.type]) return false;
|
||||||
|
if (G.viewMode === "timeline" && G.activeNodes && !G.activeNodes.has(n.id)) return false;
|
||||||
if (G.soloJob) { // 岗位子图:只保留该岗位与一跳邻居
|
if (G.soloJob) { // 岗位子图:只保留该岗位与一跳邻居
|
||||||
if (n.id !== G.soloJob && !neighborsOf(G.soloJob).has(n.id)) return false;
|
if (n.id !== G.soloJob && !neighborsOf(G.soloJob).has(n.id)) return false;
|
||||||
}
|
}
|
||||||
@@ -236,6 +402,7 @@
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
function edgeVisible(e) {
|
function edgeVisible(e) {
|
||||||
|
if (G.viewMode === "timeline" && G.activeEdges && !G.activeEdges.has(e)) return false;
|
||||||
const ef = G.filters.edges || {};
|
const ef = G.filters.edges || {};
|
||||||
if (e.rel === "REQUIRES") return ef[e.importance === "required" ? "required" : "preferred"] !== false;
|
if (e.rel === "REQUIRES") return ef[e.importance === "required" ? "required" : "preferred"] !== false;
|
||||||
if (e.rel === "APPLIES_TO") return ef.applies !== false;
|
if (e.rel === "APPLIES_TO") return ef.applies !== false;
|
||||||
@@ -273,6 +440,44 @@
|
|||||||
|
|
||||||
const sel = G.selected;
|
const sel = G.selected;
|
||||||
const hop = sel ? neighborsOf(sel) : null;
|
const hop = sel ? neighborsOf(sel) : null;
|
||||||
|
G._newPulse = false;
|
||||||
|
|
||||||
|
/* ---- 视图区域底纹与标签(技术栈类别锚点 / 职级三条带) ---- */
|
||||||
|
if (G.viewMode === "level") {
|
||||||
|
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.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";
|
||||||
|
ctx.fillText(b.name, 14, sy - 150 * G.cam.k);
|
||||||
|
if (i > 0) { // 带间分隔虚线
|
||||||
|
ctx.strokeStyle = "rgba(78,89,105,0.25)"; ctx.setLineDash([6, 5]);
|
||||||
|
ctx.beginPath(); ctx.moveTo(0, sy - 170 * G.cam.k); ctx.lineTo(c.clientWidth, sy - 170 * G.cam.k); ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
/* 成长阶梯方向提示(文字,非图标) */
|
||||||
|
ctx.fillStyle = "rgba(78,89,105,0.6)"; ctx.font = "11px sans-serif"; ctx.textAlign = "right";
|
||||||
|
ctx.fillText("成长阶梯:初级 → 中级 → 高级", c.clientWidth - 14, 20);
|
||||||
|
ctx.restore();
|
||||||
|
} else if (G.viewMode === "tech" && G.catAnchors) {
|
||||||
|
ctx.save();
|
||||||
|
G.catAnchors.forEach((a) => {
|
||||||
|
const p = { x: a.x * G.cam.k + G.cam.x, y: a.y * G.cam.k + G.cam.y };
|
||||||
|
const n = G.nodes.filter((x) => x.type === "Job" && (x.category || "未分类") === a.cat).length;
|
||||||
|
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.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.fillText(label, p.x, p.y + 3);
|
||||||
|
});
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- 边 ---- */
|
/* ---- 边 ---- */
|
||||||
G.edges.forEach((e) => {
|
G.edges.forEach((e) => {
|
||||||
@@ -355,8 +560,23 @@
|
|||||||
ctx.strokeStyle = "rgba(47,84,235,0.55)"; ctx.lineWidth = 1.5;
|
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();
|
ctx.beginPath(); ctx.arc(p.x, p.y, r + 2.5, 0, 7); ctx.stroke();
|
||||||
}
|
}
|
||||||
/* 标签:岗位常显,其余放大或选中/悬停时显示 */
|
/* NEW 标记:待审核候选与近一年入库新岗位,节点右上方脉动圆点 + NEW 文字(canvas 绘制,alpha 呼吸复用 requestDraw 循环) */
|
||||||
if (n.type === "Job" || G.cam.k > 1.05 || isSel || isHop || n.id === G.hover) {
|
if (isNewJobNode(n) && !dim) {
|
||||||
|
const pulse = 0.55 + 0.45 * Math.sin(Date.now() / 380);
|
||||||
|
const bx = p.x + r * 0.95, by = p.y - r * 0.95;
|
||||||
|
ctx.globalAlpha = pulse;
|
||||||
|
ctx.fillStyle = "#ff7d00";
|
||||||
|
ctx.beginPath(); ctx.arc(bx, by, 4, 0, 7); ctx.fill();
|
||||||
|
ctx.strokeStyle = "#fff"; ctx.lineWidth = 1.2;
|
||||||
|
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;
|
||||||
|
G._newPulse = true; // 有 NEW 节点在画面上:持续请求重绘形成呼吸
|
||||||
|
}
|
||||||
|
/* 标签按需显示(文字重叠治理):岗位常显;技能/场景/行业仅在选中/悬停/选中节点一跳邻居时显示;
|
||||||
|
* 关系标签开关(showLabels)打开且放大到一定程度时恢复全量标签 */
|
||||||
|
if (n.type === "Job" || isSel || isHop || n.id === G.hover || (G.showLabels && G.cam.k > 1.05)) {
|
||||||
ctx.font = `${n.type === "Job" ? 11 : 10}px sans-serif`;
|
ctx.font = `${n.type === "Job" ? 11 : 10}px sans-serif`;
|
||||||
ctx.textAlign = "center";
|
ctx.textAlign = "center";
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.85)";
|
ctx.fillStyle = "rgba(255,255,255,0.85)";
|
||||||
@@ -399,6 +619,9 @@
|
|||||||
ctx.fillStyle = "#fff"; ctx.textAlign = "center";
|
ctx.fillStyle = "#fff"; ctx.textAlign = "center";
|
||||||
ctx.fillText(txt, bx + tw / 2 + 8, p.y + r + 24);
|
ctx.fillText(txt, bx + tw / 2 + 8, p.y + r + 24);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* NEW 标记呼吸:本帧画到 NEW 节点则继续排队下一帧(复用 requestDraw,不另开定时器) */
|
||||||
|
if (G._newPulse) requestDraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================= 小地图 ================= */
|
/* ================= 小地图 ================= */
|
||||||
@@ -517,7 +740,29 @@
|
|||||||
$("#tool-fit").onclick = fitView;
|
$("#tool-fit").onclick = fitView;
|
||||||
$("#tool-relayout").onclick = () => {
|
$("#tool-relayout").onclick = () => {
|
||||||
$("#graph-loading").classList.remove("hidden");
|
$("#graph-loading").classList.remove("hidden");
|
||||||
setTimeout(() => { layout(); fitView(); $("#graph-loading").classList.add("hidden"); toast("布局已整理", "success"); }, 30);
|
setTimeout(() => {
|
||||||
|
layout(); relayoutByView(); fitView();
|
||||||
|
$("#graph-loading").classList.add("hidden"); toast("布局已整理", "success");
|
||||||
|
}, 30);
|
||||||
|
};
|
||||||
|
/* 视图切换分段控件(全景 / 技术栈 / 职级演进 / 动态时序) */
|
||||||
|
$$("#graph-view-tabs button").forEach((b) => { b.onclick = () => setViewMode(b.dataset.gview); });
|
||||||
|
/* 动态时序视图:滑块拖动 + 自动播放 */
|
||||||
|
$("#gt-slider").addEventListener("input", (e) => {
|
||||||
|
G.timeIdx = +e.target.value;
|
||||||
|
applyTimeWindow();
|
||||||
|
});
|
||||||
|
$("#gt-play").onclick = () => {
|
||||||
|
if (G.playTimer) return stopPlay();
|
||||||
|
if (!G.timeWins.length) return toast("暂无时序数据可播放", "info");
|
||||||
|
if (G.timeIdx >= G.timeWins.length - 1) { G.timeIdx = 0; $("#gt-slider").value = 0; applyTimeWindow(); }
|
||||||
|
$("#gt-play").textContent = "暂停";
|
||||||
|
G.playTimer = setInterval(() => {
|
||||||
|
if (G.viewMode !== "timeline" || G.timeIdx >= G.timeWins.length - 1) return stopPlay();
|
||||||
|
G.timeIdx += 1;
|
||||||
|
$("#gt-slider").value = G.timeIdx;
|
||||||
|
applyTimeWindow();
|
||||||
|
}, 1200);
|
||||||
};
|
};
|
||||||
$("#tool-labels").onclick = (e) => {
|
$("#tool-labels").onclick = (e) => {
|
||||||
G.showLabels = !G.showLabels;
|
G.showLabels = !G.showLabels;
|
||||||
@@ -723,7 +968,7 @@
|
|||||||
const pendRels = G.edges.filter((e) => e.pending && e.source === jobId);
|
const pendRels = G.edges.filter((e) => e.pending && e.source === jobId);
|
||||||
if (pendRels.length) {
|
if (pendRels.length) {
|
||||||
html += `<span class="chain-arrow">|待审核:</span>` + pendRels.map((e) =>
|
html += `<span class="chain-arrow">|待审核:</span>` + pendRels.map((e) =>
|
||||||
`<span class="chain-chip review" data-skill="${esc(e.target)}">⏳ ${esc(e.target)}</span>`).join("");
|
`<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.innerHTML = html || `<span class="text-weak text-aux">该岗位暂无演化记录</span>`;
|
||||||
chips.querySelectorAll("[data-skill]").forEach((el) => {
|
chips.querySelectorAll("[data-skill]").forEach((el) => {
|
||||||
|
|||||||
+14
-1
@@ -153,6 +153,12 @@
|
|||||||
<span class="tag tag-cyan">技能点级</span>
|
<span class="tag tag-cyan">技能点级</span>
|
||||||
<span class="tag tag-green">审核已开启</span>
|
<span class="tag tag-green">审核已开启</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="seg graph-view-tabs" id="graph-view-tabs">
|
||||||
|
<button data-gview="overview" class="active">全景总览</button>
|
||||||
|
<button data-gview="tech">技术栈</button>
|
||||||
|
<button data-gview="level">职级演进</button>
|
||||||
|
<button data-gview="timeline">动态时序</button>
|
||||||
|
</div>
|
||||||
<div class="graph-actions">
|
<div class="graph-actions">
|
||||||
<button class="btn btn-secondary" id="btn-goto-evo">
|
<button class="btn btn-secondary" id="btn-goto-evo">
|
||||||
<svg viewBox="0 0 24 24"><path d="M3 17l5.5-5.5 3.5 3.5L20 7"/><path d="M15 7h5v5"/></svg>
|
<svg viewBox="0 0 24 24"><path d="M3 17l5.5-5.5 3.5 3.5L20 7"/><path d="M15 7h5v5"/></svg>
|
||||||
@@ -189,6 +195,13 @@
|
|||||||
<button id="solo-exit" title="退出岗位子图">×</button>
|
<button id="solo-exit" title="退出岗位子图">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 动态时序视图:季度时间轴(仅该视图显示) -->
|
||||||
|
<div class="graph-timeline hidden" id="graph-timeline">
|
||||||
|
<button class="btn btn-secondary btn-sm" id="gt-play">播放</button>
|
||||||
|
<input type="range" id="gt-slider" min="0" max="0" step="1" value="0">
|
||||||
|
<span class="gt-window" id="gt-label">—</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 左上浮动工具条 -->
|
<!-- 左上浮动工具条 -->
|
||||||
<div class="graph-toolbar">
|
<div class="graph-toolbar">
|
||||||
<button class="tool-btn" id="tool-zoom-in" title="放大"><svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.35-4.35M11 8v6M8 11h6"/></svg></button>
|
<button class="tool-btn" id="tool-zoom-in" title="放大"><svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.35-4.35M11 8v6M8 11h6"/></svg></button>
|
||||||
@@ -329,7 +342,7 @@
|
|||||||
<button class="btn btn-secondary btn-sm" id="evo-compare-btn">对比</button>
|
<button class="btn btn-secondary btn-sm" id="evo-compare-btn">对比</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="evo-chart-desc">怎么用:选择两个日期,系统回放该岗位在两个时点的完整能力画像并求差集——红色=左边时点有、后来退出;绿色=右边时点新增;灰色=一直保留(带↑表示从加分升级为必备)。</div>
|
<div class="evo-chart-desc">怎么用:选择两个日期,系统回放该岗位在两个时点的完整能力画像并按行求差(Git 风格)——绿色 + 行=右边时点新增;红色 - 行(删除线)=左边时点有、后来退出;灰色行=一直保留(带↑表示从加分升级为必备)。点击任意行查看该技能的证据 JD 原文(技能名在原文中高亮)。</div>
|
||||||
<div class="evo-diff" id="evo-diff"></div>
|
<div class="evo-diff" id="evo-diff"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+56
-9
@@ -303,6 +303,17 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width:
|
|||||||
.graph-title { display: flex; align-items: center; gap: 10px; }
|
.graph-title { display: flex; align-items: center; gap: 10px; }
|
||||||
.graph-title h2 { font-size: 18px; font-weight: 600; }
|
.graph-title h2 { font-size: 18px; font-weight: 600; }
|
||||||
.graph-actions { display: flex; gap: 8px; }
|
.graph-actions { display: flex; gap: 8px; }
|
||||||
|
.graph-view-tabs { margin: 0 auto; }
|
||||||
|
|
||||||
|
/* 动态时序视图:画布顶部居中的季度时间轴 */
|
||||||
|
.graph-timeline {
|
||||||
|
position: absolute; top: 12px; left: 50%; transform: translateX(-50%);
|
||||||
|
display: flex; align-items: center; gap: 10px; z-index: 6;
|
||||||
|
background: rgba(255,255,255,0.94); border: 1px solid var(--border); border-radius: 999px;
|
||||||
|
padding: 6px 14px; box-shadow: var(--shadow-pop);
|
||||||
|
}
|
||||||
|
.graph-timeline input[type="range"] { width: 240px; accent-color: var(--primary); }
|
||||||
|
.gt-window { font-size: 12px; color: var(--text-1); font-weight: 600; white-space: nowrap; }
|
||||||
|
|
||||||
.graph-body { display: flex; gap: 12px; height: calc(100vh - var(--topbar-h) - 130px); min-height: 520px; }
|
.graph-body { display: flex; gap: 12px; height: calc(100vh - var(--topbar-h) - 130px); min-height: 520px; }
|
||||||
.graph-canvas-wrap { flex: 1; position: relative; overflow: hidden; min-width: 0; }
|
.graph-canvas-wrap { flex: 1; position: relative; overflow: hidden; min-width: 0; }
|
||||||
@@ -558,7 +569,7 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width:
|
|||||||
}
|
}
|
||||||
.hl { border-radius: 3px; padding: 1px 4px; font-weight: 500; white-space: nowrap; }
|
.hl { border-radius: 3px; padding: 1px 4px; font-weight: 500; white-space: nowrap; }
|
||||||
.hl-req { background: var(--success-light); color: var(--success); box-shadow: inset 0 0 0 1px #b7eb8f; }
|
.hl-req { background: var(--success-light); color: var(--success); box-shadow: inset 0 0 0 1px #b7eb8f; }
|
||||||
.hl-pref { background: var(--primary-light); color: var(--primary); box-shadow: inset 0 0 0 1px #d6e4ff; }
|
.hl-pref { background: #fdf0da; color: #ad6a10; box-shadow: inset 0 0 0 1px #e8c07d; }
|
||||||
.hl-other { background: var(--bg-page); color: var(--text-2); box-shadow: inset 0 0 0 1px var(--border); }
|
.hl-other { background: var(--bg-page); color: var(--text-2); box-shadow: inset 0 0 0 1px var(--border); }
|
||||||
/* 结构化简历模板(docx 表格风) */
|
/* 结构化简历模板(docx 表格风) */
|
||||||
.rd-table {
|
.rd-table {
|
||||||
@@ -673,6 +684,28 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width:
|
|||||||
.stage-check { font-size: 12px; }
|
.stage-check { font-size: 12px; }
|
||||||
.path-done { text-align: center; padding: 40px; color: var(--success); font-size: 15px; }
|
.path-done { text-align: center; padding: 40px; color: var(--success); font-size: 15px; }
|
||||||
|
|
||||||
|
/* 路线甘特图:横轴周数,每阶段一根横条(已完成绿填充 / 未完成主色描边),纯 CSS */
|
||||||
|
.gantt-wrap { margin-bottom: 18px; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px 14px 16px; background: var(--bg-page); }
|
||||||
|
.gantt-title { font-size: 12px; font-weight: 600; color: var(--text-2); margin-bottom: 8px; }
|
||||||
|
.gantt-track { position: relative; height: 30px; }
|
||||||
|
.gantt-bar {
|
||||||
|
position: absolute; top: 0; height: 100%; box-sizing: border-box;
|
||||||
|
border: 1.5px solid var(--primary); border-radius: 4px; background: var(--primary-light);
|
||||||
|
display: flex; align-items: center; overflow: hidden;
|
||||||
|
}
|
||||||
|
.gantt-bar.done { background: var(--success); border-color: var(--success); }
|
||||||
|
.gantt-bar .gb-label {
|
||||||
|
font-size: 11px; color: var(--primary); font-weight: 600;
|
||||||
|
padding: 0 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.gantt-bar.done .gb-label { color: #fff; }
|
||||||
|
.gantt-axis { position: relative; height: 14px; margin-top: 4px; }
|
||||||
|
.gantt-tick {
|
||||||
|
position: absolute; transform: translateX(-50%);
|
||||||
|
font-size: 10px; color: var(--text-3); font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.gantt-tick::before { content: ""; position: absolute; top: -4px; left: 50%; width: 1px; height: 3px; background: var(--border-dark); }
|
||||||
|
|
||||||
/* ============ 弹窗 / Toast ============ */
|
/* ============ 弹窗 / Toast ============ */
|
||||||
.modal-mask {
|
.modal-mask {
|
||||||
position: fixed; inset: 0; background: rgba(29, 33, 41, 0.45);
|
position: fixed; inset: 0; background: rgba(29, 33, 41, 0.45);
|
||||||
@@ -767,14 +800,28 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width:
|
|||||||
.evo-dates { display: flex; align-items: center; gap: 8px; }
|
.evo-dates { display: flex; align-items: center; gap: 8px; }
|
||||||
.evo-dates .input { width: 150px; }
|
.evo-dates .input { width: 150px; }
|
||||||
.diff-meta { font-size: 12px; color: var(--text-3); margin-bottom: 10px; }
|
.diff-meta { font-size: 12px; color: var(--text-3); margin-bottom: 10px; }
|
||||||
.diff-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
|
/* Git 风格行式 diff:+新增(绿) / -退出(红+删除线) / 保留(灰) */
|
||||||
@media (max-width: 1100px) { .diff-grid { grid-template-columns: 1fr; } }
|
.diff-rows { border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; }
|
||||||
.diff-col { background: var(--bg-hover); border-radius: var(--radius-sm); padding: 10px 12px; }
|
.diff-group { font-size: 12px; font-weight: 600; color: var(--text-2); padding: 6px 12px; background: var(--bg-hover); border-top: 1px solid var(--border); }
|
||||||
.diff-title { font-size: 12px; font-weight: 600; color: var(--text-1); margin-bottom: 8px; }
|
.diff-group:first-child { border-top: none; }
|
||||||
.diff-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
.dr-head-add { color: var(--success); }
|
||||||
.diff-chip { cursor: pointer; }
|
.dr-head-del { color: #cf1322; }
|
||||||
.diff-chip:hover { filter: brightness(0.92); }
|
.diff-row {
|
||||||
.diff-chip i { font-style: normal; margin-left: 3px; color: var(--primary); font-weight: 700; }
|
display: flex; align-items: center; gap: 10px; padding: 5px 12px;
|
||||||
|
font-size: 13px; cursor: pointer; border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.diff-row:hover { filter: brightness(0.97); }
|
||||||
|
.diff-row .dr-sign { width: 14px; text-align: center; font-family: Consolas, monospace; font-weight: 700; flex: none; }
|
||||||
|
.diff-row .dr-imp { margin-left: auto; font-size: 11px; color: var(--text-3); flex: none; }
|
||||||
|
.diff-row .dr-up { font-style: normal; color: var(--primary); font-weight: 700; margin-left: 3px; }
|
||||||
|
.diff-row.dr-add { background: var(--success-light); }
|
||||||
|
.diff-row.dr-add .dr-sign, .diff-row.dr-add .dr-name { color: var(--success); }
|
||||||
|
.diff-row.dr-del { background: #fff1f0; }
|
||||||
|
.diff-row.dr-del .dr-sign, .diff-row.dr-del .dr-name { color: #cf1322; }
|
||||||
|
.diff-row.dr-del .dr-name { text-decoration: line-through; }
|
||||||
|
.diff-row.dr-keep { background: var(--bg-page); color: var(--text-2); }
|
||||||
|
.diff-row.dr-none { color: var(--text-3); cursor: default; background: #fff; font-size: 12px; }
|
||||||
|
.jd-evidence mark { background: #ffe58f; color: inherit; border-radius: 2px; padding: 0 1px; }
|
||||||
.evo-heat-wrap { max-height: 260px; max-width: 920px; margin: 0 auto 16px; }
|
.evo-heat-wrap { max-height: 260px; max-width: 920px; margin: 0 auto 16px; }
|
||||||
.evo-heat-wrap canvas { max-height: 260px; }
|
.evo-heat-wrap canvas { max-height: 260px; }
|
||||||
.timeline { max-height: 420px; overflow-y: auto; }
|
.timeline { max-height: 420px; overflow-y: auto; }
|
||||||
|
|||||||
Reference in New Issue
Block a user