From 3058fc8aca81ed9c3f9224b5f9ae41c4893d0d2b Mon Sep 17 00:00:00 2001 From: RoyirinRosmontis Date: Thu, 27 Aug 2026 16:13:43 +0800 Subject: [PATCH] =?UTF-8?q?=E5=89=8D=E7=AB=AF=E4=BD=93=E9=AA=8C=E5=8D=87?= =?UTF-8?q?=E7=BA=A7=E4=B8=93=E9=A1=B9=EF=BC=9A=E9=A9=BE=E9=A9=B6=E8=88=B1?= =?UTF-8?q?T=E5=9E=8BHero+=E8=B6=8B=E5=8A=BF=E6=8A=98=E7=BA=BF+=E6=92=AD?= =?UTF-8?q?=E6=8A=A5=E8=BD=AE=E6=92=AD=E3=80=81=E5=9B=BE=E8=B0=B1=E9=AA=A8?= =?UTF-8?q?=E6=9E=B6=E5=B1=8F=E4=B8=8E=E5=8F=B3=E9=94=AE=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E3=80=81=E5=8F=8C=E9=9B=B7=E8=BE=BE=E5=9B=BE=E3=80=81=E4=B8=BB?= =?UTF-8?q?=E8=89=B2#1890FF=E7=BB=9F=E4=B8=80=E3=80=81=E7=A9=BA=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E8=A1=A5=E9=BD=90=E3=80=81=E7=A7=BB=E9=99=A4=E8=B0=83?= =?UTF-8?q?=E8=AF=95=E6=8E=A2=E9=92=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_frontend_logic.js | 47 ++++ webui/app.js | 422 ++++++++++++++++++++++++++--------- webui/graph.js | 96 +++++++- webui/index.html | 65 ++++-- webui/style.css | 176 +++++++++++---- 5 files changed, 631 insertions(+), 175 deletions(-) diff --git a/tests/test_frontend_logic.js b/tests/test_frontend_logic.js index 85407ee..cfc1b73 100644 --- a/tests/test_frontend_logic.js +++ b/tests/test_frontend_logic.js @@ -111,6 +111,53 @@ eq(d.del.map((r) => r.skill).join(","), "JSP", "diff 退出"); eq(d.keep.length, 1, "diff 保留数"); eq(d.keep[0].up, true, "加分升级必备标记"); +/* easeOutCubic:count-up 缓动曲线(0→1,中点已过 87.5%) */ +const easeOutCubic = loadFn("easeOutCubic"); +eq(easeOutCubic(0), 0, "缓动起点为 0"); +eq(easeOutCubic(1), 1, "缓动终点为 1"); +eq(easeOutCubic(0.5), 0.875, "缓动中点 easeOut 特征"); +eq(easeOutCubic(0.25) < easeOutCubic(0.5), true, "缓动单调递增"); + +/* trendSeries:近 n 季度事件序列(总量 + 四类计数) */ +const trendSeries = new Function( + `const CHANGE_META = { "新增": {}, "增强": {}, "减弱": {}, "退出": {} };\n${extractFn("trendSeries")}; return trendSeries;`)(); +const tsEvts = [ + ...Array.from({ length: 9 }, (_, i) => ({ window: `20${10 + i}Q1`, change_type: "新增" })), + { window: "2018Q1", change_type: "增强" }, + { window: "2018Q1", change_type: "退出" }, + { window: "2018Q1", change_type: "其他" }, +]; +const ts = trendSeries(tsEvts, 8); +eq(ts.length, 8, "趋势序列只取近 8 季"); +eq(ts[7].window, "2018Q1", "最近窗口在序列末尾"); +eq(ts[7].total, 4, "末季总量含未分类事件"); +eq(ts[7].byType["新增"], 1, "末季新增计数"); +eq(ts[7].byType["增强"], 1, "末季增强计数"); +eq(ts[7].byType["退出"], 1, "末季退出计数"); +eq(trendSeries([], 8).length, 0, "空事件空序列"); + +/* computeReplaceBuckets:能力更替三桶(旧核心 / 持续 / 新核心,近 4 季度口径) */ +const computeReplaceBuckets = loadFn("computeReplaceBuckets"); +const rpWins = ["2025Q2", "2025Q3", "2025Q4", "2026Q1", "2026Q2", "2026Q3"]; +const rpEdges = [ + { target: "JSP", intervals: [["2020Q1", "2025Q4"]] }, // 已退出 → 旧核心 + { target: "Java", intervals: [["2020Q1", null]] }, // 持续 + { target: "Kubernetes", intervals: [["2026Q1", null]] }, // 近 4 季进入 → 新核心 + { target: "Docker", intervals: [["2025Q2", null]] }, // 起点不在近 4 季 → 持续 +]; +const rp = computeReplaceBuckets(rpEdges, rpWins); +eq(rp.oldCore.map((e) => e.target).join(","), "JSP", "旧核心=已退出"); +eq(rp.cont.map((e) => e.target).join(","), "Java,Docker", "持续=生效且非近期进入"); +eq(rp.newCore.map((e) => e.target).join(","), "Kubernetes", "新核心=近 4 季进入"); +eq(computeReplaceBuckets(rpEdges, []).newCore.length, 0, "无窗口时无新核心"); + +/* radarValues:双雷达候选人四维归一化(缺失 0 / 超界截断) */ +const radarValues = new Function( + `const RADAR_KEYS = ["required", "preferred", "experience", "scenario"];\n${extractFn("radarValues")}; return radarValues;`)(); +eq(radarValues({ required: 80, preferred: 50, experience: 100, scenario: 0 }).join(","), "0.8,0.5,1,0", "雷达四维映射"); +eq(radarValues({ required: 120 }).join(","), "1,0,0,0", "雷达超界截断"); +eq(radarValues(null).join(","), "0,0,0,0", "雷达空对象兜底"); + /* graph.js 纯逻辑:时序边生效判定 / 职级带归属(函数在 IIFE 内,按两空格缩进闭合提取) */ const gsrc = fs.readFileSync(path.join(__dirname, "..", "webui", "graph.js"), "utf-8"); function loadGraphFns(names) { diff --git a/webui/app.js b/webui/app.js index 5b850ec..2ae5ecb 100644 --- a/webui/app.js +++ b/webui/app.js @@ -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 = ` -
+
${EMPTY_SVG}

${gone ? "该能力当前没有可展示的公开原文,已进入人工核验队列" : "暂时无法读取该条证据,请检查服务后重试"}

