From c58e2e996f5b47b1a099f8ecd0baf366db83b6d4 Mon Sep 17 00:00:00 2001 From: RoyirinRosmontis Date: Thu, 20 Aug 2026 12:10:35 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=BE=E8=B0=B1=E5=9B=9B=E8=A7=86=E5=9B=BE?= =?UTF-8?q?=E5=88=87=E6=8D=A2=EF=BC=88=E5=85=A8=E6=99=AF/=E6=8A=80?= =?UTF-8?q?=E6=9C=AF=E6=A0=88=E8=81=9A=E7=B1=BB/=E8=81=8C=E7=BA=A7?= =?UTF-8?q?=E6=BC=94=E8=BF=9B/=E5=8A=A8=E6=80=81=E6=97=B6=E5=BA=8F?= =?UTF-8?q?=EF=BC=89+=20=E6=A0=87=E7=AD=BE=E6=8C=89=E9=9C=80=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=20+=20NEW=20=E8=84=89=E5=8A=A8=E6=A0=87=E8=AE=B0?= =?UTF-8?q?=EF=BC=88CSS/Canvas=20=E5=AE=9E=E7=8E=B0=EF=BC=8C=E6=97=A0=20em?= =?UTF-8?q?oji=EF=BC=89+=20=E6=97=B6=E7=82=B9=E5=AF=B9=E6=AF=94=20Git=20St?= =?UTF-8?q?yle=20Diff=20=E4=B8=8E=E8=AF=81=E6=8D=AE=E5=85=B3=E9=94=AE?= =?UTF-8?q?=E8=AF=8D=E9=AB=98=E4=BA=AE=20+=20=E7=AE=80=E5=8E=86=E5=8A=A0?= =?UTF-8?q?=E5=88=86=E9=A1=B9=E6=94=B9=E7=90=A5=E7=8F=80=E8=89=B2=20+=20?= =?UTF-8?q?=E5=AD=A6=E4=B9=A0=E8=B7=AF=E5=BE=84=E7=94=98=E7=89=B9=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_frontend_logic.js | 43 ++++++ webui/app.js | 104 ++++++++++---- webui/graph.js | 253 ++++++++++++++++++++++++++++++++++- webui/index.html | 15 ++- webui/style.css | 65 +++++++-- 5 files changed, 442 insertions(+), 38 deletions(-) diff --git a/tests/test_frontend_logic.js b/tests/test_frontend_logic.js index 495cb8a..85407ee 100644 --- a/tests/test_frontend_logic.js +++ b/tests/test_frontend_logic.js @@ -93,5 +93,48 @@ eq(weak.distinctness, 0, "技能完全重合时差异度为 0"); eq(stageEffortText(2), "2 周 / 约 16 小时", "阶段投入文案"); 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`); process.exit(fail ? 1 : 0); diff --git a/webui/app.js b/webui/app.js index 47619d4..5b850ec 100644 --- a/webui/app.js +++ b/webui/app.js @@ -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 原文中以 高亮 */ 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) => `${hit}`); + } $("#modal-body").innerHTML = ` ${conclusion ? `
${esc(conclusion)}${ctx.window ? `(${esc(ctx.window)})` : ""}
` : ""}
@@ -209,7 +217,7 @@ async function showJdEvidence(sids, ctx = {}) { ${esc(rec.city || "—")}${esc(rec.salary || "—")}
来源:${esc(rec.source || "公开招聘平台")} · ${esc(rec.company || "企业未披露")} · 发布时间:${esc(rec.publish_date || "未知")}
-
${esc(rec.text)}
+
${jdHtml}
${ctx.skill ? `
命中依据:该 JD 对「${esc(ctx.skill)}」提出明确能力要求${ctx.changeType ? `(${esc(ctx.changeType)})` : ""}
` : ""}
${esc(sid)}
`; } catch (e) { @@ -871,6 +879,18 @@ function renderEvoStats(jobId) { `
${k}${v}
`).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) => - `${esc(s)}${extra ? `${extra}` : ""}`; - const col = (title, list, cls, note) => ` -
-
${title}(${list.length})
-
${list.map((s) => chip(s, cls, - note === "升" && setA.get(s) !== setB.get(s) ? "↑" : "")).join("") || - '无'}
+ const diff = diffSkillRows(sa.skills, sb.skills); + /* Git 风格行式 diff:+=新增(绿) -=退出(红+删除线) 无前缀=保留(灰),点击行查看该技能证据链 */ + const row = (r, kind, sign) => ` +
+ ${sign} + ${esc(r.skill)} + ${r.imp === "required" ? "必备" : "加分"}${r.up ? `↑` : ""}
`; + const group = (title, list, kind, sign) => ` +
${title}(${list.length})
+ ${list.map((r) => row(r, kind, sign)).join("") || `
无
`}`; box.innerHTML = ` -
${esc(atA)}(${esc(sa.window)}):${setA.size} 项技能 → ${esc(atB)}(${esc(sb.window)}):${setB.size} 项技能
-
- ${col(`仅 ${esc(atA)} 有(已退出)`, onlyA, "tag-red")} - ${col("两个时点共有(保留)", both, "tag-gray", "升")} - ${col(`仅 ${esc(atB)} 有(新增)`, onlyB, "tag-green")} +
${esc(atA)}(${esc(sa.window)}):${sa.skills.length} 项技能 → ${esc(atB)}(${esc(sb.window)}):${sb.skills.length} 项技能
+
+ ${group(`仅 ${esc(atB)} 有 · 新增`, diff.add, "add", "+")} + ${group(`仅 ${esc(atA)} 有 · 已退出`, diff.del, "del", "-")} + ${group("两个时点共有 · 保留", diff.keep, "keep", "")}
`; - 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 = `
@@ -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 = ` +
+
路线甘特图(共 ${totalW} 周 · 绿色=已完成阶段)
+
${data.learning_path.map((st, i) => ` +
+ 阶段${st.stage} ${stageName(i, data.learning_path.length)} · ${st.skills.length} 技能 +
`).join("")}
+
${Array.from({ length: totalW + 1 }, (_, i) => + `${i}`).join("")}
+
`; box.innerHTML = (data._demo ? `
示范路线(以零已掌握技能生成):完成一次人岗诊断后,此处将自动替换为你的个人成长路线图 -
` : "") + data.learning_path.map((st, i) => { +
` : "") + ganttHtml + data.learning_path.map((st, i) => { const done = pathState.done.has(st.stage); return `
diff --git a/webui/graph.js b/webui/graph.js index d4a8a86..c7bee24 100644 --- a/webui/graph.js +++ b/webui/graph.js @@ -15,6 +15,11 @@ edges: { required: true, preferred: true, applies: true, belongs: true } }, selected: null, // 选中节点 id soloJob: null, // 岗位子图模式:只看该岗位及其一跳邻居 + viewMode: "overview", // 视图:overview 全景 / tech 技术栈 / level 职级演进 / timeline 动态时序 + timeWins: [], timeIdx: null, // 时序视图:窗口序列与当前下标 + activeNodes: null, activeEdges: null,// 时序视图:当前窗口生效的节点/边集合 + playTimer: null, // 时序视图:播放定时器 + catAnchors: null, // 技术栈视图:类别锚点 [{cat,x,y}] hover: null, navStack: [], // 详情面板导航栈 drag: null, // {mode:'pan'|'node', ...} @@ -29,6 +34,9 @@ /* 变化类型颜色(与 app.js CHANGE_META 一致):新增=青绿 / 增强=蓝紫 / 减弱=琥珀 / 退出=砖红 */ const CHANGE_COLORS = { "新增": "#7bc8a4", "增强": "#8f9ff0", "减弱": "#e8c07d", "退出": "#d99a91" }; 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 季度有事件的技能(子图模式下按当前岗位过滤) */ function updateRecentChange() { @@ -88,6 +96,142 @@ 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() { $("#graph-loading").classList.remove("hidden"); @@ -122,6 +266,7 @@ buildFilterOptions(); layout(); + relayoutByView(); // 保持在非全景视图时重载后仍为该视图布局 restoreView() || fitView(); /* 默认展示全景图谱(全量总览);岗位子图通过详情面板「岗位子图」按钮或演示导览进入 */ updateRecentChange(); @@ -224,8 +369,29 @@ if (y == null) return ""; 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) { 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 (n.id !== G.soloJob && !neighborsOf(G.soloJob).has(n.id)) return false; } @@ -236,6 +402,7 @@ return true; } function edgeVisible(e) { + if (G.viewMode === "timeline" && G.activeEdges && !G.activeEdges.has(e)) return false; const ef = G.filters.edges || {}; if (e.rel === "REQUIRES") return ef[e.importance === "required" ? "required" : "preferred"] !== false; if (e.rel === "APPLIES_TO") return ef.applies !== false; @@ -273,6 +440,44 @@ const sel = G.selected; 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) => { @@ -355,8 +560,23 @@ 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(); } - /* 标签:岗位常显,其余放大或选中/悬停时显示 */ - if (n.type === "Job" || G.cam.k > 1.05 || isSel || isHop || n.id === G.hover) { + /* NEW 标记:待审核候选与近一年入库新岗位,节点右上方脉动圆点 + NEW 文字(canvas 绘制,alpha 呼吸复用 requestDraw 循环) */ + 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.textAlign = "center"; ctx.fillStyle = "rgba(255,255,255,0.85)"; @@ -399,6 +619,9 @@ ctx.fillStyle = "#fff"; ctx.textAlign = "center"; 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-relayout").onclick = () => { $("#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) => { G.showLabels = !G.showLabels; @@ -723,7 +968,7 @@ const pendRels = G.edges.filter((e) => e.pending && e.source === jobId); if (pendRels.length) { html += `|待审核:` + pendRels.map((e) => - `⏳ ${esc(e.target)}`).join(""); + `待审 · ${esc(e.target)}`).join(""); } chips.innerHTML = html || `该岗位暂无演化记录`; chips.querySelectorAll("[data-skill]").forEach((el) => { diff --git a/webui/index.html b/webui/index.html index 454cff6..ba52627 100644 --- a/webui/index.html +++ b/webui/index.html @@ -153,6 +153,12 @@ 技能点级 审核已开启
+
+ + + + +
+ + +
@@ -329,7 +342,7 @@
-
怎么用:选择两个日期,系统回放该岗位在两个时点的完整能力画像并求差集——红色=左边时点有、后来退出;绿色=右边时点新增;灰色=一直保留(带↑表示从加分升级为必备)。
+
怎么用:选择两个日期,系统回放该岗位在两个时点的完整能力画像并按行求差(Git 风格)——绿色 + 行=右边时点新增;红色 - 行(删除线)=左边时点有、后来退出;灰色行=一直保留(带↑表示从加分升级为必备)。点击任意行查看该技能的证据 JD 原文(技能名在原文中高亮)。
diff --git a/webui/style.css b/webui/style.css index eb9012c..b5d8886 100644 --- a/webui/style.css +++ b/webui/style.css @@ -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 h2 { font-size: 18px; font-weight: 600; } .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-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-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); } /* 结构化简历模板(docx 表格风) */ .rd-table { @@ -673,6 +684,28 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: .stage-check { font-size: 12px; } .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 ============ */ .modal-mask { 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 .input { width: 150px; } .diff-meta { font-size: 12px; color: var(--text-3); margin-bottom: 10px; } -.diff-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; } -@media (max-width: 1100px) { .diff-grid { grid-template-columns: 1fr; } } -.diff-col { background: var(--bg-hover); border-radius: var(--radius-sm); padding: 10px 12px; } -.diff-title { font-size: 12px; font-weight: 600; color: var(--text-1); margin-bottom: 8px; } -.diff-chips { display: flex; flex-wrap: wrap; gap: 6px; } -.diff-chip { cursor: pointer; } -.diff-chip:hover { filter: brightness(0.92); } -.diff-chip i { font-style: normal; margin-left: 3px; color: var(--primary); font-weight: 700; } +/* Git 风格行式 diff:+新增(绿) / -退出(红+删除线) / 保留(灰) */ +.diff-rows { border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; } +.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-group:first-child { border-top: none; } +.dr-head-add { color: var(--success); } +.dr-head-del { color: #cf1322; } +.diff-row { + 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 canvas { max-height: 260px; } .timeline { max-height: 420px; overflow-y: auto; }