答辩级 UI 改造:指挥台驾驶舱/证据地图/演化生命线/成长路线图/候选审核台

- 驾驶舱改'人才能力变化指挥台':变化事件流主图+可信度卡+行动卡+播报卡
- 图谱子图同心分层布局、技能变化角标、系统发现折叠入右栏、主操作收敛
- 演化页双 Tab(岗位报告/全岗位扫描)+ 能力生命线 Swimlane + 更替流
- 学习路径成长路线图:概要条/示范路线/预计投入/标记完成联动
- 新增候选审核台抽屉 + GET /api/stats/credibility
- 简历裸姓名/专硕提取修复(桌面真实简历验收通过)+ BGE-M3 加载提示
This commit is contained in:
RoyirinRosmontis
2026-08-19 12:14:39 +08:00
parent ac6d08cdf2
commit 0b198e9976
9 changed files with 1002 additions and 238 deletions
+445 -156
View File
@@ -230,7 +230,7 @@ CN.fetchJd = fetchJd;
/* ================= 视图切换 / 侧栏 ================= */
const PAGE_TITLES = {
dashboard: "数据驾驶舱", graph: "全景图谱", evolution: "岗位演化",
dashboard: "人才能力变化指挥台", graph: "岗位能力证据地图", evolution: "岗位演化",
match: "人岗匹配", path: "学习路径",
};
const _loaded = {};
@@ -248,7 +248,10 @@ function switchView(name) {
}
if (name === "graph" && CN.graph && !_loaded.graph) { _loaded.graph = true; CN.graph.init(); }
if (name === "graph" && CN.graph && _loaded.graph) CN.graph.onShow();
if (name === "path" && !_loaded.path) { _loaded.path = true; loadPathJob($("#path-job-select").value); }
if (name === "path" && !_loaded.path) {
_loaded.path = true;
loadPathJob($("#path-job-select").value).then(() => renderDemoPath()); // 未诊断时展示示范路线
}
}
CN.switchView = switchView;
@@ -290,36 +293,15 @@ $$(".nav-item").forEach((btn) => { btn.onclick = () => switchView(btn.dataset.vi
});
})();
/* ================= 数据驾驶舱 ================= */
/* ================= 人才能力变化指挥台(数据驾驶舱) ================= */
/* 首屏 KPI:新岗位候选 / 关键技能增强 / 待人工复核 / 影响岗位(图谱规模降级为一行小字) */
const METRIC_DEFS = [
{ key: "jobs", label: "岗位节点", view: "graph", icon: '<svg viewBox="0 0 24 24"><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V5a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>' },
{ key: "skills", label: "技能节点", view: "graph", icon: '<svg viewBox="0 0 24 24"><path d="M12 2l2.4 4.9 5.4.8-3.9 3.8.9 5.4-4.8-2.5-4.8 2.5.9-5.4L4.2 7.7l5.4-.8L12 2z"/></svg>' },
{ key: "edges", label: "图谱关系边", view: "graph", icon: '<svg viewBox="0 0 24 24"><circle cx="6" cy="6" r="2.5"/><circle cx="18" cy="18" r="2.5"/><path d="M8 8l8 8"/></svg>' },
{ key: "events", label: "演化事件", view: "evolution", icon: '<svg viewBox="0 0 24 24"><path d="M3 17l5.5-5.5 3.5 3.5L20 7"/><path d="M15 7h5v5"/></svg>' },
{ key: "pending", label: "待审核候选", view: "graph", icon: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>' },
{ key: "candidates", label: "新岗位候选", act: "desk", sub: "聚类发现,待人工决策", icon: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>' },
{ key: "boosted", label: "关键技能增强", act: "evolution", sub: "近 2 季度新增+增强事件", icon: '<svg viewBox="0 0 24 24"><path d="M3 17l5.5-5.5 3.5 3.5L20 7"/><path d="M15 7h5v5"/></svg>' },
{ key: "review", label: "待人工复核", act: "desk", sub: "候选岗位 + 候选技能关系", icon: '<svg viewBox="0 0 24 24"><path d="M9 11l3 3 8-8"/><path d="M21 12v6a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2h11"/></svg>' },
{ key: "affected", label: "影响岗位", act: "evolution", sub: "近 2 季度发生变化的岗位", icon: '<svg viewBox="0 0 24 24"><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V5a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>' },
];
function sparkline(canvas, values, color) {
const ctx = canvas.getContext("2d");
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
if (!values.length) return;
const max = Math.max(1, ...values), min = Math.min(...values);
ctx.beginPath();
values.forEach((v, i) => {
const x = (i / Math.max(1, values.length - 1)) * (W - 4) + 2;
const y = H - 3 - ((v - min) / Math.max(1e-9, max - min)) * (H - 8);
ctx[i ? "lineTo" : "moveTo"](x, y);
});
ctx.strokeStyle = color; ctx.lineWidth = 1.6; ctx.stroke();
}
function quarterlyCounts(events) {
const kv = {};
events.forEach((e) => { kv[e.window] = (kv[e.window] || 0) + 1; });
return Object.keys(kv).sort().map((k) => [k, kv[k]]);
}
async function loadDashboard() {
const [health, graph, pending, audit] = await Promise.all([
api("/api/health"), api("/api/graph"),
@@ -327,48 +309,90 @@ async function loadDashboard() {
api("/api/audit?limit=8").catch(() => []),
]);
CN.graphData = graph;
CN._pendingCache = pending;
const s = health.graph || {};
updateBanners(health);
$("#data-version").textContent = `数据截止:${(graph.events.at(-1) || {}).window || "2026Q3"} · 模拟数据`;
const events = graph.events || [];
const lastWin = (events.at(-1) || {}).window || "2026Q3";
$("#data-version").textContent = `数据截止:${lastWin} · 模拟数据`;
/* 指标卡 */
const series = quarterlyCounts(graph.events || []);
const last12 = series.slice(-12).map(([, c]) => c);
const values = { jobs: s.jobs, skills: s.skills, edges: s.edges, events: s.events, pending: pending.length };
$("#metric-cards").innerHTML = METRIC_DEFS.map((m, i) => {
const cur = last12.at(-1) ?? 0, prev = last12.at(-2) ?? cur;
const delta = m.key === "events" || m.key === "pending" ? cur - prev : 0;
const cls = delta > 0 ? "up" : delta < 0 ? "down" : "flat";
const arrow = delta > 0 ? "↑" : delta < 0 ? "↓" : "—";
const deltaTxt = m.key === "events"
? `<span class="mc-delta ${cls}">${arrow} 环比上季 ${Math.abs(delta)}</span>`
: `<span class="mc-delta flat">实时统计</span>`;
return `<div class="metric-card" data-view="${m.view}">
/* 本期口径:最近 2 个季度的变化事件 */
const wins2 = [...new Set(events.map((e) => e.window))].sort().slice(-2);
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} 个岗位候选已具备入库条件`
: `本期确认 ${recent.length} 项关键变化(新增 ${cnt("新增")} · 增强 ${cnt("增强")} · 减弱 ${cnt("减弱")} · 退出 ${cnt("退出")});当前无待审核候选`;
$("#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,
};
$("#metric-cards").innerHTML = METRIC_DEFS.map((m) => `
<div class="metric-card" data-act="${m.act}">
<div class="mc-label">${m.icon}${m.label}</div>
<div class="mc-num">${values[m.key] ?? "—"}</div>${deltaTxt}
<canvas width="90" height="30" data-spark="${i}"></canvas></div>`;
}).join("");
<div class="mc-num">${values[m.key] ?? "—"}</div>
<span class="mc-delta flat">${m.sub}</span>
</div>`).join("");
$$("#metric-cards .metric-card").forEach((el) => {
el.onclick = () => switchView(el.dataset.view);
sparkline(el.querySelector("canvas"), last12.length ? last12 : [0, 0], "#2f54eb");
el.onclick = () => (el.dataset.act === "desk" ? openReviewDesk() : switchView("evolution"));
});
$("#pending-count").textContent = `${pending.length} 待办`;
/* 趋势图(近 12 季度事件数,柱状) */
CN._trend = series.slice(-12);
drawTrend($("#trend-canvas"), CN._trend);
/* 主图:近 8 季度变化事件流(全岗位口径堆叠柱,点击柱段跳演化页) */
CN._flowEvents = events;
drawEventFlow($("#trend-canvas"), events, 8);
/* 任务与审核状态表 */
renderTrust();
renderTaskTable(audit);
$("#btn-import-jd").onclick = () => openImportDialog();
/* 动态消息流 */
renderFeed(graph.events || [], audit);
/* 待审核候选 */
$("#btn-cmd-report").onclick = () => switchView("evolution");
$("#btn-open-review-desk").onclick = () => openReviewDesk();
$("#btn-open-review-desk-2").onclick = () => openReviewDesk();
$("#btn-audit-toggle").onclick = () => {
const wrap = $("#task-table-wrap");
wrap.classList.toggle("hidden");
$("#btn-audit-toggle").textContent = wrap.classList.contains("hidden") ? "展开台账" : "收起台账";
};
renderFeed(events);
renderPending(pending);
}
/* 数据可信度卡:有效 JD / 独立企业 / 来源类型 / 人工复核率 / 异常剔除率 */
async function renderTrust() {
const box = $("#trust-card");
try {
const t = await api("/api/stats/credibility");
const rows = [
["有效 JD", `${t.jd_valid} 条`, `原始 ${t.jd_total} 条,去重降噪后入图`],
["独立企业", `${t.companies} 家`, "跨企业交叉验证,防单一企业造词"],
["来源类型", `${t.sources} 类`, (t.source_names || []).join("、") || "公开招聘平台"],
["人工复核率", t.review_rate == null ? "—" : `${Math.round(t.review_rate * 100)}%`,
`审核留痕 ${t.reviewed} 条 · 待审 ${t.pending} 项`],
["异常剔除率", `${Math.round((t.excluded_rate || 0) * 100)}%`, `剔除噪声/重复 ${t.jd_total - t.jd_valid} 条`],
];
box.innerHTML = rows.map(([k, v, sub]) => `
<div class="trust-row"><span class="trust-k">${k}</span><b class="trust-v">${v}</b>
<span class="trust-sub">${esc(sub)}</span></div>`).join("");
} catch (e) {
box.innerHTML = `<div class="empty small">
<p>可信度统计暂时加载失败:${esc(friendlyErr(e))}</p>
<button class="btn btn-secondary btn-sm" id="trust-retry">重试</button></div>`;
$("#trust-retry").onclick = renderTrust;
}
}
/* JD 数据导入对话框:上传文件 / 粘贴文本 / 直接全量重建 */
async function openImportDialog() {
const choice = await sysDialog({
@@ -433,7 +457,7 @@ function renderTaskTable(audit) {
tbody.innerHTML = rows.map((r) => {
const isNode = r.kind === "node_update";
const obj = isNode ? r.node_id : (r.candidate_id || "").replace(/^cand_/, "");
const act = isNode ? "节点编辑" : { accept: "审核通过", reject: "审核驳回", modify: "审核修改" }[r.action] || r.action;
const act = isNode ? "节点编辑" : { accept: "审核通过", reject: "退回观察", modify: "审核修改" }[r.action] || r.action;
const badge = isNode ? "badge-version" : (r.action === "reject" ? "badge-warning" : "badge-success");
return `<tr><td><span class="badge ${badge}">${act}</span></td>
<td>${esc(obj)}</td><td>${esc(r.operator || "—")}</td>
@@ -441,64 +465,64 @@ function renderTaskTable(audit) {
}).join("");
}
function renderFeed(events, audit) {
const items = [];
events.slice(-8).reverse().forEach((e) => items.push({
icon: "#2f54eb", bg: "#f0f5ff", kind: "evo",
text: `<b>${esc(e.job_id.replace(/^job_/, ""))}</b> ${esc(e.change_type)}技能「${esc(e.skill)}」`,
time: e.window, job: e.job_id,
}));
(audit || []).slice(0, 4).forEach((r) => items.push({
icon: "#00b42a", bg: "#e8ffea", kind: "audit",
text: r.kind === "node_update"
? `<b>${esc(r.operator)}</b> 编辑了节点「${esc(r.node_id)}」`
: `<b>${esc(r.operator)}</b> ${{ accept: "通过", reject: "驳回", modify: "修改" }[r.action] || r.action}了候选「${esc((r.candidate_id || "").replace(/^cand_/, ""))}」`,
time: r.time || "",
}));
items.sort((a, b) => (b.time > a.time ? 1 : -1));
$("#activity-feed").innerHTML = items.slice(0, 10).map((it, i) => `
<div class="feed-item" data-i="${i}">
<span class="fi-icon" style="color:${it.icon};background:${it.bg}">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/></svg></span>
<div><div class="fi-text">${it.text}</div><div class="fi-time">${esc(it.time)}</div></div>
</div>`).join("") || `<div class="empty small">暂无动态</div>`;
/* 变化播报卡:技能图标 + 变化类型 + 影响岗位 + 证据数 + 跳转演化页(审核动作归入台账) */
function renderFeed(events) {
const verb = { "新增": "成为新要求", "增强": "需求显著增强", "减弱": "需求明显减弱", "退出": "退出能力画像" };
const items = (events || []).slice(-6).reverse();
const jobTitle = (id) => ((CN.jobs || []).find((j) => j.job_id === id) || {}).title || id.replace(/^job_/, "");
$("#activity-feed").innerHTML = items.length ? items.map((e, i) => {
const m = CHANGE_META[e.change_type] || { tag: "tag-gray", color: "#86909c" };
return `<div class="feed-item bcast" data-i="${i}">
<span class="fi-icon" style="color:${m.color};background:${m.color}1a">
<svg viewBox="0 0 24 24"><path d="M12 2l2.4 4.9 5.4.8-3.9 3.8.9 5.4-4.8-2.5-4.8 2.5.9-5.4L4.2 7.7l5.4-.8L12 2z"/></svg></span>
<div class="bcast-body">
<div class="fi-text"><b>${esc(e.skill)}</b> ${verb[e.change_type] || esc(e.change_type)}</div>
<div class="fi-time">影响岗位:${esc(jobTitle(e.job_id))} · 证据 ${(e.source_ids || []).length} 条 JD · ${esc(e.window)}</div>
</div>
<span class="tag ${m.tag}">${esc(e.change_type)}</span>
</div>`;
}).join("") : `<div class="empty small">暂无变化播报</div>`;
$$("#activity-feed .feed-item").forEach((el) => {
el.onclick = () => {
const it = items[+el.dataset.i];
if (it.kind === "evo" && it.job) {
switchView("evolution");
$("#evo-job-select").value = it.job;
loadEvolution();
}
const e = items[+el.dataset.i];
switchView("evolution");
$("#evo-job-select").value = e.job_id;
loadEvolution();
};
});
}
/* 待办行动卡(最多 3 张:结论 + 证据强度 + 处理按钮;完整台账见审核台/下方台账) */
function renderPending(pending) {
const box = $("#pending-list");
if (!pending.length) {
box.innerHTML = `<div class="empty small">暂无待审核候选(可在图谱页触发新岗位发现)</div>`;
return;
}
box.innerHTML = pending.map((c) => `
<div class="review-item">
<div class="ri-head"><b>${esc(c.title)}</b><span class="tag tag-orange">待审核</span>
<span class="text-weak text-aux">${esc(c.category || "")}</span></div>
<div class="ri-meta">支撑 ${c.source_count} 条 JD · ${c.company_count} 家企业 · 期望年限 ${c.expected_years} 年</div>
<div class="ri-skills">必备:${c.required_skills.map((s) => `<span class="tag tag-blue">${esc(s)}</span>`).join(" ")}
${c.preferred_skills.length ? `<br>加分:${c.preferred_skills.map((s) => `<span class="tag tag-gray">${esc(s)}</span>`).join(" ")}` : ""}</div>
box.innerHTML = pending.slice(0, 3).map((c) => {
const t = candidateTrust(c, CN.jobs);
const ready = ["高", "较高"].includes(t.level);
return `<div class="review-item action-card">
<div class="ri-head"><b>${esc(c.title)}</b>
<span class="tag ${ready ? "tag-green" : "tag-orange"}">${ready ? "建议批准入图" : "建议继续观察"}</span></div>
<div class="ri-meta">结论:四指标达标 ${t.okCount}/4(可信等级 ${t.level}) · 处理人:待分配</div>
<div class="ri-meta">证据强度:${c.source_count} 条 JD · ${c.company_count} 家企业 · ${(c.support_windows || []).length} 个季度跨度</div>
<div class="ri-actions">
<button class="btn btn-primary btn-sm" data-act="accept" data-id="${esc(c.job_id)}">通过入库</button>
<button class="btn btn-secondary btn-sm" data-act="reject" data-id="${esc(c.job_id)}">驳回</button>
<button class="btn btn-text btn-sm" data-locate="${esc(c.job_id)}">在图谱中查看</button>
<button class="btn btn-primary btn-sm" data-act="accept" data-id="${esc(c.job_id)}">批准入图</button>
<button class="btn btn-secondary btn-sm" data-act="reject" data-id="${esc(c.job_id)}">退回观察</button>
<button class="btn btn-text btn-sm" data-desk="${esc(c.job_id)}">审核台查看</button>
</div>
</div>`).join("");
</div>`;
}).join("") + (pending.length > 3
? `<button class="btn btn-text btn-sm" id="pending-more">查看全部 ${pending.length} 个候选 →</button>` : "");
box.querySelectorAll("[data-act]").forEach((b) => {
b.onclick = () => doReview(b.dataset.id, b.dataset.act);
});
box.querySelectorAll("[data-locate]").forEach((b) => {
b.onclick = () => { switchView("graph"); if (CN.gotoNode) CN.gotoNode(b.dataset.locate); };
box.querySelectorAll("[data-desk]").forEach((b) => {
b.onclick = () => openReviewDesk(b.dataset.desk);
});
const more = $("#pending-more");
if (more) more.onclick = () => openReviewDesk();
}
async function doReview(id, action) {
@@ -506,18 +530,139 @@ async function doReview(id, action) {
if (operator === null) return;
let reason = "webui 审核通过";
if (action === "reject") {
reason = await CN.sysPrompt({ title: "驳回候选", label: "请输入驳回原因:", value: "证据不足" });
reason = await CN.sysPrompt({ title: "退回继续观察", label: "请输入退回原因(候选保留观察,暂不入库):", value: "证据不足,继续观察" });
if (reason === null) return;
}
try {
await post("/api/review", { candidate_id: id, action, operator: operator || "评审专家", reason });
toast(`审核完成:${action === "accept" ? "已通过入库" : "已驳回"}`, "success");
toast(action === "accept" ? "已批准入图:图谱已实时更新" : "已退回继续观察", "success");
loadDashboard();
if (!$("#review-desk-mask").classList.contains("hidden")) renderReviewDesk();
if (CN.graph && CN.graph.reload) CN.graph.reload();
} catch (e) { toast(e.message, "error"); }
}
CN.doReview = doReview;
/* ================= 候选岗位审核台(显性二级界面:发现 → 证据 → 人工决策 → 自动入图) ================= */
/* 候选可信等级四指标:支撑 JD 数 ≥15 / 独立企业数 ≥5 / 增长趋势(支撑窗口后半段不弱于前半段)/ 与既有岗位差异度 ≥0.40 */
function candidateTrust(c, jobs) {
const jdOk = (c.source_count || 0) >= 15;
const compOk = (c.company_count || 0) >= 5;
const wins = (c.support_windows || []).slice().sort();
const half = Math.max(1, Math.floor(wins.length / 2));
const before = wins.slice(0, wins.length - half).length;
const recent = wins.slice(-half).length;
const growthOk = before === 0 ? wins.length >= 2 : recent / before >= 1.0;
const skills = new Set([...(c.required_skills || []), ...(c.preferred_skills || [])].map((s) => s.toLowerCase()));
let best = { sim: 0, title: "" };
(jobs || []).forEach((j) => {
const js = new Set([...(j.required_skills || []), ...(j.preferred_skills || [])].map((s) => s.toLowerCase()));
if (!skills.size || !js.size) return;
const inter = [...skills].filter((s) => js.has(s)).length;
const sim = inter / (skills.size + js.size - inter);
if (sim > best.sim) best = { sim, title: j.title };
});
const distinct = 1 - best.sim;
const dots = [
{ label: "支撑 JD", ok: jdOk, value: `${c.source_count || 0} 条(≥15 达标)` },
{ label: "独立企业", ok: compOk, value: `${c.company_count || 0} 家(≥5 达标)` },
{ label: "增长趋势", ok: growthOk, value: `支撑窗口覆盖 ${wins.length} 个季度` },
{ label: "差异度", ok: distinct >= 0.4, value: `${distinct.toFixed(2)}(≥0.40 达标)` },
];
const okCount = dots.filter((d) => d.ok).length;
const level = okCount >= 4 ? "高" : okCount === 3 ? "较高" : okCount === 2 ? "中" : "低";
return { dots, level, okCount, distinctness: distinct, nearest: best };
}
function openReviewDesk(focusId) {
renderReviewDesk(focusId);
$("#review-desk-mask").classList.remove("hidden");
}
CN.openReviewDesk = openReviewDesk;
$("#review-desk-close").onclick = () => $("#review-desk-mask").classList.add("hidden");
$("#review-desk-mask").addEventListener("click", (e) => {
if (e.target.id === "review-desk-mask") $("#review-desk-mask").classList.add("hidden");
});
async function renderReviewDesk(focusId) {
const box = $("#review-desk-body");
if (!box) return;
let pending = CN._pendingCache;
try {
pending = await api("/api/review/pending");
CN._pendingCache = pending;
} catch (e) {
box.innerHTML = `<div class="empty small">
<p>候选数据暂时加载失败:${esc(friendlyErr(e))}</p>
<button class="btn btn-secondary btn-sm" id="desk-retry">重试</button></div>`;
$("#desk-retry").onclick = () => renderReviewDesk();
return;
}
if (!pending.length) {
box.innerHTML = `<div class="empty small">暂无待审核候选(可在图谱页触发新岗位发现)</div>`;
return;
}
const levelTag = { "高": "tag-green", "较高": "tag-cyan", "中": "tag-orange", "低": "tag-red" };
box.innerHTML = pending.map((c) => {
const t = candidateTrust(c, CN.jobs);
const wins = (c.support_windows || []).slice().sort();
const span = wins.length ? `${wins[0]} ~ ${wins[wins.length - 1]}` : "—";
return `<div class="cand-card" data-cand="${esc(c.job_id)}">
<div class="cc-head"><b>候选:${esc(c.title)}</b>
<span class="tag ${levelTag[t.level] || "tag-gray"}">可信等级:${t.level}</span></div>
<div class="cc-def">职责摘要:${esc((c.responsibilities || [])[0] || `负责${c.title}相关的方案设计、开发与交付(职责描述待人工补充)`)}</div>
<div class="cc-dots">${t.dots.map((d) =>
`<span class="cred-dot ${d.ok ? "on" : ""}" title="${esc(d.label)}:${esc(d.value)}${d.ok ? "(达标)" : "(未达标)"}"></span>`).join("")}
<span class="cc-dots-label">${t.okCount}/4 指标达标(JD / 企业 / 增长 / 差异)</span></div>
<div class="cc-meta">多源证据:${c.source_count} JD · ${c.company_count} 企业 · 时间跨度 ${esc(span)}</div>
<div class="cc-diff">与相近岗位:${t.nearest.title
? `「${esc(t.nearest.title)}」技能重合 ${(t.nearest.sim * 100).toFixed(0)}%,职责差异度 ${t.distinctness.toFixed(2)}`
: "暂无可比较岗位"}</div>
<div class="cc-skills">必备:${(c.required_skills || []).slice(0, 6).map((s) => `<span class="tag tag-blue">${esc(s)}</span>`).join(" ") || "—"}
${(c.preferred_skills || []).length ? `<br>加分:${c.preferred_skills.slice(0, 4).map((s) => `<span class="tag tag-gray">${esc(s)}</span>`).join(" ")}` : ""}</div>
<div class="cc-actions">
<button class="btn btn-secondary btn-sm" data-ev="${esc(c.job_id)}">查看证据原文</button>
<button class="btn btn-secondary btn-sm" data-cmp="${esc(c.job_id)}">相近岗位比较</button>
<button class="btn btn-primary btn-sm" data-act="accept" data-id="${esc(c.job_id)}">人工批准入图</button>
<button class="btn btn-danger btn-sm" data-act="reject" data-id="${esc(c.job_id)}">退回继续观察</button>
</div>
</div>`;
}).join("");
box.querySelectorAll("[data-act]").forEach((b) => {
b.onclick = () => doReview(b.dataset.id, b.dataset.act);
});
box.querySelectorAll("[data-ev]").forEach((b) => {
b.onclick = () => {
const c = pending.find((x) => x.job_id === b.dataset.ev);
showJdEvidence((c && c.support_source_ids) || [], { jobTitle: (c || {}).title || b.dataset.ev });
};
});
box.querySelectorAll("[data-cmp]").forEach((b) => {
b.onclick = () => {
const c = pending.find((x) => x.job_id === b.dataset.cmp);
const t = candidateTrust(c, CN.jobs);
const near = (CN.jobs || []).find((j) => j.title === t.nearest.title);
if (!near) return toast("暂无可比较的相近岗位", "info");
const chips = (list, cls) => (list || []).map((s) => `<span class="tag ${cls}">${esc(s)}</span>`).join(" ") || "—";
openModal(`相近岗位比较:${esc(c.title)} vs ${esc(near.title)}`, `
<div class="cmp-meta">技能重合 ${(t.nearest.sim * 100).toFixed(0)}% · 职责差异度 ${t.distinctness.toFixed(2)}(≥0.40 视为新岗位)</div>
<div class="diff-grid">
<div class="diff-col"><div class="diff-title">候选「${esc(c.title)}」必备</div>
<div class="diff-chips">${chips(c.required_skills, "tag-blue")}</div></div>
<div class="diff-col"><div class="diff-title">「${esc(near.title)}」必备</div>
<div class="diff-chips">${chips(near.required_skills, "tag-green")}</div></div>
<div class="diff-col"><div class="diff-title">候选独有技能</div>
<div class="diff-chips">${chips((c.required_skills || []).filter((s) => !(near.required_skills || []).includes(s)), "tag-orange")}</div></div>
</div>`);
};
});
if (focusId) {
const el = box.querySelector(`[data-cand="${focusId}"]`);
if (el) { el.scrollIntoView({ block: "center" }); el.classList.add("flash"); }
}
}
/* 画布自适应:占满父容器(宽 100%、高 100%)+ dpr 高清,返回 CSS 像素坐标系下的 ctx/W/H */
function fitCanvas(canvas, cssH) {
const dpr = window.devicePixelRatio || 1;
@@ -532,36 +677,67 @@ function fitCanvas(canvas, cssH) {
return { ctx, W, H };
}
function drawTrend(canvas, kv) {
/* 变化事件流:近 N 季度全岗位口径 新增/增强/减弱/退出 堆叠柱,点击柱段跳演化页对应岗位 */
function drawEventFlow(canvas, events, n) {
const { ctx, W, H } = fitCanvas(canvas, 220);
ctx.clearRect(0, 0, W, H);
if (!kv.length) return;
const max = Math.max(1, ...kv.map(([, c]) => c));
const bw = W / kv.length;
ctx.font = "11px sans-serif"; ctx.textAlign = "center";
kv.forEach(([k, c], i) => {
const h = (c / max) * (H - 46);
const x = i * bw + bw * 0.18, y = H - h - 24;
const grad = ctx.createLinearGradient(0, y, 0, H - 24);
grad.addColorStop(0, "#2f54eb"); grad.addColorStop(1, "#85a5ff");
ctx.fillStyle = grad;
ctx.beginPath();
ctx.roundRect(x, y, bw * 0.64, h, [3, 3, 0, 0]);
ctx.fill();
ctx.fillStyle = "#86909c";
ctx.fillText(k.slice(2), i * bw + bw / 2, H - 8);
ctx.fillStyle = "#4e5969";
ctx.fillText(c, i * bw + bw / 2, y - 5);
$("#cmd-stack-legend").innerHTML = Object.entries(CHANGE_META).map(([t, m]) =>
`<span><i style="background:${m.color}"></i>${t}</span>`).join("");
const windows = [...new Set(events.map((e) => e.window))].sort().slice(-n);
if (!windows.length) return;
const types = Object.keys(CHANGE_META);
const counts = windows.map((w) => {
const row = {};
types.forEach((t) => { row[t] = events.filter((e) => e.window === w && e.change_type === t).length; });
return row;
});
const maxTotal = Math.max(1, ...counts.map((r) => types.reduce((a, t) => a + r[t], 0)));
const bw = W / windows.length;
const baseY = H - 22;
ctx.font = "10px sans-serif"; ctx.textAlign = "center";
windows.forEach((w, i) => {
let y = baseY;
types.forEach((t) => {
const v = counts[i][t];
if (!v) return;
const h = (v / maxTotal) * (H - 52);
ctx.fillStyle = CHANGE_META[t].color;
ctx.fillRect(i * bw + bw * 0.2, y - h, bw * 0.6, h);
y -= h;
});
const total = types.reduce((a, t) => a + counts[i][t], 0);
if (total) { ctx.fillStyle = "#4e5969"; ctx.fillText(total, i * bw + bw / 2, y - 4); }
ctx.fillStyle = "#86909c";
ctx.fillText(w.slice(2), i * bw + bw / 2, H - 6);
});
canvas._bars = windows.map((w, i) => ({ w, x0: i * bw, x1: (i + 1) * bw }));
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 byJob = {};
events.filter((e) => e.window === bar.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");
};
}
/* ================= 岗位演化 ================= */
/* 变化类型视觉语义:新增=青绿 / 增强=蓝紫 / 减弱=琥珀 / 退出=砖红(持续=中性蓝灰) */
const CHANGE_META = {
"新增": { tag: "tag-green", color: "#00b42a" },
"增强": { tag: "tag-blue", color: "#2f54eb" },
"新增": { tag: "tag-cyan", color: "#13c2c2" },
"增强": { tag: "tag-purple", color: "#722ed1" },
"减弱": { tag: "tag-orange", color: "#ff7d00" },
"退出": { tag: "tag-red", color: "#f53f3f" },
};
const STEADY_COLOR = "#86909c"; // 持续:中性蓝灰
let evoEvents = [];
/* 全部岗位 × 季度 演化热力图 */
@@ -573,7 +749,7 @@ function renderEvoHeatmap() {
box.innerHTML = `<div class="empty small">暂无演化事件数据</div>`;
return;
}
const windows = [...new Set(events.map((e) => e.window))].sort();
const windows = [...new Set(events.map((e) => e.window))].sort().slice(-12); // 全岗位扫描仅近 12 季度
// 统计每个岗位每季度事件数与类型分布
const cell = {};
events.forEach((e) => {
@@ -614,11 +790,26 @@ function renderEvoHeatmap() {
box.querySelectorAll(".hm-row-label").forEach((el) => {
el.onclick = () => {
$("#evo-job-select").value = el.dataset.job;
loadEvolution().then(renderEvoHeatmap);
/* 点击行 → 转入该岗位报告(默认 Tab) */
$$("#evo-tabs .match-tab").forEach((x) => x.classList.toggle("active", x.dataset.evotab === "report"));
$("#evo-tab-report").classList.remove("hidden");
$("#evo-tab-scan").classList.add("hidden");
loadEvolution();
};
});
}
/* 演化页二级 Tab:岗位报告(默认) / 全岗位扫描(热力图) */
$$("#evo-tabs .match-tab").forEach((b) => {
b.onclick = () => {
$$("#evo-tabs .match-tab").forEach((x) => x.classList.toggle("active", x === b));
const tab = b.dataset.evotab;
$("#evo-tab-report").classList.toggle("hidden", tab !== "report");
$("#evo-tab-scan").classList.toggle("hidden", tab !== "scan");
if (tab === "scan") renderEvoHeatmap();
};
});
function bucketWindow(w, gran) {
if (gran === "year") return w.slice(0, 4);
if (gran === "month") { /* 季度窗口展开为季中月 */ return `${w.slice(0, 4)}-${String((+w.slice(5) - 1) * 3 + 2).padStart(2, "0")}`; }
@@ -766,6 +957,7 @@ function renderEvolution() {
const gran = $$("#evo-gran button").find((b) => b.classList.contains("active"))?.dataset.gran || "quarter";
drawStackBars($("#evo-stack-canvas"), evoEvents);
renderEvoGantt($("#evo-job-select").value);
renderEvoReplace($("#evo-job-select").value);
/* 热度走势:每季度实际生效的技能关系数(按 REQUIRES 边的生效区间统计) */
const jobEdges = ((CN.graphData || {}).edges || [])
.filter((e) => e.source === $("#evo-job-select").value && e.rel === "REQUIRES" && (e.intervals || []).length);
@@ -876,57 +1068,101 @@ function drawStackBars(canvas, events) {
});
}
/* 技能生命周期甘特图 */
/* 能力生命线(Swimlane):每个核心技能一行(近 8 季度),线段=生效区间,圆点=变更事件。
* 行状态取该技能最近一次事件:新增=青绿 / 增强=蓝紫 / 减弱=琥珀 / 退出=砖红 / 持续=蓝灰 */
function renderEvoGantt(jobId) {
const box = $("#evo-gantt");
const g = CN.graphData || {};
const allEvents = g.events || [];
const windows = [...new Set(allEvents.map((e) => e.window))].sort();
const windows = [...new Set(allEvents.map((e) => e.window))].sort().slice(-8);
const edges = (g.edges || []).filter((e) => e.source === jobId && e.rel === "REQUIRES" && (e.intervals || []).length);
if (!windows.length || !edges.length) {
box.innerHTML = `<div class="empty small">暂无该岗位的技能生命周期数据</div>`;
box.innerHTML = `<div class="empty small">暂无该岗位的能力生命线数据</div>`;
return;
}
// 排序:当前仍生效的在前(按起始窗口升序),已退出的在后;限 14 行
/* Top 12:当前仍生效优先,按当前需求指数降序 */
const alive = (e) => e.intervals.some(([s, en]) => en === null);
edges.sort((a, b) => {
const d = (alive(b) ? 1 : 0) - (alive(a) ? 1 : 0);
if (d) return d;
return (a.intervals[0][0] < b.intervals[0][0] ? -1 : 1);
});
const rows = edges.slice(0, 14);
const labelEvery = Math.ceil(windows.length / 12);
// 事件 → 技能 × 窗口 的圆点
const rows = edges.slice().sort((a, b) =>
((alive(b) ? 1 : 0) - (alive(a) ? 1 : 0)) || ((b.weight || 0) - (a.weight || 0))).slice(0, 12);
const dots = {};
evoEvents.forEach((e) => { dots[`${e.skill}|${e.window}`] = e.change_type; });
const cellHtml = (edge, w) => {
const lastWin = windows[windows.length - 1];
const recentWins = windows.slice(-4);
const rowMeta = (edge) => {
const activeNow = edge.intervals.some(([s, en]) => s <= lastWin && (en === null || lastWin < en));
if (!activeNow) return { label: "退出", color: CHANGE_META["退出"].color };
const lastEv = evoEvents.filter((e) => e.skill === edge.target && recentWins.includes(e.window)).at(-1);
if (lastEv) return { label: lastEv.change_type, color: (CHANGE_META[lastEv.change_type] || {}).color || STEADY_COLOR };
return { label: edge.importance === "required" ? "核心" : "加分", color: STEADY_COLOR };
};
const cellHtml = (edge, w, color) => {
const active = edge.intervals.some(([s, en]) => s <= w && (en === null || w < en));
const dotType = dots[`${edge.target}|${w}`];
const barColor = edge.importance === "required" ? "var(--primary)" : "var(--cyan)";
const dot = dotType
? `<i class="gt-dot" style="background:${(CHANGE_META[dotType] || {}).color || "#999"}" title="${w} ${dotType}"></i>` : "";
return `<div class="gt-cell" title="${edge.target} · ${w}${active ? "(生效中)" : ""}">
${active ? `<div class="gt-bar" style="background:${barColor}"></div>` : ""}${dot}</div>`;
? `<i class="sl-dot" style="background:${(CHANGE_META[dotType] || {}).color || "#999"}" title="${w} ${dotType}"></i>` : "";
return `<div class="sl-cell" title="${esc(edge.target)} · ${esc(w)}${active ? "(生效中)" : ""}">
${active ? `<div class="sl-seg" style="background:${color}"></div>` : ""}${dot}</div>`;
};
box.innerHTML = `
<div class="gt-grid" style="grid-template-columns: 110px repeat(${windows.length}, 1fr)">
<div class="sl-grid" style="grid-template-columns: 128px repeat(${windows.length}, 1fr) 56px">
<div class="hm-corner"></div>
${windows.map((w, i) => `<div class="hm-col-label">${i % labelEvery === 0 ? esc(w.slice(2)) : ""}</div>`).join("")}
${rows.map((e) => `
<div class="gt-label" data-skill="${esc(e.target)}" title="点击跳转图谱节点">${esc(e.target)}</div>
${windows.map((w) => cellHtml(e, w)).join("")}`).join("")}
${windows.map((w) => `<div class="hm-col-label">${esc(w.slice(2))}</div>`).join("")}
<div class="hm-col-label">状态</div>
${rows.map((e) => {
const m = rowMeta(e);
return `<div class="gt-label" data-skill="${esc(e.target)}" title="点击跳转图谱节点">${esc(e.target)}</div>
${windows.map((w) => cellHtml(e, w, m.color)).join("")}
<div class="sl-status" style="color:${m.color}">${m.label}</div>`;
}).join("")}
</div>
<div class="hm-legend">
<span>生效区间:</span><i class="hm-l" style="background:var(--primary)"></i>必备
<i class="hm-l" style="background:var(--cyan)"></i>加分
<span style="margin-left:10px">圆点:</span>
${Object.entries(CHANGE_META).map(([t, m]) => `<i class="gt-dot" style="background:${m.color};position:static"></i>${t}`).join("")}
<span>行状态:</span>
${Object.entries(CHANGE_META).map(([t, m]) => `<i class="hm-l" style="background:${m.color}"></i>${t}`).join("")}
<i class="hm-l" style="background:${STEADY_COLOR}"></i>持续
<span class="text-weak">(圆点 = 该季度变更事件,点击技能名跳转图谱)</span>
</div>`;
box.querySelectorAll(".gt-label").forEach((el) => {
el.onclick = () => { switchView("graph"); if (CN.gotoNode) CN.gotoNode(el.dataset.skill); };
});
}
/* 能力更替流:旧核心(已退出必备) → 持续能力 → 新核心(近 4 季度新进入) */
function renderEvoReplace(jobId) {
const box = $("#evo-replace");
const g = CN.graphData || {};
const wins = [...new Set((g.events || []).map((e) => e.window))].sort();
const recent4 = new Set(wins.slice(-4));
const edges = (g.edges || []).filter((e) => e.source === jobId && e.rel === "REQUIRES" && (e.intervals || []).length);
if (!edges.length) {
box.innerHTML = `<div class="empty small">暂无能力更替数据</div>`;
return;
}
const alive = (e) => e.intervals.some(([s, en]) => en === null);
const start = (e) => e.intervals.map((i) => i[0]).sort()[0];
const oldCore = edges.filter((e) => !alive(e));
const cont = edges.filter((e) => alive(e) && !recent4.has(start(e)));
const newCore = edges.filter((e) => alive(e) && recent4.has(start(e)));
const chip = (e) =>
`<span class="tag ${alive(e) ? (e.importance === "required" ? "tag-blue" : "tag-cyan") : "tag-red"} rp-chip" data-skill="${esc(e.target)}">${esc(e.target)}</span>`;
const col = (title, list, cls, note) => `
<div class="rp-col ${cls}">
<div class="rp-title">${title}(${list.length})</div>
<div class="rp-note">${note}</div>
<div class="rp-chips">${list.slice(0, 10).map(chip).join("") || '<span class="text-weak text-aux">无</span>'}</div>
</div>`;
box.innerHTML = `
<div class="rp-grid">
${col("旧核心(已退出)", oldCore, "old", "曾为核心/加分,当前已退出画像")}
<div class="rp-arrow">→</div>
${col("持续能力", cont, "keep", "跨周期稳定生效,构成岗位基本盘")}
<div class="rp-arrow">→</div>
${col("新核心(近 4 季度进入)", newCore, "new", "本期新增或升级的必备/加分能力")}
</div>`;
box.querySelectorAll(".rp-chip").forEach((el) => {
el.onclick = () => { switchView("graph"); if (CN.gotoNode) CN.gotoNode(el.dataset.skill); };
});
}
/* ================= 人岗匹配(三步向导) ================= */
const matchState = { step: 1, skills: [], years: 0, result: null, profileText: "" };
let lastProfile = null; // 最近一次解析的完整简历画像(结构化模板与导出用)
@@ -1062,6 +1298,12 @@ $("#btn-run-match").onclick = async () => {
flushSkillInput(); // 输入框里未回车的技能先入库,避免丢失
const jobId = $("#match-job-select").value;
matchState.years = +$("#parse-years").value || 0;
const btn = $("#btn-run-match");
btn.disabled = true;
const embedReady = lastHealth && lastHealth.embed && lastHealth.embed.ready;
btn.textContent = embedReady
? "诊断中…(首次语义召回需加载模型,约 10 秒)"
: "诊断中…";
try {
const m = await post("/api/match", {
job_id: jobId, skills: matchState.skills, years: matchState.years,
@@ -1071,6 +1313,7 @@ $("#btn-run-match").onclick = async () => {
renderMatchResult(m);
setMatchStep(3);
} catch (e) { toast(e.message, "error"); }
finally { btn.disabled = false; btn.textContent = "开始诊断"; }
};
$("#btn-back-2").onclick = () => setMatchStep(1);
$("#btn-goto-path").onclick = () => {
@@ -1484,7 +1727,12 @@ $("#path-weeks").querySelectorAll(".seg-item").forEach((b) => {
pathState.weeks = parseInt(b.dataset.weeks, 10);
};
});
$("#path-job-select").addEventListener("change", () => loadPathJob($("#path-job-select").value));
$("#path-job-select").addEventListener("change", () => {
const id = $("#path-job-select").value;
loadPathJob(id).then(() => {
if (lastPathData && lastPathData._demo) { lastPathData = null; renderDemoPath(); } // 示范路线跟随岗位切换
});
});
$("#btn-path").onclick = async () => {
const jobId = $("#path-job-select").value;
@@ -1511,23 +1759,43 @@ function stageWeeks(n) {
return Array.from({ length: n }, () => base);
}
/* 阶段预计投入文案:按每周约 8 小时有效学习估算 */
function stageEffortText(w) {
return `${w} 周 / 约 ${w * 8} 小时`;
}
function updatePathOverview() {
if (!lastPathData || !lastPathData.learning_path.length) return;
const n = lastPathData.learning_path.length;
const gaps = lastPathData.learning_path.reduce((a, st) => a + st.skills.length, 0);
const done = pathState.done.size;
const pct = n ? Math.round((done / n) * 100) : 0;
/* 当前匹配分来自匹配页诊断结果(同岗位才展示);目标分 = 匹配阈值 60 */
const cur = (matchState.result && matchState.result.job_id === pathState.jobId)
? `${Math.round(matchState.result.total_score)} 分` : "未诊断";
$("#path-overview").innerHTML = `
${lastPathData._demo ? `<div class="po-demo">示范路线 · 完成人岗诊断后将替换为你的个人结果</div>` : ""}
<div class="po-item">目标岗位<b>${esc(lastPathData.title)}</b></div>
<div class="po-item">待补技能<b class="po-primary">${gaps} 项</b></div>
<div class="po-item">阶段<b>${n} 个</b></div>
<div class="po-item">当前匹配分<b class="po-primary">${cur}</b></div>
<div class="po-item">目标分<b>60 分</b></div>
<div class="po-item">预计周期<b>${pathState.weeks} 周</b></div>
<div class="po-progress">
<div class="progress"><div class="progress-bar" style="width:${pct}%"></div></div>
<div class="path-summary-tip">学习记录:已完成 ${done}/${n} 阶段(${pct}%)</div>
<div class="path-summary-tip">学习记录:已完成 ${done}/${n} 阶段(${pct}%)· 完成阶段后可重新匹配验证</div>
</div>`;
}
/* 未诊断时展示示范路线(当前选中岗,零已掌握),完成诊断后由个人结果替换 */
async function renderDemoPath() {
if (lastPathData && lastPathData.learning_path && lastPathData.learning_path.length) return;
const jobId = $("#path-job-select").value || "job_Java开发工程师";
try {
const data = await post("/api/learning-path", { job_id: jobId, known_skills: [] });
data._demo = true;
lastPathData = data;
renderPath(data);
} catch { /* 静默:保留空状态引导 */ }
}
function renderPath(data) {
const box = $("#path-result");
$("#btn-export-path").classList.toggle("hidden", !data.learning_path.length);
@@ -1540,7 +1808,11 @@ function renderPath(data) {
/* 阶段语义化命名:基础补齐 → 岗位核心 → 项目验证(超出 3 阶段顺延为综合提升) */
const STAGE_NAMES = ["基础补齐", "岗位核心", "项目验证"];
const stageName = (i) => STAGE_NAMES[i] || "综合提升";
box.innerHTML = data.learning_path.map((st, i) => {
box.innerHTML = (data._demo ? `
<div class="path-demo-banner">
<b>示范路线</b>(以零已掌握技能生成):完成一次人岗诊断后,此处将自动替换为你的个人成长路线图
<button class="btn btn-secondary btn-sm" id="path-goto-match-2">去完成诊断</button>
</div>` : "") + data.learning_path.map((st, i) => {
const done = pathState.done.has(st.stage);
return `
<div class="stage-card${done ? " done" : ""}" data-stage="${st.stage}">
@@ -1554,6 +1826,7 @@ function renderPath(data) {
<div class="stage-skills">${st.skills.map((s) =>
`<span class="tag tag-blue" data-skill="${esc(s)}" title="点击跳转图谱节点">${esc(s)}</span>`).join("")}</div>
<div class="stage-meta">
<span>预计投入:<b>${stageEffortText(weeks[i])}</b></span>
<span>资源类型:<b>${RESOURCE_TYPES[i % RESOURCE_TYPES.length]}</b></span>
<span>技能性质:<b>必备补齐</b></span>
${st.acceptance ? `<span>能力类别:<b>${esc(st.acceptance.category)}</b></span>` : ""}
@@ -1585,11 +1858,27 @@ function renderPath(data) {
box.querySelectorAll(".stage-check").forEach((el) => {
el.onclick = () => {
const st = parseInt(el.dataset.stage, 10);
if (pathState.done.has(st)) pathState.done.delete(st); else pathState.done.add(st);
const stage = (lastPathData.learning_path || []).find((x) => x.stage === st);
if (pathState.done.has(st)) {
pathState.done.delete(st);
} else {
pathState.done.add(st);
/* 完成阶段 → 左栏「已掌握」区同步点亮该阶段技能,并提示重新匹配验证 */
if (stage && !lastPathData._demo) {
stage.skills.forEach((s) => pathState.known.add(s));
renderSkillPool();
updatePathSummary();
toast("该阶段技能已同步为「已掌握」,可回到人岗匹配重新诊断验证", "success");
} else if (stage) {
toast("示范路线:完成诊断后,阶段完成状态将同步到个人技能画像", "info");
}
}
localStorage.setItem(pathDoneKey(pathState.jobId), JSON.stringify([...pathState.done]));
renderPath(lastPathData);
};
});
const gotoMatch2 = $("#path-goto-match-2");
if (gotoMatch2) gotoMatch2.onclick = () => switchView("match");
updatePathOverview();
}
@@ -2008,7 +2297,7 @@ $("#btn-config").onclick = () => openConfigModal(false);
/* ================= 启动 ================= */
window.addEventListener("resize", () => {
if (CN._trend) drawTrend($("#trend-canvas"), CN._trend);
if (CN._flowEvents) drawEventFlow($("#trend-canvas"), CN._flowEvents, 8);
drawHeat();
});
(async function boot() {
+201 -29
View File
@@ -26,6 +26,67 @@
const NOTES_KEY = "cn_notes";
const VIEW_KEY = "cn_graph_view";
const DEFAULT_SOLO_JOB = "job_Java开发工程师"; // 首屏默认岗位子图(局部可读)
/* 变化类型颜色(与 app.js CHANGE_META 一致):新增=青绿 / 增强=蓝紫 / 减弱=琥珀 / 退出=砖红 */
const CHANGE_COLORS = { "新增": "#13c2c2", "增强": "#722ed1", "减弱": "#ff7d00", "退出": "#f53f3f" };
const CHANGE_MARK = { "新增": "新", "增强": "↑", "减弱": "↓", "退出": "退" };
/* 变化角标数据:最近 2 季度有事件的技能(子图模式下按当前岗位过滤) */
function updateRecentChange() {
const wins = [...new Set(G.events.map((e) => e.window))].sort().slice(-2);
const m = new Map();
G.events.forEach((e) => {
if (!wins.includes(e.window)) return;
if (G.soloJob && e.job_id !== G.soloJob) return;
m.set(e.skill, e); // 事件按窗口升序,后者覆盖前者 = 取最近事件
});
G.recentChange = m;
}
/* 同心分层布局(岗位子图):中心岗位 → 第一圈必备技能 → 第二圈加分技能 → 外圈场景/行业 */
function applySoloLayout() {
const job = G.nodes[G.idx[G.soloJob]];
if (!job) return;
const rings = [[], [], []];
G.edges.forEach((e) => {
if (e.source !== G.soloJob) return;
const t = G.nodes[G.idx[e.target]];
if (!t) return;
if (e.rel === "REQUIRES") rings[e.importance === "required" ? 0 : 1].push(t);
else rings[2].push(t); // APPLIES_TO / BELONGS_TO → 外圈
});
const base = [150, 260, 370];
let prevR = 0;
rings.forEach((nodes, ri) => {
nodes.sort((a, b) => String(a.title || a.id).localeCompare(String(b.title || b.id), "zh"));
const R = Math.max(base[ri], prevR + 90, (nodes.length * 34) / (2 * Math.PI));
prevR = R;
nodes.forEach((n, i) => {
const a = (i / Math.max(1, nodes.length)) * Math.PI * 2 - Math.PI / 2;
n.x = job.x + Math.cos(a) * R;
n.y = job.y + Math.sin(a) * R;
n.vx = 0; n.vy = 0;
});
});
}
/* 视野适配子图(只按子图节点的包围盒,而非全图) */
function fitSoloView() {
const c = canvas();
const ids = new Set([G.soloJob, ...neighborsOf(G.soloJob)]);
let minX = 1e9, minY = 1e9, maxX = -1e9, maxY = -1e9;
G.nodes.forEach((n) => {
if (!ids.has(n.id) || !visible(n)) return;
minX = Math.min(minX, n.x); maxX = Math.max(maxX, n.x);
minY = Math.min(minY, n.y); maxY = Math.max(maxY, n.y);
});
if (minX > maxX) return fitView();
const w = maxX - minX || 1, h = maxY - minY || 1;
const W = c.clientWidth, H = c.clientHeight;
G.cam.k = Math.max(0.2, Math.min(W / (w + 160), H / (h + 160), 1.6));
G.cam.x = (W - w * G.cam.k) / 2 - minX * G.cam.k;
G.cam.y = (H - h * G.cam.k) / 2 - minY * G.cam.k;
requestDraw(); drawMinimap();
}
/* ================= 数据装载 ================= */
async function load() {
@@ -62,14 +123,17 @@
buildFilterOptions();
layout();
restoreView() || fitView();
/* 默认进入「Java开发工程师」岗位子图:首屏局部可读,顶栏徽标可退出全图(每次装载仅应用一次) */
/* 默认进入「Java开发工程师」岗位子图:同心分层布局(中心岗位 → 必备 → 加分 → 场景/行业),
* 退出子图即全量「岗位生态总览」(每次装载仅应用一次) */
if (!G._defaultApplied && !G.pendingLocate && G.idx[DEFAULT_SOLO_JOB]) {
G._defaultApplied = true;
G.soloJob = DEFAULT_SOLO_JOB;
updateSoloBadge();
applySoloLayout();
selectNode(DEFAULT_SOLO_JOB, false);
centerOn(G.nodes[G.idx[DEFAULT_SOLO_JOB]], Math.max(G.cam.k, 1.05));
fitSoloView();
}
updateRecentChange();
renderSuggest();
renderChain(G.soloJob || null);
$("#graph-loading").classList.add("hidden");
@@ -311,8 +375,39 @@
ctx.fillStyle = n.type === "Job" ? "#1d2129" : "#4e5969";
ctx.fillText(label, p.x, p.y - r - 7);
}
/* 变化角标:最近 2 季度有事件的技能节点右上角显示 新/↑/↓/退 */
const ch = n.type === "Skill" && G.recentChange ? G.recentChange.get(n.id) : null;
if (ch && !dim) {
const mark = CHANGE_MARK[ch.change_type] || "";
const bc = CHANGE_COLORS[ch.change_type] || "#86909c";
const bx = p.x + r * 0.95, by = p.y - r * 0.95;
ctx.fillStyle = bc;
ctx.beginPath(); ctx.arc(bx, by, 7, 0, 7); ctx.fill();
ctx.strokeStyle = "#fff"; ctx.lineWidth = 1.2;
ctx.beginPath(); ctx.arc(bx, by, 7, 0, 7); ctx.stroke();
ctx.fillStyle = "#fff"; ctx.font = "bold 8px sans-serif";
ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.fillText(mark, bx, by + 0.5);
ctx.textBaseline = "alphabetic";
}
ctx.restore();
});
/* 悬停提示:有近期变化的技能显示变化时间与证据量 */
if (G.hover && G.recentChange && G.recentChange.has(G.hover) && G.idx[G.hover] != null) {
const n = G.nodes[G.idx[G.hover]];
const e = G.recentChange.get(G.hover);
const p = toScreen(n);
const r = Math.max(2.5, nodeRadius(n) * Math.sqrt(G.cam.k));
const txt = `${e.window} ${e.change_type} · 支撑 ${(e.source_ids || []).length} 条 JD`;
ctx.font = "11px sans-serif";
const tw = ctx.measureText(txt).width;
const bx = Math.max(4, Math.min(c.clientWidth - tw - 20, p.x - tw / 2 - 8));
ctx.fillStyle = "rgba(29,33,41,0.92)";
ctx.beginPath(); ctx.roundRect(bx, p.y + r + 10, tw + 16, 20, 4); ctx.fill();
ctx.fillStyle = "#fff"; ctx.textAlign = "center";
ctx.fillText(txt, bx + tw / 2 + 8, p.y + r + 24);
}
}
/* ================= 小地图 ================= */
@@ -460,17 +555,56 @@
$("#fp-category").value = ""; $("#fp-level").value = "";
requestDraw(); drawMinimap();
};
/* 头部按钮 */
/* 头部主操作:查看演化 / 查看证据 / 开始匹配;保存/分享/导出收进「更多」 */
const activeJobId = () => {
const n = G.selected != null ? G.nodes[G.idx[G.selected]] : null;
if (n && n.type === "Job" && !n.pending) return G.selected;
if (G.soloJob) return G.soloJob;
return DEFAULT_SOLO_JOB;
};
$("#btn-goto-evo").onclick = () => {
CN.switchView("evolution");
$("#evo-job-select").value = activeJobId();
$("#evo-job-select").dispatchEvent(new Event("change"));
};
$("#btn-goto-match").onclick = () => {
CN.switchView("match");
$("#match-job-select").value = activeJobId();
$("#match-job-select").dispatchEvent(new Event("change"));
toast("已带入目标岗位,请上传或载入示例简历", "success");
};
$("#btn-goto-evidence").onclick = () => {
const id = G.selected || G.soloJob;
if (!id) return toast("请先在画布中选择一个节点", "info");
const sources = new Set();
G.edges.forEach((e) => {
if (e.source === id || e.target === id) (e.sources || []).forEach((s) => sources.add(s));
});
const list = [...sources];
if (!list.length) return toast("该节点暂无来源证据记录", "info");
const n = G.nodes[G.idx[id]];
CN.showJdEvidence(list, { jobTitle: n.title || id });
};
$("#btn-more-menu").onclick = (e) => {
e.stopPropagation();
$("#more-menu").classList.toggle("hidden");
};
document.addEventListener("click", (e) => {
if (!e.target.closest(".more-wrap")) $("#more-menu").classList.add("hidden");
});
$("#btn-save-view").onclick = () => {
localStorage.setItem(VIEW_KEY, JSON.stringify({ cam: G.cam, filters: G.filters, labels: G.showLabels }));
$("#more-menu").classList.add("hidden");
toast("当前视图(位置/缩放/筛选)已保存", "success");
};
$("#btn-share-graph").onclick = async () => {
const url = `${location.origin}/#view=graph${G.selected ? `&node=${encodeURIComponent(G.selected)}` : ""}`;
$("#more-menu").classList.add("hidden");
try { await navigator.clipboard.writeText(url); toast("分享链接已复制到剪贴板", "success"); }
catch { CN.sysCopy({ title: "分享链接(请复制)", text: url }); }
};
$("#btn-export-graph").onclick = () => {
$("#more-menu").classList.add("hidden");
const c = canvas();
const tmp = document.createElement("canvas");
tmp.width = c.width; tmp.height = c.height;
@@ -487,7 +621,6 @@
toast("图谱 PNG 已导出", "success");
});
};
$("#gs-toggle").onclick = () => $("#graph-suggest").classList.toggle("collapsed");
}
function restoreView() {
@@ -511,34 +644,61 @@
} catch { return false; }
}
/* ================= 智能建议 ================= */
/* ================= 系统发现(原画布浮层「智能建议」,移入右侧详情面板底部可折叠区块) ================= */
const IGNORED_KEY = "cn_ignored_findings";
const ignoredSet = () => new Set(JSON.parse(localStorage.getItem(IGNORED_KEY) || "[]"));
function renderSuggest() {
const ig = ignoredSet();
const items = [];
G.pending.forEach((c) => items.push({
color: "#ff7d00", kind: "pending", id: c.job_id,
color: "#ff7d00", kind: "pending", id: c.job_id, nodeId: c.job_id,
text: `新岗位候选「${esc(c.title)}」待审核`,
sub: `${c.source_count} 条 JD · ${c.company_count} 家企业支撑`,
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,
color: "#2f54eb", 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)}`,
sub: `证据:窗口 ${esc(e.window)} · 需求指数 ${e.prev_weight.toFixed(2)}→${e.curr_weight.toFixed(2)} · ${(e.source_ids || []).length} 条 JD`,
}));
$("#gs-list").innerHTML = items.length ? items.map((it, i) => `
const list = items.filter((it) => !ig.has(`${it.kind}|${it.id}`));
$("#gs-list").innerHTML = list.length ? list.map((it, i) => `
<div class="gs-item" data-i="${i}">
<span class="gs-dot" style="background:${it.color}"></span>
<div><div class="gs-text">${it.text}</div><div class="gs-sub">${it.sub}</div>
${it.kind === "pending" ? `<a class="link-btn" data-review="${esc(it.id)}">去审核 →</a>` : ""}</div>
</div>`).join("") : `<div class="empty small">暂无建议</div>`;
<div class="gs-body">
<div class="gs-text">${it.text}</div>
<div class="gs-sub">${it.sub}</div>
<div class="gs-acts">
<button class="link-btn" data-accept="${i}">${it.kind === "pending" ? "采纳(去审核台)" : "采纳(去演化页)"}</button>
<button class="link-btn gs-ignore" data-ignore="${i}">忽略</button>
</div>
</div>
</div>`).join("") : `<div class="empty small">暂无系统发现</div>`;
$$("#gs-list .gs-item").forEach((el) => {
el.onclick = (ev) => {
if (ev.target.dataset.review) return;
const it = items[+el.dataset.i];
locateNode(it.id);
if (ev.target.dataset.accept !== undefined || ev.target.dataset.ignore !== undefined) return;
locateNode(list[+el.dataset.i].nodeId);
};
});
$$("#gs-list [data-review]").forEach((a) => {
a.onclick = () => locateNode(a.dataset.review);
$$("#gs-list [data-accept]").forEach((b) => {
b.onclick = () => {
const it = list[+b.dataset.accept];
if (it.kind === "pending") {
if (CN.openReviewDesk) CN.openReviewDesk(it.id);
} else {
CN.switchView("evolution");
$("#evo-job-select").value = it.nodeId;
$("#evo-job-select").dispatchEvent(new Event("change"));
}
};
});
$$("#gs-list [data-ignore]").forEach((b) => {
b.onclick = () => {
const it = list[+b.dataset.ignore];
const s = ignoredSet();
s.add(`${it.kind}|${it.id}`);
localStorage.setItem(IGNORED_KEY, JSON.stringify([...s]));
renderSuggest();
};
});
}
@@ -605,10 +765,11 @@
const n = G.nodes[G.idx[id]];
if (!n) { G.pendingLocate = id; return; } // 数据未就绪:加载完成后补定位
G.pendingLocate = null;
/* 目标不在当前岗位子图内:自动退出子图,避免"定位到不可见节点" */
/* 目标不在当前岗位子图内:自动退出子图(即全量「岗位生态总览」),避免"定位到不可见节点" */
if (G.soloJob && id !== G.soloJob && !neighborsOf(G.soloJob).has(id)) {
G.soloJob = null;
updateSoloBadge();
updateRecentChange();
fitView();
}
selectNode(id);
@@ -626,7 +787,13 @@
}
}
const soloExit = $("#solo-exit");
if (soloExit) soloExit.onclick = () => { G.soloJob = null; updateSoloBadge(); fitView(); };
if (soloExit) soloExit.onclick = () => {
G.soloJob = null;
updateSoloBadge();
updateRecentChange();
fitView();
toast("已退出子图,当前为岗位生态总览(全量图)", "info");
};
/* ================= 备注(localStorage) ================= */
const getNotes = () => JSON.parse(localStorage.getItem(NOTES_KEY) || "{}");
@@ -722,7 +889,7 @@
? G.events.filter((e) => e.job_id === id).sort((a, b) => (a.window < b.window ? 1 : -1)).slice(0, 3)
: [];
const dataCutoff = ((G.events.at(-1) || {}).window) || latestWin || "2026Q3";
const EV_TAG = { "新增": "tag-green", "增强": "tag-blue", "减弱": "tag-orange", "退出": "tag-red" };
const EV_TAG = { "新增": "tag-cyan", "增强": "tag-purple", "减弱": "tag-orange", "退出": "tag-red" };
const skillChip = (r, cls) => `<span class="tag ${cls} dt-skill" data-id="${esc(r.id)}" title="点击聚焦该技能">${esc(r.title)}</span>`;
const jobBlocks = isJob ? `
<div class="dt-sec"><div class="dt-sec-title">结论</div>
@@ -789,8 +956,8 @@
<div class="dt-sec"><div class="dt-sec-title">操作</div>
<div class="dt-actions">
${isPending ? `
<button class="btn btn-primary btn-sm" id="dt-accept">确认入库</button>
<button class="btn btn-danger btn-sm" id="dt-reject">驳回</button>` : `
<button class="btn btn-primary btn-sm" id="dt-accept">人工批准入图</button>
<button class="btn btn-danger btn-sm" id="dt-reject">退回继续观察</button>` : `
<button class="btn btn-secondary btn-sm" id="dt-edit">编辑节点</button>
<button class="btn btn-secondary btn-sm" id="dt-sources">查看支撑 JD</button>`}
<button class="btn btn-secondary btn-sm" id="dt-note">添加备注</button>
@@ -860,9 +1027,12 @@
G.soloJob = G.soloJob === id ? null : id;
G._defaultApplied = true;
updateSoloBadge();
requestDraw();
if (G.soloJob) toast(`已进入「${n.title || id}」岗位子图(只看该岗位与一跳技能/场景/行业)`, "success");
else fitView();
updateRecentChange();
if (G.soloJob) {
applySoloLayout();
fitSoloView();
toast(`已进入「${n.title || id}」岗位子图(同心分层:中心岗位 → 必备 → 加分 → 场景/行业)`, "success");
} else fitView();
};
const matchBtn = $("#dt-match");
if (matchBtn) matchBtn.onclick = () => {
@@ -988,19 +1158,21 @@
/* 数据装载完成 Promise(演示导览等需要等图谱就绪后定位) */
ready() { return this._p || Promise.resolve(); },
has(id) { return id in G.idx; },
/* 进入指定岗位的局部子图(演示导览第 3 步) */
/* 进入指定岗位的局部子图(演示导览第 3 步):同心分层布局 */
solo(id) {
if (!(id in G.idx)) return;
G._defaultApplied = true;
G.soloJob = id;
updateSoloBadge();
updateRecentChange();
applySoloLayout();
selectNode(id, false);
centerOn(G.nodes[G.idx[id]], Math.max(G.cam.k, 1.05));
requestDraw();
fitSoloView();
},
onShow() { requestDraw(); drawMinimap(); },
async reload() {
await load();
if (G.soloJob && G.idx[G.soloJob]) { applySoloLayout(); fitSoloView(); } // 子图模式下重排同心分层
if (G.selected && !(G.selected in G.idx)) selectNode(null, false);
else if (G.selected) renderDetail(G.selected);
},
+112 -47
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CareerNexus 职联 · 岗位与能力图谱平台</title>
<link rel="stylesheet" href="/static/style.css?v=20260813">
<link rel="stylesheet" href="/static/style.css?v=20260819">
</head>
<body>
<!-- ============ 顶部栏 56px ============ -->
@@ -79,62 +79,104 @@
<!-- ============ 主内容区 ============ -->
<main class="content">
<!-- ======== 数据驾驶舱 ======== -->
<!-- ======== 人才能力变化指挥台(数据驾驶舱) ======== -->
<section id="view-dashboard" class="view active">
<div class="metric-grid" id="metric-cards"></div>
<div class="card cmd-hero">
<div class="cmd-hero-main">
<h2 id="cmd-title">人才能力变化概览</h2>
<div class="cmd-sub" id="cmd-sub">正在汇总本期变化…</div>
<div class="cmd-scale text-weak" id="cmd-scale"></div>
</div>
<div class="cmd-hero-actions">
<button class="btn btn-primary btn-sm" id="btn-cmd-report">查看本期报告</button>
<button class="btn btn-secondary btn-sm" id="btn-open-review-desk">候选审核台</button>
<button class="btn btn-secondary btn-sm" id="btn-import-jd">导入 JD 数据</button>
</div>
</div>
<div class="metric-grid cmd-kpis" id="metric-cards"></div>
<div class="grid-2">
<div class="card">
<div class="card-head">
<h3>近 12 个季度演化事件趋势</h3>
<span class="text-weak text-aux">按事件窗口统计</span>
<h3>近 8 季度变化事件流(全岗位)</h3>
<span class="text-weak text-aux">点击柱段进入该季度演化详情</span>
</div>
<div class="card-body">
<canvas id="trend-canvas" height="220"></canvas>
<div class="stack-legend" id="cmd-stack-legend"></div>
</div>
<div class="card-body"><canvas id="trend-canvas" height="220"></canvas></div>
</div>
<div class="card">
<div class="card-head">
<h3>任务与审核状态</h3>
<button class="btn btn-secondary btn-sm" id="btn-import-jd">导入 JD 数据</button>
</div>
<div class="card-body" id="task-table-wrap">
<table class="table" id="task-table">
<thead><tr><th>类型</th><th>对象</th><th>操作人</th><th>时间</th><th>结果</th></tr></thead>
<tbody></tbody>
</table>
<div class="empty small hidden" id="task-empty">暂无任务记录</div>
<h3>数据可信度</h3>
<span class="text-weak text-aux">多源交叉验证口径</span>
</div>
<div class="card-body" id="trust-card"><div class="empty small">统计中…</div></div>
</div>
</div>
<div class="grid-2">
<div class="card">
<div class="card-head"><h3>动态消息流</h3><span class="text-weak text-aux">点击可跳转</span></div>
<div class="card-head"><h3>变化播报</h3><span class="text-weak text-aux">点击跳转演化详情</span></div>
<div class="card-body feed" id="activity-feed"></div>
</div>
<div class="card">
<div class="card-head"><h3>待审核新岗位候选</h3><span class="badge badge-warning" id="pending-count">0 待办</span></div>
<div class="card-head">
<h3>待办行动</h3>
<span class="cmd-head-right">
<span class="badge badge-warning" id="pending-count">0 待办</span>
<button class="btn btn-text btn-sm" id="btn-open-review-desk-2">进入审核台</button>
</span>
</div>
<div class="card-body" id="pending-list"></div>
</div>
</div>
<div class="card cmd-ledger">
<div class="card-head">
<h3>任务与审核台账</h3>
<button class="btn btn-text btn-sm" id="btn-audit-toggle">展开台账</button>
</div>
<div class="card-body hidden" id="task-table-wrap">
<table class="table" id="task-table">
<thead><tr><th>类型</th><th>对象</th><th>操作人</th><th>时间</th><th>结果</th></tr></thead>
<tbody></tbody>
</table>
<div class="empty small hidden" id="task-empty">暂无任务记录</div>
</div>
</div>
</section>
<!-- ======== 全景图谱工作台 ======== -->
<section id="view-graph" class="view">
<div class="graph-header card">
<div class="graph-title">
<h2>岗位能力图谱工作台</h2>
<h2>岗位能力证据地图</h2>
<span class="tag tag-blue">演示模式</span>
<span class="tag tag-cyan">技能点级</span>
<span class="tag tag-green">审核已开启</span>
</div>
<div class="graph-actions">
<button class="btn btn-secondary" id="btn-save-view">
<svg viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><path d="M17 21v-8H7v8M7 3v5h8"/></svg>
保存视图</button>
<button class="btn btn-secondary" id="btn-share-graph">
<svg viewBox="0 0 24 24"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 13.5l6.8 4M15.4 6.5l-6.8 4"/></svg>
分享</button>
<button class="btn btn-primary" id="btn-export-graph">
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
导出图谱</button>
<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>
查看演化</button>
<button class="btn btn-secondary" id="btn-goto-evidence">
<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
查看证据</button>
<button class="btn btn-primary" id="btn-goto-match">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="4"/><circle cx="12" cy="12" r="1" fill="currentColor"/></svg>
开始匹配</button>
<div class="more-wrap">
<button class="btn btn-secondary" id="btn-more-menu">更多 ▾</button>
<div class="more-menu hidden" id="more-menu">
<button class="more-item" id="btn-save-view">
<svg viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><path d="M17 21v-8H7v8M7 3v5h8"/></svg>
保存视图</button>
<button class="more-item" id="btn-share-graph">
<svg viewBox="0 0 24 24"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 13.5l6.8 4M15.4 6.5l-6.8 4"/></svg>
分享链接</button>
<button class="more-item" id="btn-export-graph">
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
导出图谱 PNG</button>
</div>
</div>
</div>
</div>
<div class="graph-body">
@@ -203,15 +245,7 @@
</div>
</div>
<!-- 右下:智能建议 -->
<div class="graph-suggest card" id="graph-suggest">
<div class="gs-head">
<svg viewBox="0 0 24 24"><path d="M12 2a7 7 0 00-4 12.7V17a1 1 0 001 1h6a1 1 0 001-1v-2.3A7 7 0 0012 2z"/><path d="M9 21h6"/></svg>
智能建议
<button class="icon-btn sm" id="gs-toggle" title="收起/展开"><svg viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg></button>
</div>
<div class="gs-list" id="gs-list"></div>
</div>
<!-- 右下:智能建议已移至右侧详情面板「系统发现」 -->
<!-- 底部:演化逻辑链 -->
<div class="graph-chain" id="graph-chain">
@@ -233,6 +267,11 @@
<p class="text-aux text-weak">支持拖拽平移 / 滚轮缩放 / 点击选中</p>
</div>
<div class="detail-body hidden" id="detail-body"></div>
<!-- 系统发现(原画布浮层「智能建议」移入此处,含采纳/忽略) -->
<details class="sys-findings" id="sys-findings" open>
<summary>系统发现(智能建议)</summary>
<div class="gs-list" id="gs-list"></div>
</details>
</aside>
</div>
</section>
@@ -252,10 +291,19 @@
</div>
</div>
<div class="card-body">
<!-- 全部岗位演化热力图 -->
<div class="evo-chart-title text-aux text-secondary">全部岗位演化热力图(每格 = 该季度演化事件数,点击行切换岗位)</div>
<div class="evo-heatmap" id="evo-heatmap"><div class="empty small">加载中…</div></div>
<!-- 二级 Tab:岗位报告(默认) / 全岗位扫描(热力图) -->
<div class="match-tabs" id="evo-tabs">
<button class="match-tab active" data-evotab="report">岗位报告</button>
<button class="match-tab" data-evotab="scan">全岗位扫描</button>
</div>
<div id="evo-tab-scan" class="hidden">
<!-- 全部岗位演化热力图:仅近 12 季度,无事件格子置为极浅色 -->
<div class="evo-chart-title text-aux text-secondary">全部岗位演化热力图 · 近 12 季度(每格 = 该季度演化事件数,点击行切换岗位并回到岗位报告)</div>
<div class="evo-heatmap" id="evo-heatmap"><div class="empty small">加载中…</div></div>
</div>
<div id="evo-tab-report">
<!-- 统计条 -->
<div class="evo-stats" id="evo-stats"></div>
@@ -263,6 +311,14 @@
<div class="evo-chart-title text-aux text-secondary">本期结论(最近两个季度)</div>
<div class="evo-summary" id="evo-summary"><div class="empty small">加载中…</div></div>
<!-- 能力生命线(Swimlane):每个核心技能一行,线段=生效区间,圆点=变更事件 -->
<div class="evo-chart-title text-aux text-secondary">能力生命线(近 8 季度 · 按当前需求指数取 Top 12;线段 = 生效区间,圆点 = 变更事件)</div>
<div class="evo-gantt" id="evo-gantt"></div>
<!-- 能力更替流:旧核心 → 持续能力 → 新核心 -->
<div class="evo-chart-title text-aux text-secondary">能力更替流(旧核心 → 持续能力 → 新核心)</div>
<div class="evo-replace" id="evo-replace"></div>
<!-- 时点对比器 -->
<div class="evo-compare">
<div class="evo-compare-head">
@@ -282,10 +338,6 @@
<div class="evo-heat-wrap"><canvas id="evo-stack-canvas"></canvas></div>
<div class="stack-legend" id="stack-legend"></div>
<!-- 技能生命周期甘特图 -->
<div class="evo-chart-title text-aux text-secondary">技能生命周期(横条 = 技能在画像中的生效区间,圆点 = 变更事件)</div>
<div class="evo-gantt" id="evo-gantt"></div>
<!-- 热度走势(限高,默认近 8 个季度,纵轴为活跃技能关系数) -->
<div class="evo-chart-title text-aux text-secondary evo-heat-head">
<span>技能热度走势(纵轴:活跃技能关系数)</span>
@@ -296,9 +348,10 @@
</div>
<div class="evo-heat-wrap"><canvas id="evo-heat-canvas"></canvas></div>
<!-- 事件时间轴 -->
<div class="evo-chart-title text-aux text-secondary">演化事件时间轴(点击查看证据 JD)</div>
<!-- 本期变化卡:事件时间轴,每条含变化前后/支撑证据入口 -->
<div class="evo-chart-title text-aux text-secondary">本期变化卡(变化前 → 变化后 / 支撑 JD 数 / 点击查看证据原文)</div>
<div class="timeline" id="evo-timeline"></div>
</div>
</div>
</div>
</section>
@@ -538,6 +591,18 @@
</div>
<div class="toast-wrap" id="toast-wrap"></div>
<!-- 候选岗位审核台(右侧抽屉式二级界面) -->
<div class="drawer-mask hidden" id="review-desk-mask">
<aside class="drawer" id="review-desk" role="dialog" aria-modal="true">
<div class="drawer-head">
<h3>候选岗位审核台</h3>
<span class="text-weak text-aux">发现 → 证据 → 人工决策 → 自动入图</span>
<button class="icon-btn" id="review-desk-close" title="关闭"><svg viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12"/></svg></button>
</div>
<div class="drawer-body" id="review-desk-body"></div>
</aside>
</div>
<!-- 评委演示导览浮动条(右上角固定) -->
<div class="tour-bar hidden" id="tour-bar">
<span class="tour-prog" id="tour-prog">演示 1/6</span>
@@ -550,7 +615,7 @@
<button class="btn btn-text btn-sm" id="tour-exit">退出</button>
</div>
<script src="/static/app.js?v=20260813"></script>
<script src="/static/graph.js?v=20260813"></script>
<script src="/static/app.js?v=20260819"></script>
<script src="/static/graph.js?v=20260819"></script>
</body>
</html>
+140 -2
View File
@@ -481,8 +481,8 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width:
content: ""; position: absolute; left: -21px; top: 16px;
width: 10px; height: 10px; border-radius: 50%; border: 2px solid #fff;
}
.evo-event.t-新增::before { background: var(--success); }
.evo-event.t-增强::before { background: var(--primary); }
.evo-event.t-新增::before { background: var(--cyan); }
.evo-event.t-增强::before { background: var(--purple); }
.evo-event.t-减弱::before { background: var(--warning); }
.evo-event.t-退出::before { background: var(--danger); }
.evo-event .ee-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
@@ -1024,3 +1024,141 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width:
/* 空状态内的行动按钮 */
.empty .btn { margin-top: 8px; }
/* ============ 2026Q3 改版:指挥台 / 审核台 / 证据地图 / 生命线 / 路线图 ============ */
/* 指挥台首屏 */
.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%);
}
.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-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; }
/* 数据可信度卡 */
.trust-row {
display: flex; align-items: baseline; gap: 10px;
padding: 8px 2px; border-bottom: 1px dashed var(--border);
}
.trust-row:last-child { border-bottom: none; }
.trust-k { width: 76px; flex: none; font-size: 13px; color: var(--text-2); }
.trust-v { font-size: 18px; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--primary); min-width: 64px; }
.trust-sub { font-size: 12px; color: var(--text-3); }
/* 变化播报卡 */
.feed-item.bcast { align-items: center; }
.feed-item .bcast-body { flex: 1; min-width: 0; }
.feed-item.bcast .tag { flex: none; }
/* 待办行动卡 */
.review-item.action-card { border-left: 3px solid var(--warning); }
/* 候选岗位审核台(右侧抽屉) */
.drawer-mask {
position: fixed; inset: 0; background: rgba(29, 33, 41, 0.35); z-index: 450;
}
.drawer {
position: absolute; top: 0; right: 0; bottom: 0; width: 480px; max-width: calc(100vw - 48px);
background: var(--bg-page); box-shadow: -6px 0 16px rgba(29, 33, 41, 0.12);
display: flex; flex-direction: column;
}
.drawer-head {
display: flex; align-items: center; gap: 10px;
padding: 14px 16px; background: #fff; border-bottom: 1px solid var(--border);
}
.drawer-head h3 { font-size: 15px; }
.drawer-head .icon-btn { margin-left: auto; }
.drawer-body { flex: 1; overflow-y: auto; padding: 14px 16px; }
.cand-card {
background: #fff; border: 1px solid var(--border); border-radius: var(--radius);
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); } }
.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; }
.cc-dots { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
.cred-dot {
width: 12px; height: 12px; border-radius: 50%;
background: var(--bg-page); border: 1.5px solid var(--border-dark);
}
.cred-dot.on { background: var(--success); border-color: var(--success); }
.cc-dots-label { font-size: 12px; color: var(--text-3); margin-left: 4px; }
.cc-meta { font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
.cc-diff { font-size: 12px; color: var(--text-2); margin-bottom: 8px; }
.cc-skills { font-size: 12px; color: var(--text-2); line-height: 1.9; margin-bottom: 10px; }
.cc-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.cmp-meta { font-size: 13px; color: var(--text-2); margin-bottom: 12px; }
/* 图谱页「更多」下拉 */
.more-wrap { position: relative; }
.more-menu {
position: absolute; top: 38px; right: 0; z-index: 60; min-width: 150px;
background: #fff; border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: var(--shadow-pop); padding: 4px; display: flex; flex-direction: column;
}
.more-item {
display: flex; align-items: center; gap: 8px; border: none; background: none;
padding: 8px 12px; font-size: 13px; color: var(--text-1); border-radius: var(--radius-sm);
text-align: left; cursor: pointer; white-space: nowrap;
}
.more-item:hover { background: var(--primary-light); color: var(--primary); }
.more-item svg { width: 14px; height: 14px; }
/* 右侧详情面板底部「系统发现」(原画布智能建议浮层) */
.sys-findings { margin-top: auto; border-top: 1px solid var(--border); background: var(--bg-hover); }
.sys-findings > summary {
cursor: pointer; padding: 10px 16px; font-size: 13px; font-weight: 600; color: var(--text-1);
user-select: none; list-style: none;
}
.sys-findings > summary::before { content: "▸ "; color: var(--warning); }
.sys-findings[open] > summary::before { content: "▾ "; }
.sys-findings .gs-list { max-height: 240px; overflow-y: auto; padding: 0 8px 10px; }
.gs-item .gs-body { flex: 1; min-width: 0; }
.gs-acts { display: flex; gap: 10px; margin-top: 4px; }
.gs-ignore { color: var(--text-3); }
.gs-ignore:hover { color: var(--danger); }
/* 能力生命线(Swimlane) */
.sl-grid { display: grid; gap: 2px; min-width: 640px; align-items: center; }
.sl-cell { position: relative; height: 20px; display: flex; align-items: center; }
.sl-seg { position: absolute; left: 0; right: 0; height: 4px; border-radius: 2px; opacity: .8; }
.sl-dot {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
width: 9px; height: 9px; border-radius: 50%; border: 1.5px solid #fff; z-index: 1;
}
.sl-status { font-size: 11px; text-align: center; font-weight: 600; }
/* 能力更替流(旧核心 → 持续能力 → 新核心) */
.evo-replace { margin-bottom: 16px; }
.rp-grid { display: grid; grid-template-columns: 1fr 28px 1fr 28px 1fr; gap: 8px; align-items: stretch; }
.rp-col { background: var(--bg-hover); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 12px; }
.rp-col.old { border-left: 3px solid var(--danger); }
.rp-col.keep { border-left: 3px solid var(--text-3); }
.rp-col.new { border-left: 3px solid var(--cyan); }
.rp-title { font-size: 12px; font-weight: 600; color: var(--text-1); }
.rp-note { font-size: 11px; color: var(--text-3); margin: 2px 0 8px; }
.rp-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.rp-chip { cursor: pointer; }
.rp-chip:hover { filter: brightness(0.92); }
.rp-arrow { align-self: center; text-align: center; color: var(--text-3); font-size: 16px; }
/* 成长路线图:概要条示范标识 / 示范路线横幅 */
.po-demo {
width: 100%; font-size: 12px; color: var(--warning);
background: var(--warning-light); border-radius: var(--radius-sm); padding: 4px 10px;
}
.path-demo-banner {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
background: var(--warning-light); border: 1px solid #ffd591; border-radius: var(--radius);
padding: 10px 14px; font-size: 13px; color: #ad4e00; margin-bottom: 14px;
}
.path-demo-banner .btn { margin-left: auto; }