@@ -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: '' }, - { key: "boosted", label: "关键技能增强", act: "evolution", sub: "近 2 季度新增+增强事件", icon: '' }, - { key: "review", label: "待人工复核", act: "desk", sub: "候选岗位 + 候选技能关系", icon: '' }, - { key: "affected", label: "影响岗位", act: "evolution", sub: "近 2 季度发生变化的岗位", icon: '' }, +/* 空状态统一占位图形(SVG + 引导文案 + 行动按钮中的图形部分) */ +const EMPTY_SVG = ''; + +/* Hero 指标区:有效 JD / 独立企业 / 技能演化事件 / 人工复核率(浅色渐变卡 + count-up 数字滚动) */ +const HERO_DEFS = [ + { key: "jd", label: "有效 JD", unit: " 条", sub: "去重降噪后入图", + icon: '' }, + { key: "companies", label: "独立企业", unit: " 家", sub: "跨企业交叉验证,防单一企业造词", + icon: '' }, + { key: "events", label: "技能演化事件", unit: " 项", sub: "岗位能力动态更新累计", act: "evolution", + icon: '' }, + { key: "review", label: "人工复核率", unit: "%", sub: "审核留痕覆盖率", act: "desk", + icon: '' }, ]; +/* 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) => ` -
-
${m.icon}${m.label}
-
${values[m.key] ?? "—"}
- ${m.sub} + $("#metric-cards").innerHTML = HERO_DEFS.map((m) => ` +
+
${m.icon}${m.label}
+
—
+
${m.sub}
`).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() {
${k}${v} ${esc(sub)}
`).join(""); } catch (e) { - box.innerHTML = `
+ box.innerHTML = `
${EMPTY_SVG}

可信度统计暂时加载失败:${esc(friendlyErr(e))}

`; - $("#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 = `
${EMPTY_SVG}

暂无变化播报

`; + return; + } + box.innerHTML = `
${items.map((e, i) => { const m = CHANGE_META[e.change_type] || { tag: "tag-gray", color: "#86909c" }; - return `
+ return `
@@ -489,8 +527,24 @@ function renderFeed(events) {
${esc(e.change_type)}
`; - }).join("") : `
暂无变化播报
`; - $$("#activity-feed .feed-item").forEach((el) => { + }).join("")}
+
${items.map((_, i) => ``).join("")}
`; + const cards = $$("#activity-feed .bcast-card"); + const dots = $$("#activity-feed .bcast-dots i"); + let idx = 0; + const show = (k) => { + idx = k; + cards.forEach((c, j) => c.classList.toggle("on", j === k)); + dots.forEach((d, j) => d.classList.toggle("on", j === k)); + }; + const start = () => { + if (CN._bcastTimer) clearInterval(CN._bcastTimer); + CN._bcastTimer = setInterval(() => show((idx + 1) % items.length), 3000); + }; + start(); + box.onmouseenter = () => { if (CN._bcastTimer) { clearInterval(CN._bcastTimer); CN._bcastTimer = null; } }; + box.onmouseleave = start; + cards.forEach((el) => { el.onclick = () => { const e = items[+el.dataset.i]; switchView("evolution"); @@ -498,13 +552,17 @@ function renderFeed(events) { loadEvolution(); }; }); + dots.forEach((d) => { d.onclick = () => { show(+d.dataset.i); start(); } }); } /* 待办行动卡(最多 3 张:结论 + 证据强度 + 处理按钮;完整台账见审核台/下方台账) */ function renderPending(pending) { const box = $("#pending-list"); if (!pending.length) { - box.innerHTML = `
暂无待审核候选(可在图谱页触发新岗位发现)
`; + box.innerHTML = `
${EMPTY_SVG} +

暂无待审核候选(可在图谱页触发新岗位发现)

+
`; + $("#pending-goto-graph").onclick = () => switchView("graph"); return; } box.innerHTML = pending.slice(0, 3).map((c) => { @@ -608,7 +666,13 @@ async function renderReviewDesk(focusId) { return; } if (!pending.length) { - box.innerHTML = `
暂无待审核候选(可在图谱页触发新岗位发现)
`; + box.innerHTML = `
${EMPTY_SVG} +

暂无待审核候选
可在图谱页触发新岗位发现,候选经人工审核后才会入图

+
`; + $("#desk-goto-graph").onclick = () => { + $("#review-desk-mask").classList.add("hidden"); + switchView("graph"); + }; return; } const levelTag = { "高": "tag-green", "较高": "tag-cyan", "中": "tag-orange", "低": "tag-red" }; @@ -692,55 +756,116 @@ function fitCanvas(canvas, cssH) { return { ctx, W, H }; } -/* 变化事件流:近 N 季度全岗位口径 新增/增强/减弱/退出 堆叠柱,点击柱段跳演化页对应岗位 */ -function drawEventFlow(canvas, events, n) { - const { ctx, W, H } = fitCanvas(canvas, 220); +/* 季度变化事件序列(纯逻辑,node 可测):近 n 个季度,每季总量 + 四类变化计数 */ +function trendSeries(events, n) { + const windows = [...new Set(events.map((e) => e.window))].sort().slice(-n); + return windows.map((w) => { + const byType = {}; + Object.keys(CHANGE_META).forEach((t) => { byType[t] = 0; }); + let total = 0; + events.forEach((e) => { + if (e.window !== w) return; + total += 1; + if (e.change_type in byType) byType[e.change_type] += 1; + }); + return { window: w, total, byType }; + }); +} + +/* Catmull-Rom 转三次贝塞尔:过点平滑曲线(canvas 绘制辅助) */ +function smoothPath(ctx, pts) { + ctx.moveTo(pts[0].x, pts[0].y); + for (let i = 0; i < pts.length - 1; i++) { + const p0 = pts[Math.max(0, i - 1)], p1 = pts[i]; + const p2 = pts[i + 1], p3 = pts[Math.min(pts.length - 1, i + 2)]; + ctx.bezierCurveTo( + p1.x + (p2.x - p0.x) / 6, p1.y + (p2.y - p0.y) / 6, + p2.x - (p3.x - p1.x) / 6, p2.y - (p3.y - p1.y) / 6, + p2.x, p2.y); + } +} + +/* 人才能力变化趋势:平滑折线 + 面积渐变 + hover 数值 tooltip,点击定位该季度变化最多的岗位 */ +function drawTrendLine(canvas, events, n) { + const { ctx, W, H } = fitCanvas(canvas, 240); ctx.clearRect(0, 0, W, H); $("#cmd-stack-legend").innerHTML = Object.entries(CHANGE_META).map(([t, m]) => `${t}`).join(""); - const windows = [...new Set(events.map((e) => e.window))].sort().slice(-n); - if (!windows.length) return; - const types = Object.keys(CHANGE_META); - const counts = windows.map((w) => { - const row = {}; - types.forEach((t) => { row[t] = events.filter((e) => e.window === w && e.change_type === t).length; }); - return row; - }); - const maxTotal = Math.max(1, ...counts.map((r) => types.reduce((a, t) => a + r[t], 0))); - const bw = W / windows.length; - const baseY = H - 22; - ctx.font = "10px sans-serif"; ctx.textAlign = "center"; - windows.forEach((w, i) => { - let y = baseY; - types.forEach((t) => { - const v = counts[i][t]; - if (!v) return; - const h = (v / maxTotal) * (H - 52); - ctx.fillStyle = CHANGE_META[t].color; - ctx.fillRect(i * bw + bw * 0.2, y - h, bw * 0.6, h); - y -= h; - }); - const total = types.reduce((a, t) => a + counts[i][t], 0); - if (total) { ctx.fillStyle = "#4e5969"; ctx.fillText(total, i * bw + bw / 2, y - 4); } + const kv = trendSeries(events, n); + if (!kv.length) return; + const pad = { l: 34, r: 14, t: 14, b: 26 }; + const max = Math.max(1, ...kv.map((p) => p.total)); + /* y 轴网格与刻度 */ + ctx.strokeStyle = "#f0f0f0"; ctx.fillStyle = "#86909c"; ctx.font = "10px sans-serif"; + for (let g = 0; g <= 4; g++) { + const y = pad.t + ((H - pad.t - pad.b) * g) / 4; + ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(W - pad.r, y); ctx.stroke(); + ctx.textAlign = "right"; + ctx.fillText(Math.round(max * (1 - g / 4)), pad.l - 6, y + 3); + } + const px = (i) => pad.l + (i / Math.max(1, kv.length - 1)) * (W - pad.l - pad.r); + const py = (v) => pad.t + (1 - v / max) * (H - pad.t - pad.b); + const pts = kv.map((p, i) => ({ x: px(i), y: py(p.total) })); + const hover = canvas._hoverIdx; + /* 平滑折线 + 面积渐变 */ + ctx.beginPath(); + smoothPath(ctx, pts); + ctx.strokeStyle = "#1890ff"; ctx.lineWidth = 2; ctx.stroke(); + ctx.lineTo(pts[pts.length - 1].x, H - pad.b); ctx.lineTo(pts[0].x, H - pad.b); ctx.closePath(); + const grad = ctx.createLinearGradient(0, pad.t, 0, H - pad.b); + grad.addColorStop(0, "rgba(24,144,255,0.22)"); grad.addColorStop(1, "rgba(24,144,255,0.02)"); + ctx.fillStyle = grad; ctx.fill(); + /* 数据点 + x 轴季度标签 */ + ctx.textAlign = "center"; + pts.forEach((p, i) => { + ctx.fillStyle = i === hover ? "#096dd9" : "#1890ff"; + ctx.beginPath(); ctx.arc(p.x, p.y, i === hover ? 4.5 : 3, 0, 7); ctx.fill(); ctx.fillStyle = "#86909c"; - ctx.fillText(w.slice(2), i * bw + bw / 2, H - 6); + ctx.fillText(kv[i].window.slice(2), p.x, H - 8); }); - canvas._bars = windows.map((w, i) => ({ w, x0: i * bw, x1: (i + 1) * bw })); + + const nearestIdx = (clientX) => { + const x = clientX - canvas.getBoundingClientRect().left; + let best = null, bd = 1e9; + pts.forEach((p, i) => { const d = Math.abs(p.x - x); if (d < bd) { bd = d; best = i; } }); + return { best, bd }; + }; + canvas.onmousemove = (ev) => { + const tip = $("#trend-tip"); + const { best, bd } = nearestIdx(ev.clientX); + if (best == null || bd > 48) { + if (canvas._hoverIdx != null) { canvas._hoverIdx = null; drawTrendLine(canvas, events, n); } + tip.classList.add("hidden"); + return; + } + if (canvas._hoverIdx !== best) { canvas._hoverIdx = best; drawTrendLine(canvas, events, n); } + const p = kv[best]; + const tip2 = $("#trend-tip"); + tip2.innerHTML = `${esc(p.window)} · 变化事件 ${p.total} 项
` + + (Object.entries(p.byType).filter(([, v]) => v).map(([t, v]) => `${t} ${v}`).join(" · ") || "本季度无四类变化"); + tip2.classList.remove("hidden"); + const ox = canvas.offsetLeft, oy = canvas.offsetTop; + tip2.style.left = `${Math.min(Math.max(pts[best].x + ox + 12, 4), W + ox - 150)}px`; + tip2.style.top = `${Math.max(pts[best].y + oy - 12, 4)}px`; + }; + canvas.onmouseleave = () => { + $("#trend-tip").classList.add("hidden"); + if (canvas._hoverIdx != null) { canvas._hoverIdx = null; drawTrendLine(canvas, events, n); } + }; canvas.onclick = (ev) => { - const rect = canvas.getBoundingClientRect(); - const x = ev.clientX - rect.left; - const bar = (canvas._bars || []).find((b) => x >= b.x0 && x < b.x1); - if (!bar) return; + const { best } = nearestIdx(ev.clientX); + if (best == null) return; + const w = kv[best].window; /* 跳到该季度事件最多的岗位演化报告 */ const byJob = {}; - events.filter((e) => e.window === bar.w).forEach((e) => { byJob[e.job_id] = (byJob[e.job_id] || 0) + 1; }); + events.filter((e) => e.window === w).forEach((e) => { byJob[e.job_id] = (byJob[e.job_id] || 0) + 1; }); const top = Object.entries(byJob).sort((a, b) => b[1] - a[1])[0]; switchView("evolution"); if (top && $("#evo-job-select").querySelector(`option[value="${top[0]}"]`)) { $("#evo-job-select").value = top[0]; loadEvolution(); } - toast(`已定位 ${bar.w} 季度变化最多的岗位`, "info"); + toast(`已定位 ${w} 季度变化最多的岗位`, "info"); }; } @@ -1037,15 +1162,15 @@ function drawHeatLine(canvas, kv) { /* 面积 + 折线 */ ctx.beginPath(); kv.forEach(([, v], i) => ctx[i ? "lineTo" : "moveTo"](px(i), py(v))); - ctx.strokeStyle = "#2f54eb"; ctx.lineWidth = 2; ctx.stroke(); + ctx.strokeStyle = "#1890ff"; ctx.lineWidth = 2; ctx.stroke(); ctx.lineTo(px(kv.length - 1), H - pad.b); ctx.lineTo(px(0), H - pad.b); ctx.closePath(); const grad = ctx.createLinearGradient(0, pad.t, 0, H - pad.b); - grad.addColorStop(0, "rgba(47,84,235,0.25)"); grad.addColorStop(1, "rgba(47,84,235,0.02)"); + grad.addColorStop(0, "rgba(24,144,255,0.25)"); grad.addColorStop(1, "rgba(24,144,255,0.02)"); ctx.fillStyle = grad; ctx.fill(); /* 点与 x 轴 */ ctx.textAlign = "center"; kv.forEach(([k, v], i) => { - ctx.fillStyle = "#2f54eb"; + ctx.fillStyle = "#1890ff"; ctx.beginPath(); ctx.arc(px(i), py(v), 3, 0, 7); ctx.fill(); if (kv.length <= 14 || i % Math.ceil(kv.length / 14) === 0) { ctx.fillStyle = "#86909c"; ctx.fillText(k, px(i), H - 8); @@ -1093,22 +1218,65 @@ function drawStackBars(canvas, events) { /* 能力生命线(Swimlane):每个核心技能一行(近 8 季度),线段=生效区间,圆点=变更事件。 * 行状态取该技能最近一次事件:新增=青绿 / 增强=蓝紫 / 减弱=琥珀 / 退出=砖红 / 持续=蓝灰 */ +/* 能力更替分桶(纯逻辑,node 可测):旧核心(已退出) → 持续能力 → 新核心(近 4 季度进入) */ +function computeReplaceBuckets(edges, windows) { + const wins = (windows || []).slice().sort(); + const recent4 = new Set(wins.slice(-4)); + const alive = (e) => e.intervals.some(([s, en]) => en === null); + const start = (e) => e.intervals.map((i) => i[0]).sort()[0]; + return { + oldCore: edges.filter((e) => !alive(e)), + cont: edges.filter((e) => alive(e) && !recent4.has(start(e))), + newCore: edges.filter((e) => alive(e) && recent4.has(start(e))), + }; +} + +/* 驾驶舱「岗位能力更替」卡:全岗位口径近 4 季度(与演化页更替流共用分桶逻辑) */ +function renderCmdReplace() { + const box = $("#cmd-replace"); + const g = CN.graphData || {}; + const wins = [...new Set((g.events || []).map((e) => e.window))].sort(); + const edges = (g.edges || []).filter((e) => e.rel === "REQUIRES" && (e.intervals || []).length); + if (!edges.length) { + box.innerHTML = `
${EMPTY_SVG}

暂无能力更替数据

`; + return; + } + const buckets = computeReplaceBuckets(edges, wins); + const col = (title, list, cls, note, tagCls) => { + const skills = [...new Set(list.map((e) => e.target))]; + return `
+
${title}(${skills.length})
+
${note}
+
${skills.slice(0, 10).map((s) => + `${esc(s)}`).join("") || + '无'} + ${skills.length > 10 ? `+${skills.length - 10}` : ""}
+
`; + }; + box.innerHTML = `
+ ${col("旧核心(已退出)", buckets.oldCore, "old", "曾生效,当前已退出画像", "tag-red")} +
→
+ ${col("持续能力", buckets.cont, "keep", "跨周期稳定生效的岗位基本盘", "tag-blue")} +
→
+ ${col("新核心(近 4 季度)", buckets.newCore, "new", "本期新增或升级的能力", "tag-cyan")} +
`; + box.querySelectorAll(".rp-chip").forEach((el) => { + el.onclick = () => { switchView("graph"); if (CN.gotoNode) CN.gotoNode(el.dataset.skill); }; + }); +} + /* 能力更替流:旧核心(已退出必备) → 持续能力 → 新核心(近 4 季度新进入) */ function renderEvoReplace(jobId) { const box = $("#evo-replace"); const g = CN.graphData || {}; const wins = [...new Set((g.events || []).map((e) => e.window))].sort(); - const recent4 = new Set(wins.slice(-4)); const edges = (g.edges || []).filter((e) => e.source === jobId && e.rel === "REQUIRES" && (e.intervals || []).length); if (!edges.length) { box.innerHTML = `
暂无能力更替数据
`; return; } const alive = (e) => e.intervals.some(([s, en]) => en === null); - const start = (e) => e.intervals.map((i) => i[0]).sort()[0]; - const oldCore = edges.filter((e) => !alive(e)); - const cont = edges.filter((e) => alive(e) && !recent4.has(start(e))); - const newCore = edges.filter((e) => alive(e) && recent4.has(start(e))); + const { oldCore, cont, newCore } = computeReplaceBuckets(edges, wins); const chip = (e) => `${esc(e.target)}`; const col = (title, list, cls, note) => ` @@ -1289,6 +1457,23 @@ $("#btn-goto-path").onclick = () => { loadPathJob($("#match-job-select").value, matchState.skills).then(() => $("#btn-path").click()); }; +/* 缺失技能「去学习」:跳学习路径页,带入岗位与已掌握技能,生成路线并在技能清单中闪烁定位目标技能 */ +function gotoLearnSkill(skill) { + const jobId = $("#match-job-select").value; + $("#path-job-select").value = jobId; + switchView("path"); + loadPathJob(jobId, matchState.skills).then(() => { + $("#btn-path").click(); + if (!skill) return; + const chip = $$("#path-skill-pool .pool-chip").find((el) => el.dataset.skill === skill); + if (chip) { + chip.scrollIntoView({ block: "center", behavior: "smooth" }); + chip.classList.add("flash"); + setTimeout(() => chip.classList.remove("flash"), 1800); + } + }); +} + /* 总分 → 自然语言结论(答辩口径:分数只是结论的辅助) */ function matchAdvice(score) { if (score >= 80) return "强烈推荐投递:能力画像与目标岗位高度匹配,可优先安排面试。"; @@ -1307,24 +1492,37 @@ function renderMatchResult(m) { $("#matched-chips").innerHTML = [...m.matched_required.map((s) => chip(s, "tag-green")), ...m.matched_preferred.map((s) => chip(s, "tag-cyan"))].join("") || `无命中(简历中未识别到本岗位所需技能)`; - /* 缺口技能:超过 5 项默认折叠,避免清单压垮用户 */ + /* 缺口技能:chips 可点击「去学习」(跳学习路径页并闪烁定位该技能);超过 5 项默认折叠 */ + const learnChip = (s, cls) => + `${esc(s)}`; const missing = [ - ...m.missing_required.map((s) => chip(s, "tag-red")), - ...m.missing_preferred.map((s) => chip(s, "tag-orange")), + ...m.missing_required.map((s) => learnChip(s, "tag-red")), + ...m.missing_preferred.map((s) => learnChip(s, "tag-orange")), ]; + const missSkills = [...m.missing_required, ...m.missing_preferred]; const missBox = $("#missing-chips"); + const learnFoot = missing.length ? `` : ""; + const bindLearn = () => { + missBox.querySelectorAll(".miss-learn").forEach((el) => { + el.onclick = () => gotoLearnSkill(el.dataset.skill); + }); + const all = $("#miss-learn-all"); + if (all) all.onclick = () => gotoLearnSkill(missSkills[0]); + }; if (!missing.length) { missBox.innerHTML = `无`; } else if (missing.length <= 5) { - missBox.innerHTML = missing.join(""); + missBox.innerHTML = missing.join("") + learnFoot; } else { const renderMiss = (expanded) => { missBox.innerHTML = (expanded ? missing : missing.slice(0, 5)).join("") + - ``; + `` + learnFoot; $("#miss-toggle").onclick = () => renderMiss(!expanded); + bindLearn(); }; renderMiss(false); } + bindLearn(); $("#transfer-chips").innerHTML = m.transferable_skills.map((s) => chip(s, "tag-purple")).join("") || `暂无可迁移技能`; // 声明掌握但与本岗位无关的技能:显式告知"不计入评分",避免用户疑惑 @@ -1435,19 +1633,28 @@ function drawGauge(canvas, score) { ctx.lineWidth = 14; ctx.lineCap = "round"; ctx.strokeStyle = "#f0f1f5"; ctx.beginPath(); ctx.arc(cx, cy, R, Math.PI, 2 * Math.PI); ctx.stroke(); - const color = score >= 80 ? "#00b42a" : score >= 60 ? "#2f54eb" : "#ff7d00"; + const color = score >= 80 ? "#00b42a" : score >= 60 ? "#1890ff" : "#ff7d00"; ctx.strokeStyle = color; ctx.beginPath(); ctx.arc(cx, cy, R, Math.PI, Math.PI * (1 + Math.min(100, score) / 100)); ctx.stroke(); $("#gauge-score").textContent = Math.round(score); $("#gauge-score").style.color = color; } +const RADAR_KEYS = ["required", "preferred", "experience", "scenario"]; +const RADAR_LABELS = { required: "必备技能", preferred: "加分技能", experience: "经验年限", scenario: "场景经验" }; + +/* 雷达四维归一化(纯逻辑,node 可测):缺失键按 0,超界截断到 [0,1] */ +function radarValues(dims) { + return RADAR_KEYS.map((k) => Math.max(0, Math.min(100, (dims || {})[k] || 0)) / 100); +} + +/* 双系列雷达:实线=岗位基准(四维 100% 轮廓),虚线=候选人四维得分(图例见画布下方 DOM) */ function drawRadar(canvas, dims) { const ctx = canvas.getContext("2d"); ctx.clearRect(0, 0, 340, 280); const cx = 170, cy = 145, R = 100; - const labels = { required: "必备技能", preferred: "加分技能", experience: "经验年限", scenario: "场景经验" }; - const keys = Object.keys(labels); + const keys = RADAR_KEYS; + const labels = RADAR_LABELS; const angle = (i) => -Math.PI / 2 + (i * 2 * Math.PI) / keys.length; for (let ring = 1; ring <= 4; ring++) { ctx.beginPath(); @@ -1462,20 +1669,31 @@ function drawRadar(canvas, dims) { ctx.lineTo(cx + R * Math.cos(angle(i)), cy + R * Math.sin(angle(i))); ctx.strokeStyle = "#f0f0f0"; ctx.stroke(); }); + /* 岗位基准:四维 100% 实线轮廓 */ ctx.beginPath(); - keys.forEach((k, i) => { - const r = (R * (dims[k] || 0)) / 100; + keys.forEach((_, i) => { + ctx[i ? "lineTo" : "moveTo"](cx + R * Math.cos(angle(i)), cy + R * Math.sin(angle(i))); + }); + ctx.closePath(); + ctx.strokeStyle = "#1890ff"; ctx.lineWidth = 2; ctx.stroke(); + /* 候选人:虚线 + 面积填充 */ + const vals = radarValues(dims); + ctx.beginPath(); + keys.forEach((_, i) => { + const r = R * vals[i]; ctx[i ? "lineTo" : "moveTo"](cx + r * Math.cos(angle(i)), cy + r * Math.sin(angle(i))); }); ctx.closePath(); - ctx.fillStyle = "rgba(47,84,235,0.18)"; ctx.fill(); - ctx.strokeStyle = "#2f54eb"; ctx.lineWidth = 2; ctx.stroke(); + ctx.fillStyle = "rgba(19,194,194,0.15)"; ctx.fill(); + ctx.setLineDash([5, 4]); + ctx.strokeStyle = "#13c2c2"; ctx.lineWidth = 2; ctx.stroke(); + ctx.setLineDash([]); keys.forEach((k, i) => { - const r = (R * (dims[k] || 0)) / 100; - ctx.fillStyle = "#2f54eb"; + const r = R * vals[i]; + ctx.fillStyle = "#13c2c2"; ctx.beginPath(); ctx.arc(cx + r * Math.cos(angle(i)), cy + r * Math.sin(angle(i)), 3.5, 0, 7); ctx.fill(); ctx.fillStyle = "#4e5969"; ctx.font = "12px sans-serif"; ctx.textAlign = "center"; - ctx.fillText(`${labels[k]} ${Math.round(dims[k] || 0)}`, + ctx.fillText(`${labels[k]} ${Math.round((dims || {})[k] || 0)}`, cx + (R + 36) * Math.cos(angle(i)), cy + (R + 22) * Math.sin(angle(i)) + 4); }); } @@ -2039,7 +2257,7 @@ async function openConfigModal(needToken, firstRun) { ? `离线回退` : `联网就绪`; openModal(firstRun ? "欢迎使用 CareerNexus 职联 · 初始设置" : "系统设置", ` ${firstRun ? `
- 首次使用,建议先完成基础配置(均可稍后通过右上角 ⚙ 修改): + 首次使用,建议先完成基础配置(均可稍后通过右上角设置按钮修改):
· 配置 LLM API 密钥可启用大模型抽取(不配则离线确定性方案兜底)
· 配置语义向量模型(本地 BGE-M3 或联网 API)可启用语义相似匹配
· 需要对外提供服务时,再调整监听地址与访问令牌 @@ -2109,7 +2327,7 @@ async function openConfigModal(needToken, firstRun) {
- +
自动完成全部配置,推荐首次使用:
① 安装 sentence-transformers 依赖(PyPI 镜像站) @@ -2290,7 +2508,7 @@ $("#btn-config").onclick = () => openConfigModal(false); /* ================= 启动 ================= */ window.addEventListener("resize", () => { - if (CN._flowEvents) drawEventFlow($("#trend-canvas"), CN._flowEvents, 8); + if (CN._flowEvents) drawTrendLine($("#trend-canvas"), CN._flowEvents, 8); drawHeat(); }); (async function boot() { diff --git a/webui/graph.js b/webui/graph.js index 08be7cb..a38c2a8 100644 --- a/webui/graph.js +++ b/webui/graph.js @@ -458,7 +458,7 @@ ctx.save(); LEVEL_BANDS.forEach((b, i) => { const sy = b.y * G.cam.k + G.cam.y; - ctx.fillStyle = i % 2 ? "rgba(47,84,235,0.025)" : "rgba(19,194,194,0.03)"; + ctx.fillStyle = i % 2 ? "rgba(24,144,255,0.025)" : "rgba(19,194,194,0.03)"; ctx.fillRect(0, sy - 175 * G.cam.k, c.clientWidth, 350 * G.cam.k); ctx.fillStyle = "rgba(78,89,105,0.85)"; ctx.font = "bold 13px sans-serif"; ctx.textAlign = "left"; @@ -481,10 +481,10 @@ const label = `${a.cat}(${n})`; ctx.font = "bold 13px sans-serif"; const tw = ctx.measureText(label).width; - ctx.fillStyle = "rgba(47,84,235,0.08)"; - ctx.strokeStyle = "rgba(47,84,235,0.35)"; + ctx.fillStyle = "rgba(24,144,255,0.08)"; + ctx.strokeStyle = "rgba(24,144,255,0.35)"; ctx.beginPath(); ctx.roundRect(p.x - tw / 2 - 10, p.y - 14, tw + 20, 24, 12); ctx.fill(); ctx.stroke(); - ctx.fillStyle = "#2f54eb"; ctx.textAlign = "center"; + ctx.fillStyle = "#1890ff"; ctx.textAlign = "center"; ctx.fillText(label, p.x, p.y + 3); }); ctx.restore(); @@ -505,7 +505,7 @@ ctx.setLineDash([]); ctx.lineWidth = isHot ? 1.4 : 0.8; } else if (isReq) { - ctx.strokeStyle = dim ? "rgba(78,89,105,0.05)" : isHot ? "rgba(47,84,235,0.75)" : "rgba(78,89,105,0.28)"; + ctx.strokeStyle = dim ? "rgba(78,89,105,0.05)" : isHot ? "rgba(24,144,255,0.75)" : "rgba(78,89,105,0.28)"; ctx.setLineDash([]); ctx.lineWidth = isHot ? 1.8 : 1; } else { @@ -534,7 +534,7 @@ /* 关系标签(颜色 + 文字双重编码) */ if (G.showLabels && e.rel === "REQUIRES" && G.cam.k > 0.55 && !dim) { ctx.font = "10px sans-serif"; ctx.textAlign = "center"; - ctx.fillStyle = isReq ? "rgba(47,84,235,0.85)" : "rgba(9,151,156,0.9)"; + ctx.fillStyle = isReq ? "rgba(24,144,255,0.85)" : "rgba(9,151,156,0.9)"; ctx.fillText(isReq ? "必备" : "加分", (pa.x + pb.x) / 2, (pa.y + pb.y) / 2 - 3); } ctx.restore(); @@ -550,7 +550,7 @@ const dim = sel && !isSel && !isHop; const meta = TYPE_META[n.type] || { color: "#86909c" }; ctx.save(); - ctx.globalAlpha = dim ? 0.15 : 1; + ctx.globalAlpha = dim ? 0.2 : 1; ctx.fillStyle = meta.color; if (n.type === "Skill") { const s = r * 1.7; @@ -568,7 +568,7 @@ ctx.strokeStyle = "#1d2129"; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(p.x, p.y, r + 4, 0, 7); ctx.stroke(); } else if (isHop) { - ctx.strokeStyle = "rgba(47,84,235,0.55)"; ctx.lineWidth = 1.5; + ctx.strokeStyle = "rgba(24,144,255,0.55)"; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(p.x, p.y, r + 2.5, 0, 7); ctx.stroke(); } /* NEW 标记:待审核候选与近一年入库新岗位,节点右上方脉动圆点 + NEW 文字(canvas 绘制,alpha 呼吸复用 requestDraw 循环) */ @@ -582,7 +582,7 @@ ctx.beginPath(); ctx.arc(bx, by, 4, 0, 7); ctx.stroke(); ctx.font = "bold 9px sans-serif"; ctx.textAlign = "left"; ctx.fillText("NEW", bx + 6, by + 3); - ctx.globalAlpha = dim ? 0.15 : 1; + ctx.globalAlpha = dim ? 0.2 : 1; G._newPulse = true; // 有 NEW 节点在画面上:持续请求重绘形成呼吸 } /* 标签按需显示(文字重叠治理):岗位常显;技能/场景/行业仅在选中/悬停/选中节点一跳邻居时显示; @@ -666,7 +666,7 @@ /* 视口框 */ const c = canvas(); const tl = toWorld(0, 0), br = toWorld(c.clientWidth, c.clientHeight); - ctx.strokeStyle = "#2f54eb"; ctx.lineWidth = 1.4; + ctx.strokeStyle = "#1890ff"; ctx.lineWidth = 1.4; ctx.strokeRect(tl.x * k + ox, tl.y * k + oy, (br.x - tl.x) * k, (br.y - tl.y) * k); mm._proj = { k, ox, oy }; } @@ -736,6 +736,15 @@ const node = pickNode(e.clientX - rect.left, e.clientY - rect.top); selectNode(node ? node.id : null); }); + /* 节点右键菜单:选中节点 + 弹出自定义菜单(查看证据 / 时点对比 / 人岗诊断 / 子图开关) */ + c.addEventListener("contextmenu", (e) => { + e.preventDefault(); + const rect = c.getBoundingClientRect(); + const node = pickNode(e.clientX - rect.left, e.clientY - rect.top); + if (!node) { hideCtxMenu(); return; } + if (G.selected !== node.id) selectNode(node.id); + showCtxMenu(node, e.clientX, e.clientY); + }); c.addEventListener("wheel", (e) => { e.preventDefault(); const rect = c.getBoundingClientRect(); @@ -744,8 +753,71 @@ window.addEventListener("resize", () => { requestDraw(); drawMinimap(); }); } + /* ================= 节点右键菜单(CSS 定位 DOM,点空白 / Esc 关闭) ================= */ + function hideCtxMenu() { + const m = $("#ctx-menu"); + if (m) m.classList.add("hidden"); + } + function showCtxMenu(n, clientX, clientY) { + const menu = $("#ctx-menu"); + const wrap = $("#graph-canvas-wrap"); + const wr = wrap.getBoundingClientRect(); + const isJob = n.type === "Job" && !(n.pending || n.status === "pending"); + const items = [ + { key: "evidence", label: "查看证据 JD 原文" }, + { key: "compare", label: "能力画像时点对比", disabled: !isJob }, + { key: "match", label: "开始人岗诊断", disabled: !isJob }, + { key: "solo", label: G.soloJob === n.id ? "退出岗位子图" : "岗位子图开关", disabled: !isJob }, + ]; + menu.innerHTML = `
${esc(n.title || n.id)}
` + items.map((it) => + ``).join(""); + menu.classList.remove("hidden"); + /* 先渲染量尺寸,再定位并防止溢出画布容器 */ + let x = clientX - wr.left, y = clientY - wr.top; + x = Math.max(4, Math.min(x, wr.width - menu.offsetWidth - 4)); + y = Math.max(4, Math.min(y, wr.height - menu.offsetHeight - 4)); + menu.style.left = `${x}px`; + menu.style.top = `${y}px`; + menu.querySelectorAll(".ctx-item:not(:disabled)").forEach((b) => { + b.onclick = () => { hideCtxMenu(); ctxAction(b.dataset.key, n); }; + }); + } + function ctxAction(key, n) { + if (key === "evidence") { + const sources = new Set(); + G.edges.forEach((e) => { + if (e.source === n.id || e.target === n.id) (e.sources || []).forEach((s) => sources.add(s)); + }); + const list = [...sources]; + if (!list.length) return toast("该节点暂无来源证据记录", "info"); + CN.showJdEvidence(list, { jobTitle: n.title || n.id }); + } else if (key === "compare") { + CN.switchView("evolution"); + $("#evo-job-select").value = n.id; + $("#evo-job-select").dispatchEvent(new Event("change")); + setTimeout(() => { const d = $("#evo-diff"); if (d) d.scrollIntoView({ block: "center" }); }, 450); + } else if (key === "match") { + CN.switchView("match"); + $("#match-job-select").value = n.id; + $("#match-job-select").dispatchEvent(new Event("change")); + toast("已带入目标岗位,请上传或载入示例简历", "success"); + } else if (key === "solo") { + G.soloJob = G.soloJob === n.id ? null : n.id; + G._defaultApplied = true; + updateSoloBadge(); + updateRecentChange(); + if (G.soloJob) { + applySoloLayout(); + fitSoloView(); + toast(`已进入「${n.title || n.id}」岗位子图(同心分层:中心岗位 → 必备 → 加分 → 场景/行业)`, "success"); + } else fitView(); + } + } + /* ================= 工具条 / 筛选 ================= */ function bindToolbar() { + document.addEventListener("click", (e) => { if (!e.target.closest("#ctx-menu")) hideCtxMenu(); }); + document.addEventListener("keydown", (e) => { if (e.key === "Escape") hideCtxMenu(); }); $("#tool-zoom-in").onclick = () => zoomAt(1.25); $("#tool-zoom-out").onclick = () => zoomAt(0.8); $("#tool-fit").onclick = fitView; @@ -903,7 +975,7 @@ sub: `原因:聚类发现 ${c.source_count} 条 JD / ${c.company_count} 家企业指向未收录岗位 · 证据 ${c.source_count} 条`, })); G.events.slice(-4).reverse().forEach((e) => items.push({ - color: "#2f54eb", kind: "event", id: `${e.job_id}|${e.skill}|${e.window}`, nodeId: e.job_id, + color: "#1890ff", kind: "event", id: `${e.job_id}|${e.skill}|${e.window}`, nodeId: e.job_id, text: `「${esc(e.job_id.replace(/^job_/, ""))}」${esc(e.change_type)}技能 ${esc(e.skill)}`, sub: `证据:窗口 ${esc(e.window)} · 需求指数 ${e.prev_weight.toFixed(2)}→${e.curr_weight.toFixed(2)} · ${(e.source_ids || []).length} 条 JD`, })); @@ -1328,7 +1400,7 @@ values.forEach((v, i) => { const bw = c.width / values.length; const h = (v / max) * (c.height - 16); - ctx.fillStyle = "#2f54eb"; + ctx.fillStyle = "#1890ff"; ctx.fillRect(i * bw + 2, c.height - h - 12, bw - 4, h); if (i % 3 === 0) { ctx.fillStyle = "#86909c"; ctx.font = "9px sans-serif"; ctx.textAlign = "center"; diff --git a/webui/index.html b/webui/index.html index ba52627..38b3773 100644 --- a/webui/index.html +++ b/webui/index.html @@ -4,7 +4,7 @@ CareerNexus 职联 · 岗位与能力图谱平台 - + @@ -81,6 +81,9 @@
+ +
+

人才能力变化概览

@@ -93,18 +96,37 @@
-
-
+ +
-

近 8 季度变化事件流(全岗位)

- 点击柱段进入该季度演化详情 +

人才能力变化趋势

+ 近 8 季度 · 悬停看明细 · 点击定位岗位
-
- +
+ +
+
+
+

岗位能力更替(全岗位 · 近 4 季度)

+ 旧核心 → 持续 → 新核心 +
+
统计中…
+
+
+ +
+
+

变化播报

+ 自动轮播 · 悬停暂停 · 点击跳转演化详情 +
+
+
+ +

数据可信度

@@ -112,12 +134,6 @@
统计中…
-
-
-
-

变化播报

点击跳转演化详情
-
-

待办行动

@@ -266,8 +282,21 @@
—
+ + + +
-
+
图谱布局计算中…
@@ -466,6 +495,10 @@
+
+ 岗位基准(四维 100%) + 候选人得分 +
@@ -631,7 +664,7 @@
- - + + diff --git a/webui/style.css b/webui/style.css index b5d8886..1301dfa 100644 --- a/webui/style.css +++ b/webui/style.css @@ -4,9 +4,9 @@ ============================================================ */ :root { /* 品牌色 */ - --primary: #2f54eb; - --primary-hover: #1d39c4; - --primary-light: #f0f5ff; + --primary: #1890ff; + --primary-hover: #096dd9; + --primary-light: #e6f7ff; --success: #00b42a; --success-light: #e8ffea; --warning: #ff7d00; @@ -41,6 +41,7 @@ html, body { height: 100%; } body { font-family: var(--font); font-size: 14px; + line-height: 1.6; color: var(--text-1); background: var(--bg-page); overflow: hidden; @@ -80,7 +81,7 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: .brand { display: flex; align-items: center; gap: 8px; } .brand-logo { width: 30px; height: 30px; border-radius: 7px; - background: linear-gradient(135deg, #2f54eb, #13c2c2); + background: linear-gradient(135deg, #1890ff, #13c2c2); display: inline-flex; align-items: center; justify-content: center; } .brand-logo svg { width: 19px; height: 19px; color: #fff; } @@ -88,7 +89,7 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: .brand-name b { font-weight: 600; } .page-title { position: absolute; left: 50%; transform: translateX(-50%); - font-size: 15px; font-weight: 600; color: var(--text-1); + font-size: 20px; font-weight: 600; color: var(--text-1); } .topbar-right { margin-left: auto; display: flex; align-items: center; gap: 14px; } .global-search { @@ -134,7 +135,7 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: font-size: 12px; line-height: 20px; padding: 0 8px; border-radius: var(--radius-sm); border: 1px solid transparent; white-space: nowrap; } -.tag-blue { color: var(--primary); background: var(--primary-light); border-color: #d6e4ff; } +.tag-blue { color: var(--primary); background: var(--primary-light); border-color: #91d5ff; } .tag-green { color: var(--success); background: var(--success-light); border-color: #b7eb8f; } .tag-orange { color: var(--warning); background: var(--warning-light); border-color: #ffd591; } .tag-red { color: var(--danger); background: var(--danger-light); border-color: #ffa39e; } @@ -194,7 +195,7 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid var(--border); } -.card-head h3 { font-size: 15px; font-weight: 600; } +.card-head h3 { font-size: 18px; font-weight: 600; } .card-body { padding: 16px; } .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px; } @@ -225,7 +226,7 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: padding: 6px 10px; outline: none; background: #fff; transition: border-color 0.15s; } -.input:focus, .select:focus, .textarea:focus { border-color: var(--primary); box-shadow: 0 0 0 2px rgba(47, 84, 235, 0.1); } +.input:focus, .select:focus, .textarea:focus { border-color: var(--primary); box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1); } .textarea { width: 100%; resize: vertical; line-height: 1.6; } .select { height: 32px; } .form-row { margin-bottom: 16px; } @@ -246,30 +247,35 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: .empty svg { width: 44px; height: 44px; stroke-width: 1.2; margin-bottom: 10px; opacity: 0.5; } .empty p { font-size: 13px; line-height: 1.8; } .empty.small { padding: 20px; font-size: 13px; } -.skeleton-graph { - width: 240px; height: 240px; border-radius: 50%; - background: radial-gradient(circle, #e8ebf0 20%, transparent 21%), - conic-gradient(#e8ebf0, #f5f6f8, #e8ebf0); - animation: pulse 1.4s ease-in-out infinite; +.empty.small svg { width: 30px; height: 30px; } +/* 图谱加载骨架屏:节点/连线占位形状 + shimmer 扫光 */ +.sk-graph { position: relative; width: 280px; height: 240px; overflow: hidden; border-radius: var(--radius); } +.sk-node { position: absolute; border-radius: 50%; background: #e8ebf0; } +.sk-node.sk-sq { border-radius: 5px; } +.sk-center { left: 126px; top: 100px; width: 28px; height: 28px; } +.sk-n1 { left: 30px; top: 30px; width: 18px; height: 18px; } +.sk-n2 { left: 216px; top: 24px; width: 18px; height: 18px; } +.sk-n3 { left: 24px; top: 178px; width: 18px; height: 18px; } +.sk-n4 { left: 222px; top: 184px; width: 18px; height: 18px; } +.sk-n5 { left: 80px; top: 196px; width: 16px; height: 16px; } +.sk-n6 { left: 186px; top: 78px; width: 16px; height: 16px; } +.sk-n7 { left: 60px; top: 92px; width: 16px; height: 16px; } +.sk-edge { position: absolute; height: 3px; border-radius: 2px; background: #eef0f4; transform-origin: left center; } +.sk-e1 { left: 46px; top: 42px; width: 100px; transform: rotate(38deg); } +.sk-e2 { left: 152px; top: 106px; width: 84px; transform: rotate(-32deg); } +.sk-e3 { left: 42px; top: 186px; width: 106px; transform: rotate(-38deg); } +.sk-e4 { left: 152px; top: 122px; width: 92px; transform: rotate(40deg); } +.sk-e5 { left: 96px; top: 200px; width: 56px; transform: rotate(-58deg); } +.sk-e6 { left: 76px; top: 96px; width: 56px; transform: rotate(6deg); } +.sk-graph::after { + content: ""; position: absolute; inset: 0; + background: linear-gradient(100deg, transparent 30%, rgba(255, 255, 255, 0.8) 50%, transparent 70%); + animation: sk-sweep 1.6s linear infinite; } -@keyframes pulse { 50% { opacity: 0.5; } } +@keyframes sk-sweep { from { transform: translateX(-100%); } to { transform: translateX(100%); } } /* ============ 数据驾驶舱 ============ */ -.metric-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; } -.metric-card { - background: #fff; border: 1px solid var(--border); border-radius: var(--radius); - box-shadow: var(--shadow-card); padding: 16px; cursor: pointer; - transition: all 0.15s; position: relative; overflow: hidden; -} -.metric-card:hover { border-color: var(--primary); box-shadow: var(--shadow-pop); transform: translateY(-2px); } -.metric-card .mc-label { font-size: 13px; color: var(--text-2); display: flex; align-items: center; gap: 6px; } -.metric-card .mc-label svg { width: 15px; height: 15px; color: var(--primary); } -.metric-card .mc-num { font-size: 26px; font-weight: 600; font-variant-numeric: tabular-nums; margin: 6px 0 4px; } -.metric-card .mc-delta { font-size: 12px; display: inline-flex; align-items: center; gap: 3px; } -.metric-card .mc-delta.up { color: var(--success); } -.metric-card .mc-delta.down { color: var(--danger); } -.metric-card .mc-delta.flat { color: var(--text-3); } -.metric-card canvas { position: absolute; right: 10px; bottom: 10px; opacity: 0.9; } +/* 首屏 Hero 指标卡样式见文末「2026-08 前端体验升级专项」.hero-kpis */ .feed { max-height: 320px; overflow-y: auto; } .feed-item { @@ -301,7 +307,7 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: padding: 12px 16px; margin-bottom: 12px; } .graph-title { display: flex; align-items: center; gap: 10px; } -.graph-title h2 { font-size: 18px; font-weight: 600; } +.graph-title h2 { font-size: 20px; font-weight: 600; } .graph-actions { display: flex; gap: 8px; } .graph-view-tabs { margin: 0 auto; } @@ -404,7 +410,7 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: .gc-chips { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; max-height: 62px; overflow-y: auto; } .chain-chip { display: inline-flex; align-items: center; gap: 4px; - border: 1px solid #b7c6f5; background: var(--primary-light); color: var(--primary); + border: 1px solid #91d5ff; background: var(--primary-light); color: var(--primary); font-size: 12px; border-radius: 12px; padding: 2px 10px; cursor: pointer; } .chain-chip:hover { background: var(--primary); color: #fff; } @@ -450,7 +456,7 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: font-size: 13px; padding: 6px 8px; border-radius: var(--radius-sm); cursor: pointer; border: 1px solid transparent; } -.rel-item:hover { background: var(--primary-light); border-color: #d6e4ff; } +.rel-item:hover { background: var(--primary-light); border-color: #91d5ff; } .rel-item .ri-kind { flex: none; } .rel-item .ri-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .rel-item .ri-extra { font-size: 12px; color: var(--text-3); flex: none; } @@ -521,7 +527,7 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: .chip-editor { display: flex; flex-wrap: wrap; gap: 8px; min-height: 40px; border: 1px dashed var(--border-dark); border-radius: var(--radius); padding: 10px; } .skill-chip { display: inline-flex; align-items: center; gap: 5px; - background: var(--primary-light); color: var(--primary); border: 1px solid #d6e4ff; + background: var(--primary-light); color: var(--primary); border: 1px solid #91d5ff; font-size: 13px; border-radius: var(--radius-sm); padding: 3px 6px 3px 10px; } .skill-chip button { border: none; background: none; color: inherit; font-size: 14px; line-height: 1; padding: 0 2px; } @@ -733,7 +739,7 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: } .toast.success { border-color: #b7eb8f; color: var(--success); } .toast.error { border-color: #ffa39e; color: var(--danger); } -.toast.info { border-color: #d6e4ff; color: var(--primary); } +.toast.info { border-color: #91d5ff; color: var(--primary); } @keyframes toast-in { from { opacity: 0; transform: translateY(-8px); } } /* 滚动条 */ @@ -924,7 +930,7 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: /* 一键配置主按钮区 */ .setup-hero { - border: 1px solid #d6e4ff; background: var(--primary-light); + border: 1px solid #91d5ff; background: var(--primary-light); border-radius: var(--radius); padding: 14px 16px; margin-bottom: 14px; } .setup-hero .btn { width: 100%; } @@ -1074,17 +1080,15 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: /* ============ 2026Q3 改版:指挥台 / 审核台 / 证据地图 / 生命线 / 路线图 ============ */ -/* 指挥台首屏 */ +/* 指挥台首屏(弱化:结论行降级为普通卡片,主视觉让给 Hero 指标区与趋势图) */ .cmd-hero { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; - padding: 16px 20px; margin-bottom: 16px; - background: linear-gradient(135deg, #f0f5ff 0%, #ffffff 55%); + padding: 12px 20px; margin-bottom: 16px; } -.cmd-hero-main h2 { font-size: 18px; font-weight: 600; } -.cmd-sub { font-size: 13px; color: var(--text-2); margin-top: 6px; } -.cmd-scale { font-size: 12px; margin-top: 6px; } +.cmd-hero-main h2 { font-size: 20px; font-weight: 600; } +.cmd-sub { font-size: 13px; color: var(--text-2); margin-top: 4px; } +.cmd-scale { font-size: 12px; margin-top: 4px; } .cmd-hero-actions { display: flex; gap: 8px; flex: none; padding-top: 4px; } -.cmd-kpis { grid-template-columns: repeat(4, 1fr); margin-bottom: 16px; } .cmd-head-right { display: inline-flex; align-items: center; gap: 4px; } .cmd-ledger { margin-top: 16px; } .cmd-ledger .card-body { padding-top: 8px; } @@ -1128,7 +1132,7 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: padding: 14px 16px; margin-bottom: 12px; } .cand-card.flash { animation: cand-flash 1.6s ease; } -@keyframes cand-flash { 0%, 60% { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(47, 84, 235, 0.15); } } +@keyframes cand-flash { 0%, 60% { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(24, 144, 255, 0.15); } } .cc-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; } .cc-head b { font-size: 15px; } .cc-def { font-size: 13px; color: var(--text-2); line-height: 1.6; margin-bottom: 8px; } @@ -1210,8 +1214,17 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: } .path-demo-banner .btn { margin-left: auto; } -/* 驾驶舱事件流主图限高 */ -#trend-canvas { max-height: 260px; } +/* 驾驶舱趋势图:等高布局 + hover tooltip */ +.cmd-main { align-items: stretch; } +.cmd-main .card { display: flex; flex-direction: column; } +.cmd-main .card-body { flex: 1; display: flex; flex-direction: column; } +.trend-wrap { position: relative; } +#trend-canvas { flex: 1; min-height: 240px; max-height: 300px; } +.trend-tip { + position: absolute; z-index: 5; pointer-events: none; + background: rgba(29, 33, 41, 0.92); color: #fff; font-size: 12px; + border-radius: 6px; padding: 6px 10px; line-height: 1.6; white-space: nowrap; +} /* 演化页图表说明文字 */ .evo-chart-desc { font-size: 12px; color: var(--text-3); line-height: 1.7; margin: -2px 0 8px; } @@ -1227,3 +1240,76 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: .scan-track { flex: 1; display: flex; height: 14px; background: var(--bg-page); border-radius: 3px; overflow: hidden; } .scan-bar { display: block; height: 100%; } .scan-num { flex: none; width: 34px; font-size: 12px; color: var(--text-2); font-variant-numeric: tabular-nums; } + +/* ============ 2026-08 前端体验升级专项 ============ */ + +/* 驾驶舱 Hero 指标区:浅色渐变高亮卡 + count-up 数字 */ +.hero-kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 14px; } +.hero-kpi { + background: linear-gradient(135deg, var(--primary-light) 0%, #ffffff 62%); + border: 1px solid var(--border); border-radius: var(--radius); + box-shadow: var(--shadow-card); padding: 16px 18px; + transition: all 0.15s; +} +.hero-kpi.clickable { cursor: pointer; } +.hero-kpi.clickable:hover { border-color: var(--primary); box-shadow: var(--shadow-pop); transform: translateY(-2px); } +.hk-label { font-size: 13px; color: var(--text-2); display: flex; align-items: center; gap: 6px; } +.hk-label svg { width: 15px; height: 15px; color: var(--primary); } +.hk-num { + font-size: 30px; font-weight: 700; color: var(--primary); + font-variant-numeric: tabular-nums; margin: 6px 0 2px; line-height: 1.2; +} +.hk-sub { font-size: 12px; color: var(--text-3); } + +/* 变化播报:卡片翻页轮播(淡入淡出,非滚动) */ +.cmd-bcast-card { margin-top: 16px; } +.bcast-stage { position: relative; height: 68px; } +.bcast-card { + position: absolute; inset: 0; + opacity: 0; transition: opacity 0.45s ease; + pointer-events: none; +} +.bcast-card.on { opacity: 1; pointer-events: auto; } +.bcast-dots { display: flex; gap: 6px; justify-content: center; margin-top: 8px; } +.bcast-dots i { + width: 6px; height: 6px; border-radius: 50%; + background: var(--border-dark); cursor: pointer; transition: background 0.2s; +} +.bcast-dots i.on { background: var(--primary); } + +/* 图谱节点右键菜单 */ +.ctx-menu { + position: absolute; z-index: 40; min-width: 176px; + background: #fff; border: 1px solid var(--border); border-radius: var(--radius); + box-shadow: var(--shadow-pop); padding: 4px; +} +.ctx-title { + font-size: 12px; color: var(--text-3); padding: 6px 10px 5px; + border-bottom: 1px dashed var(--border); margin-bottom: 4px; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 220px; +} +.ctx-item { + display: block; width: 100%; text-align: left; + border: none; background: none; padding: 7px 10px; + font-size: 13px; color: var(--text-1); border-radius: var(--radius-sm); cursor: pointer; +} +.ctx-item:hover { background: var(--primary-light); color: var(--primary); } +.ctx-item.disabled, .ctx-item:disabled { color: var(--text-3); cursor: not-allowed; opacity: 0.6; } +.ctx-item.disabled:hover, .ctx-item:disabled:hover { background: none; color: var(--text-3); } + +/* 匹配页双雷达图例 */ +.radar-legend { + display: flex; gap: 18px; justify-content: center; margin-top: 6px; + font-size: 12px; color: var(--text-2); +} +.radar-legend span { display: inline-flex; align-items: center; gap: 6px; } +.rl-line { width: 18px; height: 0; display: inline-block; border-top: 2px solid var(--primary); } +.rl-line.dashed { border-top-style: dashed; border-top-color: var(--cyan); } + +/* 缺失技能「去学习」:chips 可点击 + 学习路径页目标技能闪烁定位 */ +.hm-chips .tag.miss-learn { cursor: pointer; } +.hm-chips .tag.miss-learn:hover { filter: brightness(0.94); box-shadow: 0 0 0 2px var(--primary-light); } +.pool-chip.flash { animation: cand-flash 1.6s ease; } + +/* 驾驶舱更替卡紧凑化 */ +.cmd-rp { grid-template-columns: 1fr 20px 1fr 20px 1fr; }