/* CareerNexus 职联 前端主脚本:外壳 + 驾驶舱 + 演化 + 匹配 + 学习路径。 * 全部数据走 /api,完全离线(无外部依赖)。图谱工作台见 graph.js。 */ "use strict"; /* ================= 公共工具 ================= */ const $ = (s) => document.querySelector(s); const $$ = (s) => Array.from(document.querySelectorAll(s)); async function api(path, options) { options = options || {}; options.headers = Object.assign( { "X-Access-Token": localStorage.getItem("cn_access_token") || "" }, options.headers); const resp = await fetch(path, options); if (resp.status === 401) { openConfigModal(true); throw new Error("需要访问令牌:请在系统设置中填写"); } const body = await resp.json().catch(() => ({ code: -1, message: "响应解析失败" })); if (!resp.ok || body.code !== 0) throw new Error(body.detail || body.message || `请求失败 (${resp.status})`); return body.data; } function esc(s) { return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } function toast(msg, type = "info") { const el = document.createElement("div"); el.className = `toast ${type}`; el.textContent = msg; $("#toast-wrap").appendChild(el); setTimeout(() => el.remove(), 2800); } /* ================= 统一系统弹窗(替代浏览器原生 alert/prompt/confirm) ================= */ function sysDialog({ title, bodyHtml, okText = "确定", cancelText = "取消", danger = false, onOk }) { return new Promise((resolve) => { const wrap = document.createElement("div"); wrap.className = "sysdlg-mask"; wrap.innerHTML = ` `; document.body.appendChild(wrap); const close = (val) => { wrap.remove(); resolve(val); }; wrap.querySelector('[data-act="ok"]').onclick = () => { if (onOk) { const v = onOk(wrap); if (v !== undefined) close(v); // onOk 返回 undefined 表示校验不通过,不关闭 } else close(true); }; wrap.querySelector('[data-act="cancel"]')?.addEventListener("click", () => close(null)); wrap.addEventListener("click", (e) => { if (e.target === wrap) close(null); }); const input = wrap.querySelector("input,textarea"); if (input) { input.focus(); input.select(); input.addEventListener("keydown", (e) => { if (e.key === "Enter" && input.tagName === "INPUT") wrap.querySelector('[data-act="ok"]').click(); }); } }); } function sysPrompt({ title, label = "", value = "", placeholder = "" }) { return sysDialog({ title, bodyHtml: `${label ? `
${esc(label)}
` : ""} `, onOk: (w) => w.querySelector("input").value.trim() || null, }); } function sysConfirm({ title, text, okText = "确定", danger = false }) { return sysDialog({ title, bodyHtml: `
${esc(text)}
`, okText, danger }); } function sysAlert({ title, text, okText = "知道了" }) { return sysDialog({ title, bodyHtml: `
${esc(text)}
`, okText, cancelText: "" }); } /* 复制展示框:readonly 输入 + 一键复制(clipboard 失败时退化为手动全选复制) */ function sysCopy({ title, text }) { return sysDialog({ title, okText: "复制", cancelText: "关闭", bodyHtml: ``, onOk: async (w) => { const input = w.querySelector("input"); input.focus(); input.select(); try { await navigator.clipboard.writeText(text); toast("已复制到剪贴板", "success"); } catch { document.execCommand("copy"); toast("已复制到剪贴板", "success"); } return true; }, }); } function openModal(title, bodyHtml, footHtml) { $("#modal-title").innerHTML = title; $("#modal-body").innerHTML = bodyHtml; const foot = $("#modal-foot"); if (footHtml) { foot.innerHTML = footHtml; foot.classList.remove("hidden"); } else foot.classList.add("hidden"); $("#modal-mask").classList.remove("hidden"); } function closeModal() { $("#modal-mask").classList.add("hidden"); $("#modal-box").classList.remove("wide"); } $("#modal-close").onclick = closeModal; $("#modal-mask").addEventListener("click", (e) => { if (e.target.id === "modal-mask") closeModal(); }); document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeModal(); }); const post = (path, payload) => api(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); /* 全局共享状态与命名空间(graph.js 复用) */ const CN = window.CN = { api, post, $, $$, esc, toast, openModal, closeModal, sysPrompt, sysConfirm, sysAlert, sysCopy, jobs: [], // /api/jobs 缓存 graphData: null, // /api/graph 缓存 switchView: null, // 下方赋值 gotoNode: null, // graph.js 注册:跳转图谱并选中节点 }; const TYPE_META = { Job: { name: "岗位", color: "#2f54eb", tag: "tag-blue" }, Skill: { name: "技能", color: "#00b42a", tag: "tag-green" }, Industry: { name: "行业", color: "#13c2c2", tag: "tag-cyan" }, Scenario: { name: "场景", color: "#722ed1", tag: "tag-purple" }, }; CN.TYPE_META = TYPE_META; /* JD 证据弹窗(演化事件 / 图谱来源共用) */ async function showJdEvidence(sid) { openModal(`证据来源 · ${esc(sid)}`, `
加载中…
`); try { const rec = await api(`/api/jd?sid=${encodeURIComponent(sid)}`); $("#modal-body").innerHTML = `
${esc(rec.job_title)}${esc(rec.company)} ${esc(rec.city || "—")}${esc(rec.salary || "—")} 发布 ${esc(rec.publish_date)}来源 ${esc(rec.source || "—")}
${esc(rec.text)}
`; } catch (e) { $("#modal-body").innerHTML = `
原文读取失败:${esc(e.message)}
`; } } CN.showJdEvidence = showJdEvidence; /* ================= 视图切换 / 侧栏 ================= */ const PAGE_TITLES = { dashboard: "数据驾驶舱", graph: "全景图谱", evolution: "岗位演化", match: "人岗匹配", path: "学习路径", }; const _loaded = {}; function switchView(name) { $$(".nav-item").forEach((b) => b.classList.toggle("active", b.dataset.view === name)); $$(".view").forEach((v) => v.classList.remove("active")); $("#view-" + name).classList.add("active"); $("#page-title").textContent = PAGE_TITLES[name] || ""; /* 视图记入 URL hash,刷新后回到同一页(node 参数在 graph.js 内维护) */ const hash = new URLSearchParams(location.hash.slice(1)); if (hash.get("view") !== name) { hash.set("view", name); if (name !== "graph") hash.delete("node"); history.replaceState(null, "", "#" + hash.toString()); } 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); } } CN.switchView = switchView; $$(".nav-item").forEach((btn) => { btn.onclick = () => switchView(btn.dataset.view); }); /* ================= 全局搜索 ================= */ (function initSearch() { const input = $("#global-search-input"), drop = $("#global-search-drop"); let timer = null; input.addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(async () => { const kw = input.value.trim(); if (!kw) { drop.classList.add("hidden"); return; } try { if (!CN.graphData) CN.graphData = await api("/api/graph"); const hits = CN.graphData.nodes .filter((n) => (n.title || n.id).toLowerCase().includes(kw.toLowerCase())) .slice(0, 8); drop.innerHTML = hits.length ? hits.map((n, i) => { const m = TYPE_META[n.type] || { name: n.type, tag: "tag-gray" }; return `
${m.name}${esc(n.title || n.id)} ${esc(n.category || "")}
`; }).join("") : `
无匹配结果
`; drop.classList.remove("hidden"); $$("#global-search-drop .sd-item[data-id]").forEach((el) => { el.onclick = () => { drop.classList.add("hidden"); input.value = ""; switchView("graph"); if (CN.gotoNode) CN.gotoNode(el.dataset.id); }; }); } catch { /* 静默 */ } }, 200); }); document.addEventListener("click", (e) => { if (!e.target.closest(".global-search")) drop.classList.add("hidden"); }); })(); /* ================= 数据驾驶舱 ================= */ const METRIC_DEFS = [ { key: "jobs", label: "岗位节点", view: "graph", icon: '' }, { key: "skills", label: "技能节点", view: "graph", icon: '' }, { key: "edges", label: "图谱关系边", view: "graph", icon: '' }, { key: "events", label: "演化事件", view: "evolution", icon: '' }, { key: "pending", label: "待审核候选", view: "graph", icon: '' }, ]; 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"), api("/api/review/pending").catch(() => []), api("/api/audit?limit=8").catch(() => []), ]); CN.graphData = graph; const s = health.graph || {}; updateBanners(health); $("#data-version").textContent = `数据更新至 ${(graph.events.at(-1) || {}).window || "2026Q3"}(模拟)`; /* 指标卡 */ 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" ? `${arrow} 环比上季 ${Math.abs(delta)}` : `实时统计`; return `
${m.icon}${m.label}
${values[m.key] ?? "—"}
${deltaTxt}
`; }).join(""); $$("#metric-cards .metric-card").forEach((el) => { el.onclick = () => switchView(el.dataset.view); sparkline(el.querySelector("canvas"), last12.length ? last12 : [0, 0], "#2f54eb"); }); $("#pending-count").textContent = `${pending.length} 待办`; /* 趋势图(近 12 季度事件数,柱状) */ CN._trend = series.slice(-12); drawTrend($("#trend-canvas"), CN._trend); /* 任务与审核状态表 */ renderTaskTable(audit); $("#btn-import-jd").onclick = () => openImportDialog(); /* 动态消息流 */ renderFeed(graph.events || [], audit); /* 待审核候选 */ renderPending(pending); } /* JD 数据导入对话框:上传文件 / 粘贴文本 / 直接全量重建 */ async function openImportDialog() { const choice = await sysDialog({ title: "JD 数据导入", okText: "开始导入", bodyHtml: `
支持 .jsonl / .csv / .txt 文件上传(UTF-8,≤10MB),或直接粘贴 JSONL 文本(每行一条记录);两者都留空则仅触发全量重建。
上传文件
或粘贴文本
`, onOk: (w) => { const f = w.querySelector("#imp-file").files[0]; const t = w.querySelector("#imp-text").value; if (!f && !t.trim()) return "rebuild"; return { file: f, text: t }; }, }); if (choice === null) return; try { let t; if (choice === "rebuild") { t = await post("/api/jd/import", {}); } else { const fd = new FormData(); if (choice.file) fd.append("file", choice.file); else fd.append("text", choice.text); t = await api("/api/jd/import", { method: "POST", body: fd }); const bad = (t.bad_lines || []).length; toast(`已接收:导入 ${t.imported} 条 / 跳过 ${t.skipped} 条 / 坏行 ${bad} 条` + (bad ? `(详见 logs/${t.bad_log})` : ""), bad ? "error" : "success"); } toast(`导入任务已启动:${t.task_id}`, "success"); pollTask(t.task_id); } catch (e) { toast(e.message, "error"); } } async function pollTask(taskId) { const timer = setInterval(async () => { try { const t = await api(`/api/tasks/${taskId}`); if (t.status === "done") { clearInterval(timer); toast("JD 导入完成,图谱已更新", "success"); loadDashboard(); } else if (t.status === "failed") { clearInterval(timer); toast("导入失败:" + (t.error || "未知错误"), "error"); } } catch { clearInterval(timer); } }, 1500); } function renderTaskTable(audit) { const tbody = $("#task-table tbody"); const rows = (audit || []).slice(0, 8); if (!rows.length) { tbody.innerHTML = ""; $("#task-empty").classList.remove("hidden"); return; } $("#task-empty").classList.add("hidden"); 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 badge = isNode ? "badge-version" : (r.action === "reject" ? "badge-warning" : "badge-success"); return `${act} ${esc(obj)}${esc(r.operator || "—")} ${esc(r.time || "—")}${esc(r.reason || "—")}`; }).join(""); } function renderFeed(events, audit) { const items = []; events.slice(-8).reverse().forEach((e) => items.push({ icon: "#2f54eb", bg: "#f0f5ff", kind: "evo", text: `${esc(e.job_id.replace(/^job_/, ""))} ${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" ? `${esc(r.operator)} 编辑了节点「${esc(r.node_id)}」` : `${esc(r.operator)} ${{ 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) => `
${it.text}
${esc(it.time)}
`).join("") || `
暂无动态
`; $$("#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(); } }; }); } function renderPending(pending) { const box = $("#pending-list"); if (!pending.length) { box.innerHTML = `
暂无待审核候选(可在图谱页触发新岗位发现)
`; return; } box.innerHTML = pending.map((c) => `
${esc(c.title)}待审核 ${esc(c.category || "")}
支撑 ${c.source_count} 条 JD · ${c.company_count} 家企业 · 期望年限 ${c.expected_years} 年
必备:${c.required_skills.map((s) => `${esc(s)}`).join(" ")} ${c.preferred_skills.length ? `
加分:${c.preferred_skills.map((s) => `${esc(s)}`).join(" ")}` : ""}
`).join(""); 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); }; }); } async function doReview(id, action) { const operator = await CN.sysPrompt({ title: "审核确认", label: "请输入审核人姓名:", value: "评审专家" }); if (operator === null) return; let reason = "webui 审核通过"; if (action === "reject") { 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"); loadDashboard(); if (CN.graph && CN.graph.reload) CN.graph.reload(); } catch (e) { toast(e.message, "error"); } } CN.doReview = doReview; /* 画布自适应:占满父容器(宽 100%、高 100%)+ dpr 高清,返回 CSS 像素坐标系下的 ctx/W/H */ function fitCanvas(canvas, cssH) { const dpr = window.devicePixelRatio || 1; canvas.style.width = "100%"; canvas.style.height = "100%"; const W = canvas.clientWidth || 600; const H = (canvas.clientHeight >= 120 ? canvas.clientHeight : cssH); canvas.width = W * dpr; canvas.height = H * dpr; const ctx = canvas.getContext("2d"); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return { ctx, W, H }; } function drawTrend(canvas, kv) { 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); }); } /* ================= 岗位演化 ================= */ const CHANGE_META = { "新增": { tag: "tag-green", color: "#00b42a" }, "增强": { tag: "tag-blue", color: "#2f54eb" }, "减弱": { tag: "tag-orange", color: "#ff7d00" }, "退出": { tag: "tag-red", color: "#f53f3f" }, }; let evoEvents = []; /* 全部岗位 × 季度 演化热力图 */ function renderEvoHeatmap() { const box = $("#evo-heatmap"); const events = (CN.graphData && CN.graphData.events) || []; const jobs = CN.jobs || []; if (!events.length || !jobs.length) { box.innerHTML = `
暂无演化事件数据
`; return; } const windows = [...new Set(events.map((e) => e.window))].sort(); // 统计每个岗位每季度事件数与类型分布 const cell = {}; events.forEach((e) => { const k = `${e.job_id}|${e.window}`; cell[k] = cell[k] || { n: 0, types: {} }; cell[k].n += 1; cell[k].types[e.change_type] = (cell[k].types[e.change_type] || 0) + 1; }); // 按总事件数降序排列岗位 const totals = {}; events.forEach((e) => { totals[e.job_id] = (totals[e.job_id] || 0) + 1; }); const rows = [...jobs].sort((a, b) => (totals[b.job_id] || 0) - (totals[a.job_id] || 0)); const labelEvery = Math.ceil(windows.length / 10); // 色阶:开方缩放 + 12 封顶,避免个别高峰季度(如 57 件)把其余格子压到看不清 const alphaOf = (n) => 0.18 + 0.82 * Math.sqrt(Math.min(n, 12) / 12); box.innerHTML = `
${windows.map((w, i) => `
${i % labelEvery === 0 ? esc(w) : ""}
`).join("")} ${rows.map((j) => `
${esc(j.title)}
${windows.map((w) => { const c = cell[`${j.job_id}|${w}`]; const tip = c ? `${j.title} · ${w}:${c.n} 个事件(${Object.entries(c.types).map(([t, n]) => `${t} ${n}`).join(",")})` : `${j.title} · ${w}:无事件`; return `
`; }).join("")}`).join("")}
事件数:少 多 (悬停查看类型分布,点击行标签切换岗位)
`; box.querySelectorAll(".hm-row-label").forEach((el) => { el.onclick = () => { $("#evo-job-select").value = el.dataset.job; loadEvolution().then(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")}`; } return w; } async function loadEvolution() { const id = $("#evo-job-select").value; if (!id) return; const data = await api(`/api/jobs/${encodeURIComponent(id)}/evolution`); evoEvents = data.events; renderEvoStats(id); renderEvolution(); runEvoCompare(); // 默认对比:最早事件前 → 今天 } function renderEvoStats(jobId) { const job = (CN.jobs || []).find((j) => j.job_id === jobId) || {}; const wins = [...new Set(evoEvents.map((e) => e.window))].sort(); const recent = new Set(wins.slice(-4)); const cnt = (t) => evoEvents.filter((e) => e.change_type === t && recent.has(e.window)).length; const jds = new Set(evoEvents.flatMap((e) => e.source_ids || [])); const items = [ ["当前必备技能", `${(job.required_skills || []).length} 项`], ["当前加分技能", `${(job.preferred_skills || []).length} 项`], ["近 4 季度新增", cnt("新增")], ["近 4 季度退出", cnt("退出")], ["证据 JD 总量", `${jds.size} 条`], ]; $("#evo-stats").innerHTML = items.map(([k, v]) => `
${k}${v}
`).join(""); } async function runEvoCompare() { const id = $("#evo-job-select").value; if (!id) return; const box = $("#evo-diff"); // 默认日期:A=最早事件所在季度初,B=今天 if (!$("#evo-date-a").value) { const first = (evoEvents[0] || {}).window || "2019Q1"; $("#evo-date-a").value = `${first.slice(0, 4)}-01-01`; } if (!$("#evo-date-b").value) { $("#evo-date-b").value = new Date().toISOString().slice(0, 10); } const atA = $("#evo-date-a").value, atB = $("#evo-date-b").value; box.innerHTML = `
对比计算中…
`; try { const [sa, sb] = await Promise.all([ api(`/api/jobs/${encodeURIComponent(id)}/snapshot?at=${atA}`), api(`/api/jobs/${encodeURIComponent(id)}/snapshot?at=${atB}`), ]); const setA = new Map(sa.skills.map((s) => [s.skill, s.importance])); const setB = new Map(sb.skills.map((s) => [s.skill, s.importance])); const onlyA = [...setA.keys()].filter((s) => !setB.has(s)); const both = [...setA.keys()].filter((s) => setB.has(s)); const onlyB = [...setB.keys()].filter((s) => !setA.has(s)); const chip = (s, cls, extra) => `${esc(s)}${extra ? `${extra}` : ""}`; const col = (title, list, cls, note) => `
${title}(${list.length})
${list.map((s) => chip(s, cls, note === "升" && setA.get(s) !== setB.get(s) ? "↑" : "")).join("") || '无'}
`; box.innerHTML = `
${esc(atA)}(${esc(sa.window)}):${setA.size} 项技能 → ${esc(atB)}(${esc(sb.window)}):${setB.size} 项技能
${col(`仅 ${esc(atA)} 有(已退出)`, onlyA, "tag-red")} ${col("两个时点共有(保留)", both, "tag-gray", "升")} ${col(`仅 ${esc(atB)} 有(新增)`, onlyB, "tag-green")}
`; box.querySelectorAll(".diff-chip").forEach((el) => { el.onclick = () => { switchView("graph"); if (CN.gotoNode) CN.gotoNode(el.dataset.skill); }; }); } catch (e) { box.innerHTML = `
对比失败:${esc(e.message)}
`; } } $("#evo-compare-btn").onclick = runEvoCompare; $("#evo-date-a").addEventListener("change", runEvoCompare); $("#evo-date-b").addEventListener("change", runEvoCompare); $("#evo-job-select").onchange = loadEvolution; $$("#evo-gran button").forEach((b) => { b.onclick = () => { $$("#evo-gran button").forEach((x) => x.classList.toggle("active", x === b)); renderEvolution(); }; }); 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); /* 热度走势:每季度实际生效的技能关系数(按 REQUIRES 边的生效区间统计) */ const jobEdges = ((CN.graphData || {}).edges || []) .filter((e) => e.source === $("#evo-job-select").value && e.rel === "REQUIRES" && (e.intervals || []).length); const allWins = [...new Set(((CN.graphData || {}).events || []).map((e) => e.window))].sort(); const winKey = (w) => bucketWindow(w, gran); const buckets = {}; allWins.forEach((w) => { buckets[winKey(w)] = jobEdges.filter((e) => e.intervals.some(([s, en]) => s <= w && (en === null || w < en))).length; }); CN._heat = Object.entries(buckets).sort((a, b) => (a[0] < b[0] ? -1 : 1)); drawHeatLine($("#evo-heat-canvas"), CN._heat); /* 时间轴 */ const box = $("#evo-timeline"); box.innerHTML = evoEvents.length ? evoEvents.map((e, i) => { const m = CHANGE_META[e.change_type] || { tag: "tag-gray" }; return `
${esc(e.window)} ${esc(e.change_type)} ${esc(e.skill)} 权重 ${e.prev_weight.toFixed(2)} → ${e.curr_weight.toFixed(2)}
${esc(e.description)}
证据 JD ${e.source_ids.length} 条 · 点击查看原文
`; }).join("") : `
该岗位暂无演化事件
`; box.querySelectorAll(".evo-event").forEach((el) => { el.onclick = () => { const e = evoEvents[+el.dataset.i]; const sids = e.source_ids || []; if (!sids.length) return toast("该事件无关联证据", "info"); if (sids.length === 1) return showJdEvidence(sids[0]); openModal(`证据来源 · ${esc(e.skill)}(${sids.length} 条)`, sids.map((s) => `
${esc(s)}
`).join("")); $$("#modal-body .src-item").forEach((x) => { x.onclick = () => showJdEvidence(x.dataset.sid); }); }; }); } function drawHeatLine(canvas, kv) { const { ctx, W, H } = fitCanvas(canvas, 200); ctx.clearRect(0, 0, W, H); if (!kv.length) return; const pad = { l: 40, r: 16, t: 16, b: 28 }; const max = Math.max(1, ...kv.map(([, v]) => v)); /* 网格与 y 轴 */ ctx.strokeStyle = "#f0f0f0"; ctx.fillStyle = "#86909c"; ctx.font = "11px 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 + 4); } 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); /* 面积 + 折线 */ ctx.beginPath(); kv.forEach(([, v], i) => ctx[i ? "lineTo" : "moveTo"](px(i), py(v))); ctx.strokeStyle = "#2f54eb"; 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)"); ctx.fillStyle = grad; ctx.fill(); /* 点与 x 轴 */ ctx.textAlign = "center"; kv.forEach(([k, v], i) => { ctx.fillStyle = "#2f54eb"; 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); } }); } /* 变更类型构成堆叠柱状图(按季度) */ function drawStackBars(canvas, events) { const { ctx, W, H } = fitCanvas(canvas, 220); ctx.clearRect(0, 0, W, H); const windows = [...new Set(events.map((e) => e.window))].sort(); $("#stack-legend").innerHTML = Object.entries(CHANGE_META).map(([t, m]) => `${t}`).join(""); 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); }); } /* 技能生命周期甘特图 */ 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 edges = (g.edges || []).filter((e) => e.source === jobId && e.rel === "REQUIRES" && (e.intervals || []).length); if (!windows.length || !edges.length) { box.innerHTML = `
暂无该岗位的技能生命周期数据
`; return; } // 排序:当前仍生效的在前(按起始窗口升序),已退出的在后;限 14 行 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 dots = {}; evoEvents.forEach((e) => { dots[`${e.skill}|${e.window}`] = e.change_type; }); const cellHtml = (edge, w) => { 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 ? `` : ""; return `
${active ? `
` : ""}${dot}
`; }; box.innerHTML = `
${windows.map((w, i) => `
${i % labelEvery === 0 ? esc(w.slice(2)) : ""}
`).join("")} ${rows.map((e) => `
${esc(e.target)}
${windows.map((w) => cellHtml(e, w)).join("")}`).join("")}
生效区间:必备 加分 圆点: ${Object.entries(CHANGE_META).map(([t, m]) => `${t}`).join("")}
`; box.querySelectorAll(".gt-label").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; // 最近一次解析的完整简历画像(结构化模板与导出用) function setMatchStep(n) { matchState.step = n; $$("#match-steps .step").forEach((s) => { const i = +s.dataset.step; s.classList.toggle("active", i === n); s.classList.toggle("done", i < n); if (i < n) s.querySelector("i").innerHTML = ''; else s.querySelector("i").textContent = i; }); [1, 2, 3].forEach((i) => $(`#match-pane-${i}`).classList.toggle("hidden", i !== n)); } $("#btn-load-sample").onclick = async () => { $("#resume-text").value = "姓名:陈晓\n手机:13900001111\n邮箱:chenx@example.com\n\n5年 Java 后端开发经验," + "熟悉 Java、Spring Boot、Spring Cloud、MySQL、Redis,了解 Docker 与 Kubernetes," + "参与过电商与金融场景的交易系统建设,具备分布式系统调优经验。"; toast("已载入示例简历", "success"); }; $("#resume-file").addEventListener("change", async (e) => { const f = e.target.files[0]; e.target.value = ""; if (!f) return; const ext = (f.name.split(".").pop() || "").toLowerCase(); if (["txt", "md"].includes(ext)) { // 纯文本:本地读取,走文本解析 const reader = new FileReader(); reader.onload = () => { $("#resume-text").value = reader.result; toast(`已读取 ${f.name}`, "success"); }; reader.readAsText(f); return; } if (["docx", "pdf"].includes(ext)) { // 二进制格式:上传后端解析 try { toast(`正在解析 ${f.name}…`); const fd = new FormData(); fd.append("file", f); const resp = await fetch("/api/resume/parse/file", { method: "POST", body: fd, headers: { "X-Access-Token": localStorage.getItem("cn_access_token") || "" }, }); const body = await resp.json(); if (!resp.ok) throw new Error(body.detail || `HTTP ${resp.status}`); const p = body.data; applyParsedProfile(p, `${f.name}(已脱敏)`); toast(`已解析 ${f.name}:识别技能 ${p.skills.length} 项`, "success"); } catch (err) { toast(`解析失败:${err.message}`, "error"); } return; } toast("仅支持 .txt / .docx / .pdf 格式", "error"); }); function applyParsedProfile(p, sourceDesc) { lastProfile = p; matchState.profileText = p.raw_masked_text || ""; matchState.skills = [...p.skills]; matchState.years = p.years || 0; $("#resume-text").value = p.raw_masked_text || ""; $("#parse-summary").innerHTML = `解析完成(来源:${esc(sourceDesc || "文本")},已自动脱敏):识别技能 ${p.skills.length} 项 · ` + `工作年限约 ${p.years || 0} 年 · 场景经验 ${(p.scenarios || []).join("、") || "未识别"}`; $("#parse-years").value = matchState.years; renderSkillChips(); setMatchStep(2); } $("#btn-parse-resume").onclick = async () => { const text = $("#resume-text").value.trim(); if (!text) return toast("请先粘贴或上传简历", "error"); try { const p = await post("/api/resume/parse", { text }); applyParsedProfile(p, "粘贴文本"); } catch (e) { toast(e.message, "error"); } }; function renderSkillChips() { const box = $("#skill-chip-editor"); box.innerHTML = matchState.skills.length ? matchState.skills.map((s, i) => `${esc(s)}`).join("") : `暂无技能,请在下方输入添加`; box.querySelectorAll("button").forEach((b) => { b.onclick = () => { matchState.skills.splice(+b.dataset.i, 1); renderSkillChips(); }; }); } function flushSkillInput() { // 支持逗号/顿号/分号/空白分隔的批量添加,去重去空 const input = $("#skill-add-input"); const parts = input.value.split(/[,,、;;\s]+/).map((s) => s.trim()).filter(Boolean); let added = 0; parts.forEach((v) => { if (!matchState.skills.includes(v)) { matchState.skills.push(v); added++; } }); input.value = ""; if (added) renderSkillChips(); return added; } $("#skill-add-input").addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); flushSkillInput(); } }); $("#skill-add-input").addEventListener("blur", flushSkillInput); $("#btn-skill-add").onclick = flushSkillInput; $("#btn-back-1").onclick = () => setMatchStep(1); $("#btn-run-match").onclick = async () => { flushSkillInput(); // 输入框里未回车的技能先入库,避免丢失 const jobId = $("#match-job-select").value; matchState.years = +$("#parse-years").value || 0; try { const m = await post("/api/match", { job_id: jobId, skills: matchState.skills, years: matchState.years, scenarios: [], }); matchState.result = m; renderMatchResult(m); setMatchStep(3); } catch (e) { toast(e.message, "error"); } }; $("#btn-back-2").onclick = () => setMatchStep(1); $("#btn-goto-path").onclick = () => { switchView("path"); $("#path-job-select").value = $("#match-job-select").value; loadPathJob($("#match-job-select").value, matchState.skills).then(() => $("#btn-path").click()); }; function renderMatchResult(m) { drawGauge($("#gauge-canvas"), m.total_score); $("#match-verdict").innerHTML = m.is_match ? `✓ 达到匹配阈值 目标岗位:${esc(m.job_title)}` : `未达匹配阈值(60 分) 目标岗位:${esc(m.job_title)}`; drawRadar($("#radar-canvas"), m.dimension_scores); const chip = (s, cls) => `${esc(s)}`; $("#matched-chips").innerHTML = [...m.matched_required.map((s) => chip(s, "tag-green")), ...m.matched_preferred.map((s) => chip(s, "tag-cyan"))].join("") || `无命中(简历中未识别到本岗位所需技能)`; $("#missing-chips").innerHTML = [...m.missing_required.map((s) => chip(s, "tag-red")), ...m.missing_preferred.map((s) => chip(s, "tag-orange"))].join("") || `无`; $("#transfer-chips").innerHTML = m.transferable_skills.map((s) => chip(s, "tag-purple")).join("") || `暂无可迁移技能`; // 声明掌握但与本岗位无关的技能:显式告知"不计入评分",避免用户疑惑 // (后端已做大小写/别名归一化,这里比较时统一小写,避免 redis/Redis 误判) const counted = new Set([...(m.matched_required || []), ...(m.matched_preferred || []), ...(m.transferable_skills || [])].map((s) => s.toLowerCase())); const irrelevant = matchState.skills.filter((s) => !counted.has(s.toLowerCase())); $("#irrelevant-block").classList.toggle("hidden", !irrelevant.length); $("#irrelevant-chips").innerHTML = irrelevant.map((s) => chip(s, "tag-gray")).join(""); renderResumeHighlight(m); renderRecJobs(m); } function skillClsMap(m) { // 技能 → 高亮类别:命中必备=绿,命中加分=蓝,其他已识别=灰 const clsOf = new Map(); (m.matched_required || []).forEach((s) => clsOf.set(s.toLowerCase(), "hl-req")); (m.matched_preferred || []).forEach((s) => clsOf.set(s.toLowerCase(), "hl-pref")); matchState.skills.forEach((s) => { if (!clsOf.has(s.toLowerCase())) clsOf.set(s.toLowerCase(), "hl-other"); }); return clsOf; } function highlightHtml(text, clsOf) { if (!clsOf.size) return esc(text); // 单次扫描替换:按长度降序构造联合正则,避免嵌套匹配(如 Spring / Spring Boot) const names = [...clsOf.keys()].sort((a, b) => b.length - a.length) .map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); const re = new RegExp(names.join("|"), "gi"); return esc(text).replace(re, (hit) => `${hit}`); } function renderResumeHighlight(m) { const box = $("#resume-highlight"); const p = lastProfile; if (!p || !(p.raw_masked_text || "").trim()) { box.innerHTML = `

无简历文本(本次以技能清单直接匹配)

`; return; } const clsOf = skillClsMap(m); const photo = p.photo_b64 ? `证件照` : `
无照片
`; const kv = (k, v) => `${k}${esc(v || "—")}`; const secs = (p.sections && p.sections.length) ? p.sections : [{ title: "简历全文", text: p.raw_masked_text }]; box.innerHTML = ` ${kv("姓名", p.name)}${kv("性别", p.gender)} ${kv("学历", p.education)}${kv("工作年限", p.years ? p.years + " 年" : "")}${kv("手机", p.phone)}${kv("邮箱", p.email)} ${secs.map((sec) => ` `).join("")}
${photo}
家庭住址${esc(p.address || "—")}
${esc(sec.title)} ${highlightHtml(sec.text, clsOf)}
`; } $("#btn-export-resume").onclick = async () => { if (!lastProfile) return toast("请先解析简历", "error"); try { const resp = await fetch("/api/resume/export", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ profile: lastProfile, match: matchState.result }), }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const blob = await resp.blob(); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = `简历_${lastProfile.name || "脱敏"}_结构化.docx`; a.click(); URL.revokeObjectURL(a.href); toast("规范简历 DOCX 已导出", "success"); } catch (e) { toast(`导出失败:${e.message}`, "error"); } }; async function renderRecJobs(current) { const box = $("#rec-job-cards"); box.innerHTML = `
计算中…
`; const others = CN.jobs.filter((j) => j.job_id !== current.job_id).slice(0, 12); const results = await Promise.all(others.map((j) => post("/api/match", { job_id: j.job_id, skills: matchState.skills, years: matchState.years, scenarios: [] }) .then((m) => ({ job: j, m })).catch(() => null))); const top = results.filter(Boolean).sort((a, b) => b.m.total_score - a.m.total_score).slice(0, 3); box.innerHTML = top.map(({ job, m }) => `
${esc(job.title)} ${esc(job.category || "")}
${m.total_score} 分
缺口:${m.missing_required.slice(0, 3).map(esc).join("、") || "无"}
`).join(""); box.querySelectorAll(".rec-job-card").forEach((el) => { el.onclick = () => { $("#match-job-select").value = el.dataset.id; $("#btn-run-match").click(); }; }); } function drawGauge(canvas, score) { const ctx = canvas.getContext("2d"); ctx.clearRect(0, 0, 220, 150); const cx = 110, cy = 120, R = 88; 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"; 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; } 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 angle = (i) => -Math.PI / 2 + (i * 2 * Math.PI) / keys.length; for (let ring = 1; ring <= 4; ring++) { ctx.beginPath(); keys.forEach((_, i) => { const r = (R * ring) / 4; ctx[i ? "lineTo" : "moveTo"](cx + r * Math.cos(angle(i)), cy + r * Math.sin(angle(i))); }); ctx.closePath(); ctx.strokeStyle = "#e5e6eb"; ctx.stroke(); } keys.forEach((_, i) => { ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + R * Math.cos(angle(i)), cy + R * Math.sin(angle(i))); ctx.strokeStyle = "#f0f0f0"; ctx.stroke(); }); ctx.beginPath(); keys.forEach((k, i) => { const r = (R * (dims[k] || 0)) / 100; 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(); keys.forEach((k, i) => { const r = (R * (dims[k] || 0)) / 100; ctx.fillStyle = "#2f54eb"; 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)}`, cx + (R + 36) * Math.cos(angle(i)), cy + (R + 22) * Math.sin(angle(i)) + 4); }); } /* ================= 批量匹配 ================= */ const batchState = { files: [], result: null }; /* 子页签切换 */ $$(".match-tab").forEach((b) => { b.onclick = () => { $$(".match-tab").forEach((x) => x.classList.toggle("active", x === b)); $("#match-single").classList.toggle("hidden", b.dataset.mtab !== "single"); $("#match-batch").classList.toggle("hidden", b.dataset.mtab !== "batch"); }; }); const _BATCH_EXTS = ["txt", "md", "docx", "pdf"]; function batchAddFiles(fileList) { let added = 0; for (const f of fileList) { const ext = (f.name.split(".").pop() || "").toLowerCase(); if (!_BATCH_EXTS.includes(ext)) continue; if (batchState.files.length >= 50) { toast("单批最多 50 份,超出部分已忽略", "error"); break; } if (!batchState.files.some((x) => x.name === f.name && x.size === f.size)) { batchState.files.push(f); added++; } } if (added) renderBatchFileList(); if (!added && fileList.length) toast("没有可添加的简历文件(支持 .txt/.docx/.pdf)", "error"); } function renderBatchFileList() { const box = $("#batch-filelist"); box.innerHTML = batchState.files.length ? batchState.files.map((f, i) => ` ${esc(f.name)}${(f.size / 1024).toFixed(0)}KB `).join("") : ""; box.querySelectorAll("button").forEach((b) => { b.onclick = () => { batchState.files.splice(+b.dataset.i, 1); renderBatchFileList(); }; }); } $("#batch-files").addEventListener("change", (e) => { batchAddFiles(e.target.files); e.target.value = ""; }); $("#batch-dir").addEventListener("change", (e) => { batchAddFiles(e.target.files); e.target.value = ""; }); /* 拖拽:文件直接收,文件夹递归遍历 */ const drop = $("#batch-drop"); ["dragenter", "dragover"].forEach((ev) => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add("over"); })); ["dragleave", "drop"].forEach((ev) => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove("over"); })); drop.addEventListener("drop", async (e) => { const items = Array.from(e.dataTransfer.items || []); const entries = items.map((it) => it.webkitGetAsEntry && it.webkitGetAsEntry()).filter(Boolean); if (!entries.length) { batchAddFiles(e.dataTransfer.files); return; } const collected = []; async function walk(entry) { if (entry.isFile) { await new Promise((res) => entry.file((f) => { collected.push(f); res(); })); } else if (entry.isDirectory) { const reader = entry.createReader(); let batch; do { batch = await new Promise((res) => reader.readEntries(res)); for (const ent of batch) await walk(ent); } while (batch.length); } } for (const ent of entries) await walk(ent); batchAddFiles(collected); }); $("#btn-batch-run").onclick = async () => { if (!batchState.files.length) return toast("请先添加简历文件", "error"); const box = $("#batch-result"); box.innerHTML = `
批量解析与匹配中(${batchState.files.length} 份)…
`; try { const fd = new FormData(); fd.append("job_id", $("#batch-job-select").value); batchState.files.forEach((f) => fd.append("files", f, f.name)); const resp = await fetch("/api/match/batch", { method: "POST", body: fd, headers: { "X-Access-Token": localStorage.getItem("cn_access_token") || "" }, }); const body = await resp.json(); if (!resp.ok) throw new Error(body.detail || `HTTP ${resp.status}`); batchState.result = body.data; renderBatchResult(body.data); } catch (e2) { box.innerHTML = `
批量匹配失败:${esc(e2.message)}
`; } }; function renderBatchResult(data) { $("#btn-batch-export").classList.remove("hidden"); const rows = data.results.map((r, i) => { if (r.error) { return `${i + 1}${esc(r.filename)} ${esc(r.error)}`; } const pct = Math.round(r.total_score); return ` ${i + 1} ${esc(r.name)}
${esc(r.filename)}
${r.total_score}
${r.years || 0} 年 ${(r.matched || []).slice(0, 4).map(esc).join("、") || "—"} ${(r.missing || []).slice(0, 4).map(esc).join("、") || "—"} ${r.is_match ? '达标' : '未达标'} `; }).join(""); $("#batch-result").innerHTML = `
共 ${data.total} 份,成功匹配 ${data.ok} 份,目标岗位:${esc(data.job_title)}(按总分降序)
${rows}
#候选人总分年限命中技能关键缺口结论
`; } $("#btn-batch-export").onclick = () => { const data = batchState.result; if (!data) return; const head = ["排名", "文件名", "姓名", "总分", "工作年限", "技能数", "必备技能分", "加分技能分", "经验分", "场景分", "命中技能", "缺失技能", "是否达标", "备注"]; const lines = [head.join(",")]; data.results.forEach((r, i) => { const cell = (v) => `"${String(v ?? "").replace(/"/g, '""')}"`; if (r.error) { lines.push([i + 1, cell(r.filename), "", "", "", "", "", "", "", "", "", "", "", cell(r.error)].join(",")); } else { const d = r.dimension_scores || {}; lines.push([i + 1, cell(r.filename), cell(r.name), r.total_score, r.years || 0, r.skills_count || 0, d.required ?? 0, d.preferred ?? 0, d.experience ?? 0, d.scenario ?? 0, cell((r.matched || []).join("、")), cell((r.missing || []).join("、")), r.is_match ? "达标" : "未达标", ""].join(",")); } }); const blob = new Blob(["" + lines.join("\r\n")], { type: "text/csv;charset=utf-8" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = `批量匹配结果_${data.job_title}_${new Date().toISOString().slice(0, 10)}.csv`; a.click(); URL.revokeObjectURL(a.href); toast("CSV 已导出", "success"); }; /* ================= 学习路径 ================= */ const RESOURCE_TYPES = ["官方文档", "实战项目", "在线课程", "技术社区"]; let lastPathData = null; const pathState = { jobId: null, known: new Set(), weeks: 8, done: new Set(), required: [], preferred: [] }; function pathDoneKey(jobId) { return `cn_path_done_${jobId}`; } async function loadPathJob(jobId, presetKnown) { if (!jobId) return; pathState.jobId = jobId; pathState.known = new Set(presetKnown || []); try { pathState.done = new Set(JSON.parse(localStorage.getItem(pathDoneKey(jobId)) || "[]")); } catch { pathState.done = new Set(); } const pool = $("#path-skill-pool"); pool.innerHTML = `
加载技能清单…
`; try { const d = await api(`/api/jobs/${encodeURIComponent(jobId)}`); const def = d.definition || d; pathState.required = def.required_skills || []; pathState.preferred = def.preferred_skills || []; renderSkillPool(); updatePathSummary(); } catch (e) { pool.innerHTML = `
加载失败:${esc(e.message)}
`; } } function renderSkillPool() { const pool = $("#path-skill-pool"); const group = (title, skills) => `
${title}(${skills.length})
${skills.map((s) => `${esc(s)}`).join("")}
`; pool.innerHTML = group("必备技能", pathState.required) + group("加分技能", pathState.preferred); pool.querySelectorAll(".pool-chip").forEach((el) => { el.onclick = () => { const s = el.dataset.skill; if (pathState.known.has(s)) pathState.known.delete(s); else pathState.known.add(s); el.classList.toggle("known"); updatePathSummary(); }; }); } function updatePathSummary() { const req = pathState.required; const knownReq = req.filter((s) => pathState.known.has(s)); const knownAll = [...pathState.known].filter((s) => req.includes(s) || pathState.preferred.includes(s)); const cov = req.length ? Math.round((knownReq.length / req.length) * 100) : 0; $("#ps-known").textContent = `${knownAll.length} 项`; $("#ps-gap").textContent = `${req.length - knownReq.length} 项必备`; $("#ps-bar").style.width = cov + "%"; $("#ps-tip").textContent = `必备技能覆盖率 ${cov}%`; } $("#path-weeks").querySelectorAll(".seg-item").forEach((b) => { b.onclick = () => { $("#path-weeks").querySelectorAll(".seg-item").forEach((x) => x.classList.remove("active")); b.classList.add("active"); pathState.weeks = parseInt(b.dataset.weeks, 10); }; }); $("#path-job-select").addEventListener("change", () => loadPathJob($("#path-job-select").value)); $("#btn-path").onclick = async () => { const jobId = $("#path-job-select").value; if (jobId !== pathState.jobId) await loadPathJob(jobId); try { const data = await post("/api/learning-path", { job_id: jobId, known_skills: [...pathState.known] }); lastPathData = data; renderPath(data); } catch (e) { toast(e.message, "error"); } }; function stageWeeks(n) { const base = Math.max(1, Math.round(pathState.weeks / Math.max(1, n))); return Array.from({ length: n }, () => base); } 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; $("#path-overview").innerHTML = `
目标岗位${esc(lastPathData.title)}
待补技能${gaps} 项
阶段${n} 个
预计周期${pathState.weeks} 周
学习记录:已完成 ${done}/${n} 阶段(${pct}%)
`; } function renderPath(data) { const box = $("#path-result"); $("#btn-export-path").classList.toggle("hidden", !data.learning_path.length); $("#path-overview").classList.toggle("hidden", !data.learning_path.length); if (!data.learning_path.length) { box.innerHTML = `
✓ 已覆盖「${esc(data.title)}」全部必备技能,无需补课
`; return; } const weeks = stageWeeks(data.learning_path.length); box.innerHTML = data.learning_path.map((st, i) => { const done = pathState.done.has(st.stage); return `
${done ? "✓" : st.stage}
阶段 ${st.stage} · ${st.skills.length} 项技能 建议 ${weeks[i]} 周
${i > 0 ? `
前置:建议完成阶段 ${st.stage - 1} 后开始(按技能前置关系拓扑排序)
` : ""}
${st.skills.map((s) => `${esc(s)}`).join("")}
资源类型:${RESOURCE_TYPES[i % RESOURCE_TYPES.length]} 技能性质:必备补齐 ${st.acceptance ? `能力类别:${esc(st.acceptance.category)}` : ""}
${st.acceptance ? `
验收标准(产出物 1 项 · 量化验收 ${st.acceptance.criteria.length} 项 · 自测 ${st.acceptance.self_check.length} 问)
产出物
${esc(st.acceptance.deliverable)}
量化验收项
    ${st.acceptance.criteria.map((c, k) => `
  • ${k + 1}${esc(c)}
  • `).join("")}
自测问答(能口头讲清才算通过)
    ${st.acceptance.self_check.map((q, k) => `
  • Q${k + 1}${esc(q)}
  • `).join("")}
` : ""}
`; }).join(""); box.querySelectorAll("[data-skill]").forEach((el) => { el.onclick = () => { switchView("graph"); if (CN.gotoNode) CN.gotoNode(el.dataset.skill); }; }); 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); localStorage.setItem(pathDoneKey(pathState.jobId), JSON.stringify([...pathState.done])); renderPath(lastPathData); }; }); updatePathOverview(); } $("#btn-export-path").onclick = () => { if (!lastPathData) return; const lines = [`CareerNexus 学习路径 · 目标岗位:${lastPathData.title}`, ""]; lastPathData.learning_path.forEach((st) => { lines.push(`阶段 ${st.stage}:${st.skills.join("、")}`); if (st.acceptance) { lines.push(` 产出物:${st.acceptance.deliverable}`); lines.push(` 量化验收:`); st.acceptance.criteria.forEach((c, k) => lines.push(` ${k + 1}. ${c}`)); lines.push(` 自测问答:`); st.acceptance.self_check.forEach((q, k) => lines.push(` Q${k + 1}. ${q}`)); } }); const blob = new Blob([lines.join("\n")], { type: "text/plain;charset=utf-8" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = `学习路径_${lastPathData.title}.txt`; a.click(); URL.revokeObjectURL(a.href); toast("已导出文本", "success"); }; /* ================= 系统设置 / 状态横幅 / 演示模式 ================= */ let lastHealth = null; // 最近一次 health 响应(横幅与状态徽标的数据源) function updateBanners(health) { if (health) lastHealth = health; const h = lastHealth || {}; const demo = h.demo_mode !== false; const embed = h.embed || {}; const degraded = !!h.offline_fallback || !embed.ready; // 演示模式:警告横幅降级为顶栏状态徽标(首屏不出现负面字眼) const showOffline = !demo && !!h.offline_fallback && sessionStorage.getItem("cn_banner_dismissed") !== "1"; const showEmbed = !demo && !embed.ready && sessionStorage.getItem("cn_banner2_dismissed") !== "1"; $("#offline-banner").classList.toggle("hidden", !showOffline); $("#embed-banner").classList.toggle("hidden", !showEmbed); const n = (showOffline ? 1 : 0) + (showEmbed ? 1 : 0); document.body.classList.toggle("has-banner", n === 1); document.body.classList.toggle("has-banner2", n === 2); // 顶栏状态徽标:绿=全就绪,黄=离线回退/语义未就绪 const badge = $("#demo-badge"); badge.classList.toggle("warn", degraded); $("#demo-badge-text").textContent = degraded ? "离线演示" : (demo ? "演示模式" : "在线"); badge.title = degraded ? "离线回退中(LLM/语义模型未配置),点击查看设置" : "全部通道就绪,点击查看设置"; } function dismissBanner(id, key) { sessionStorage.setItem(key, "1"); updateBanners(); } $("#banner-close").onclick = () => dismissBanner("offline-banner", "cn_banner_dismissed"); $("#embed-banner-close").onclick = () => dismissBanner("embed-banner", "cn_banner2_dismissed"); $("#banner-config").onclick = () => openConfigModal(false); $("#embed-banner-config").onclick = () => openConfigModal(false); $("#demo-badge").onclick = () => openConfigModal(false); /* ================= 演示导览(预置路线,一键串起完整闭环) ================= */ const DEMO_STEPS = [ { title: "① 数据驾驶舱:多源数据总览", desc: "JD 规模、岗位/技能/事件总量、演化趋势、清洗与审核状态", go: () => switchView("dashboard") }, { title: "② 新岗位发现:候选→审核→入图", desc: "图谱定位「大模型应用工程师」:四指标证据、人工确认、证据 JD 回查", go: () => { switchView("graph"); if (CN.gotoNode) CN.gotoNode("job_大模型应用工程师"); } }, { title: "③ 既有岗位演化:Java 编年史", desc: "时点对比、变更堆叠图、生命周期甘特、事件证据链", go: () => { switchView("evolution"); $("#evo-job-select").value = "job_Java开发工程师"; $("#evo-job-select").dispatchEvent(new Event("change")); } }, { title: "④ 人岗匹配诊断 + 学习路径", desc: "载入示例简历 → 确认解析 → 总分/四维/差距清单 → 一键生成学习路径", go: () => { switchView("match"); $$(".match-tab").forEach((x) => x.classList.toggle("active", x.dataset.mtab === "single")); $("#match-single").classList.remove("hidden"); $("#match-batch").classList.add("hidden"); $("#btn-load-sample").click(); } }, ]; $("#btn-demo-tour").onclick = () => { openModal("演示导览(预置路线)", `
按预置路线依次演示完整闭环,每步点击「前往」自动跳转到位:
${DEMO_STEPS.map((s, i) => `
${esc(s.title)}
${esc(s.desc)}
`).join("")}`); $$("#modal-body [data-step]").forEach((b) => { b.onclick = () => { closeModal(); DEMO_STEPS[+b.dataset.step].go(); }; }); }; function embedStatusHtml(emb) { if (emb.ready) { return `已就绪(${emb.provider === "api" ? "联网 API" : "本地权重"},语义召回已启用)`; } if (emb.provider === "api") { return emb.api_key_masked ? `已配置 Key,保存后生效` : `未配置 API Key(当前使用词表归一化匹配)`; } if (emb.configured) { const reasons = [ !emb.weights_present ? "权重文件缺失" : "", !emb.deps_installed ? "未安装 sentence-transformers" : "", ].filter(Boolean).join(";"); return `已配置未就绪:${esc(reasons)}`; } return `未配置(当前使用词表归一化匹配)`; } async function openConfigModal(needToken, firstRun) { let cfg; try { cfg = await api("/api/config"); } catch (e) { toast(e.message, "error"); return; } $("#modal-box").classList.add("wide"); const modeBadge = cfg.offline_fallback ? `离线回退` : `联网就绪`; openModal(firstRun ? "欢迎使用 CareerNexus 职联 · 初始设置" : "系统设置", ` ${firstRun ? `
首次使用,建议先完成基础配置(均可稍后通过右上角 ⚙ 修改):
· 配置 LLM API 密钥可启用大模型抽取(不配则离线确定性方案兜底)
· 配置语义向量模型(本地 BGE-M3 或联网 API)可启用语义相似匹配
· 需要对外提供服务时,再调整监听地址与访问令牌
` : ""}
当前监听 ${esc(cfg.running.host)}:${cfg.running.port} LLM 状态 ${modeBadge} ${cfg.restart_required ? `监听变更待重启` : ""}
${needToken ? `
该服务已启用访问令牌,请填写后保存。
` : ""}
服务监听(修改后需重启生效)
LLM API(星火 / OpenAI 兼容,保存即热生效)
语义向量匹配(可选增强,保存即热生效)
${embedStatusHtml(cfg.embed)}
自动完成全部配置,推荐首次使用:
① 安装 sentence-transformers 依赖(PyPI 镜像站)
② 从 ModelScope 镜像站下载 BGE-M3 权重(约 2.2GB)
③ 自动写入配置并启用语义匹配。全程约 3–8 分钟,期间可继续使用其他功能。
手动配置方式(不使用一键配置时)
  1. 安装依赖:pip install sentence-transformers
  2. 下载权重(约 2.2GB):ModelScope(国内推荐)modelscope.cn/models/BAAI/bge-m3 或 HuggingFace huggingface.co/BAAI/bge-m3
  3. 目录内需包含 config.json、model.safetensors 与分词器文件,填入上方路径保存即可
联网 API 配置引导
  1. 开通阿里云百炼(DashScope),在控制台获取 API Key
  2. 默认接口为 OpenAI 兼容模式,模型推荐 text-embedding-v4
  3. 填入 Key 保存即生效;网络不可达时自动回退词表归一化
如遇问题,请联系 support@rrrosmontis.icu
`, `${firstRun ? `` : ""} `); let host = cfg.saved.host; $$("#cfg-host .seg-item").forEach((b) => { b.onclick = () => { $$("#cfg-host .seg-item").forEach((x) => x.classList.remove("active")); b.classList.add("active"); host = b.dataset.host; }; }); let embedProvider = cfg.embed.provider === "api" ? "api" : "local"; let demoMode = cfg.demo_mode !== false; $$("#cfg-demo .seg-item").forEach((b) => { b.onclick = () => { $$("#cfg-demo .seg-item").forEach((x) => x.classList.remove("active")); b.classList.add("active"); demoMode = b.dataset.demo === "1"; }; }); $$("#cfg-embed-provider .seg-item").forEach((b) => { b.onclick = () => { $$("#cfg-embed-provider .seg-item").forEach((x) => x.classList.remove("active")); b.classList.add("active"); embedProvider = b.dataset.provider; $("#cfg-embed-local").classList.toggle("hidden", embedProvider !== "local"); $("#cfg-embed-api").classList.toggle("hidden", embedProvider !== "api"); }; }); let tokenDirty = false; // 只有用户真正改过令牌框才提交(空串也提交 = 清除) $("#cfg-token").addEventListener("input", () => { tokenDirty = true; }); $("#cfg-token-gen").onclick = () => { const t = Array.from(crypto.getRandomValues(new Uint8Array(16))).map((x) => x.toString(16).padStart(2, "0")).join(""); $("#cfg-token").value = t; $("#cfg-token").type = "text"; tokenDirty = true; }; $("#cfg-token-show").onclick = () => { const i = $("#cfg-token"); i.type = i.type === "password" ? "text" : "password"; }; async function doSave() { const payload = { host, port: parseInt($("#cfg-port").value, 10), llm_base_url: $("#cfg-llm-url").value.trim(), llm_model: $("#cfg-llm-model").value.trim(), embed_provider: embedProvider, embed_model_path: $("#cfg-embed-path").value.trim(), embed_api_base_url: $("#cfg-embed-base").value.trim(), embed_api_model: $("#cfg-embed-model").value.trim(), demo_mode: demoMode, guided: true, // 保存即视为完成初始设置 }; const tok = $("#cfg-token").value; if (tokenDirty) payload.access_token = tok.trim(); // dirty 才提交;空串 = 清除令牌 const key = $("#cfg-llm-key").value; if (key) payload.llm_api_key = key.trim(); const ekey = $("#cfg-embed-key").value; if (ekey) payload.embed_api_key = ekey.trim(); const saved = await post("/api/config", payload); if (tokenDirty) { // 保存成功后同步 localStorage,未动过令牌框则不触碰 if (payload.access_token) localStorage.setItem("cn_access_token", payload.access_token); else localStorage.removeItem("cn_access_token"); } lastHealth = Object.assign({}, lastHealth, { offline_fallback: saved.offline_fallback, embed: saved.embed, demo_mode: saved.demo_mode, }); updateBanners(); return saved; } $("#cfg-save").onclick = async () => { try { const saved = await doSave(); closeModal(); toast(saved.restart_required ? "已保存;监听地址/端口需重启后生效" : "设置已保存并热生效", "success"); } catch (e) { toast(e.message, "error"); } }; $("#cfg-save-restart").onclick = async () => { try { await doSave(); await post("/api/config/restart", {}); closeModal(); toast("服务重启中,5 秒后自动刷新…", "info"); setTimeout(() => location.reload(), 5000); } catch (e) { toast(e.message, "error"); } }; if (firstRun) { $("#cfg-skip").onclick = async () => { try { await post("/api/config", { guided: true }); } catch { /* 跳过标记失败不阻塞 */ } closeModal(); toast("已跳过初始设置,当前为离线演示模式", "info"); }; } /* 一键下载并配置语义环境(异步任务 + 轮询进度) */ const STAGE_NAMES = { deps: "① 安装 Python 依赖", download: "② 下载 BGE-M3 权重", config: "③ 写入配置", done: "✓ 配置完成", start: "准备中…", error: "✗ 失败" }; $("#cfg-embed-setup").onclick = async () => { const btn = $("#cfg-embed-setup"); btn.disabled = true; $("#setup-progress").classList.remove("hidden"); try { await post("/api/semantic/setup", {}); } catch (e) { toast(e.message, "error"); btn.disabled = false; return; } const timer = setInterval(async () => { try { const st = await api("/api/semantic/setup/status"); $("#setup-stage").textContent = STAGE_NAMES[st.stage] || st.stage || "进行中…"; $("#setup-log").textContent = (st.log || []).slice(-3).join("\n"); if (!st.running) { clearInterval(timer); btn.disabled = false; if (st.done || (st.embed && st.embed.ready)) { toast("语义向量通道已就绪", "success"); closeModal(); openConfigModal(false); // 重新打开刷新状态 lastHealth = Object.assign({}, lastHealth, { embed: st.embed }); updateBanners(); } else { $("#setup-stage").textContent = "✗ 配置失败:" + (st.error || "未知原因"); toast("语义环境配置失败:" + (st.error || ""), "error"); } } } catch { /* 轮询失败继续等 */ } }, 2000); }; } $("#btn-config").onclick = () => openConfigModal(false); /* ================= 启动 ================= */ window.addEventListener("resize", () => { if (CN._trend) drawTrend($("#trend-canvas"), CN._trend); if (CN._heat) drawHeatLine($("#evo-heat-canvas"), CN._heat); }); (async function boot() { try { CN.jobs = await api("/api/jobs"); const opts = CN.jobs.map((j) => ``).join(""); ["#evo-job-select", "#match-job-select", "#path-job-select", "#batch-job-select"].forEach((s) => { $(s).innerHTML = opts; }); $("#evo-job-select").value = "job_Java开发工程师"; // 演化页默认锁定最强样例 $("#match-job-select").value = "job_Java开发工程师"; await loadDashboard(); // 先加载图谱缓存(演化页甘特图依赖) await loadEvolution(); renderEvoHeatmap(); /* 分享链接:#view=graph&node=xxx */ const hash = new URLSearchParams(location.hash.slice(1)); if (hash.get("view")) switchView(hash.get("view")); if (hash.get("node") && CN.gotoNode) CN.gotoNode(hash.get("node")); /* 首次使用:演示模式下不弹初始设置(评审口径),非演示模式才引导 */ if ((!lastHealth || !lastHealth.guided) && lastHealth && lastHealth.demo_mode === false) { openConfigModal(false, true); } } catch (e) { toast("后端连接失败:" + e.message, "error"); } })();