From ef9fe9faa300abb985d8794b02cc2759cefc16ee Mon Sep 17 00:00:00 2001 From: RoyirinRosmontis Date: Wed, 19 Aug 2026 15:48:11 +0800 Subject: [PATCH] =?UTF-8?q?=E6=BC=94=E5=8C=96=E9=A1=B5=E5=8E=BB=E5=8F=AF?= =?UTF-8?q?=E8=A7=86=E5=8C=96=E7=82=AB=E6=8A=80=EF=BC=9A=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E7=94=9F=E5=91=BD=E7=BA=BF=E6=B3=B3=E9=81=93?= =?UTF-8?q?=E5=9B=BE=EF=BC=8C=E5=85=A8=E5=B2=97=E4=BD=8D=E6=89=AB=E6=8F=8F?= =?UTF-8?q?=E7=83=AD=E5=8A=9B=E5=9B=BE=E6=94=B9=E4=B8=BA=E6=A8=AA=E6=9D=A1?= =?UTF-8?q?=E6=8E=92=E8=A1=8C=EF=BC=88=E8=B4=9F=E8=B4=A3=E4=BA=BA=E5=8F=8D?= =?UTF-8?q?=E9=A6=88=EF=BC=9A=E4=B8=8D=E7=9B=B4=E8=A7=82=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- webui/app.js | 4601 +++++++++++++++++++++++----------------------- webui/index.html | 13 +- webui/style.css | 12 + 3 files changed, 2281 insertions(+), 2345 deletions(-) diff --git a/webui/app.js b/webui/app.js index a043d44..21cedae 100644 --- a/webui/app.js +++ b/webui/app.js @@ -1,2336 +1,2265 @@ -/* CareerNexus 职联 前端主脚本:外壳 + 驾驶舱 + 演化 + 匹配 + 学习路径。 - * 全部数据走 /api,完全离线(无外部依赖)。图谱工作台见 graph.js。 */ -"use strict"; - -/* ================= 公共工具 ================= */ -const $ = (s) => document.querySelector(s); -const $$ = (s) => Array.from(document.querySelectorAll(s)); - -/* 网络层统一封装:网络错误一律翻译为中文业务文案, - * 演示环境禁止出现 "Failed to fetch"、接口路径、内部文件名。 */ -async function cnFetch(path, options) { - try { - return await fetch(path, options); - } catch { - throw new Error("暂时无法连接服务,请检查后端是否已启动"); - } -} - -/* 把残余的技术型报错兜底翻译为业务文案(服务端中文 detail 原样保留) */ -function friendlyErr(e, fallback = "操作失败,请稍后重试") { - const raw = (e && e.message) || ""; - if (!raw) return fallback; - if (/failed to fetch|networkerror|load failed/i.test(raw)) return "暂时无法连接服务,请检查后端是否已启动"; - if (/\.jsonl|\.py|traceback/i.test(raw)) return fallback; // 内部文件名不外显 - return raw.replace(/HTTP (\d{3})/g, "服务响应异常($1)"); -} - -async function api(path, options) { - options = options || {}; - options.headers = Object.assign( - { "X-Access-Token": localStorage.getItem("cn_access_token") || "" }, - options.headers); - const resp = await cnFetch(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(friendlyErr({ message: 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 原文缓存(证据弹窗与来源列表共用,失败不缓存以允许重试) */ -const _jdCache = new Map(); -async function fetchJd(sid) { - if (_jdCache.has(sid)) return _jdCache.get(sid); - const p = api(`/api/jd?sid=${encodeURIComponent(sid)}`); - _jdCache.set(sid, p); - try { return await p; } catch (e) { _jdCache.delete(sid); throw e; } -} - -/* 来源列表行内联元信息:填充为「企业 · 平台 · 日期」可读文案(不显示内部 source_id) */ -CN.jdMetaLine = async function (el, sid) { - try { - const r = await fetchJd(sid); - el.textContent = `${r.company || "企业未披露"} · ${r.source || "公开招聘平台"} · ${r.publish_date || "时间未知"}`; - } catch { - el.textContent = "证据详情暂时读取失败,点击可重试查看"; - } -}; - -/* JD 证据弹窗(演化事件 / 图谱来源共用) - * sids:单条或数组(数组时支持上一条/下一条翻页) - * ctx:{ skill, jobTitle, changeType, window } 用于生成可读结论与命中依据 */ -async function showJdEvidence(sids, ctx = {}) { - const list = (Array.isArray(sids) ? sids : [sids]).filter(Boolean); - if (!list.length) return toast("该条目暂无关联的公开 JD 证据", "info"); - let idx = 0; - const conclusion = ctx.skill - ? `${ctx.skill}${ctx.changeType ? ` · ${ctx.changeType}` : ""}${ctx.jobTitle ? ` — ${ctx.jobTitle}` : ""}` - : ""; - async function render() { - const sid = list[idx]; - const foot = ` - ${list.length > 1 ? ` - - ${idx + 1} / ${list.length} - ` : ""} - `; - openModal("JD 原文证据", `
证据加载中…
`, foot); - $("#ev-close").onclick = closeModal; - if (list.length > 1) { - $("#ev-prev").onclick = () => { idx -= 1; render(); }; - $("#ev-next").onclick = () => { idx += 1; render(); }; - } - try { - const rec = await fetchJd(sid); - $("#modal-body").innerHTML = ` - ${conclusion ? `
${esc(conclusion)}${ctx.window ? `(${esc(ctx.window)})` : ""}
` : ""} -
- ${esc(rec.job_title)}${esc(rec.company || "企业未披露")} - ${esc(rec.city || "—")}${esc(rec.salary || "—")} -
-
来源:${esc(rec.source || "公开招聘平台")} · ${esc(rec.company || "企业未披露")} · 发布时间:${esc(rec.publish_date || "未知")}
-
${esc(rec.text)}
- ${ctx.skill ? `
命中依据:该 JD 对「${esc(ctx.skill)}」提出明确能力要求${ctx.changeType ? `(${esc(ctx.changeType)})` : ""}
` : ""} -
${esc(sid)}
`; - } catch (e) { - const gone = /不存在|非法|格式/.test((e && e.message) || ""); - $("#modal-body").innerHTML = ` -
-

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

- -
`; - $("#ev-retry").onclick = render; - } - } - await render(); -} -CN.showJdEvidence = showJdEvidence; -CN.fetchJd = fetchJd; - -/* ================= 视图切换 / 侧栏 ================= */ -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).then(() => renderDemoPath()); // 未诊断时展示示范路线 - } -} -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"); - }); -})(); - -/* ================= 人才能力变化指挥台(数据驾驶舱) ================= */ -/* 首屏 KPI:新岗位候选 / 关键技能增强 / 待人工复核 / 影响岗位(图谱规模降级为一行小字) */ -const METRIC_DEFS = [ - { key: "candidates", label: "新岗位候选", act: "desk", sub: "聚类发现,待人工决策", icon: '' }, - { key: "boosted", label: "关键技能增强", act: "evolution", sub: "近 2 季度新增+增强事件", icon: '' }, - { key: "review", label: "待人工复核", act: "desk", sub: "候选岗位 + 候选技能关系", icon: '' }, - { key: "affected", label: "影响岗位", act: "evolution", sub: "近 2 季度发生变化的岗位", icon: '' }, -]; - -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; - CN._pendingCache = pending; - const s = health.graph || {}; - updateBanners(health); - const events = graph.events || []; - const lastWin = (events.at(-1) || {}).window || "2026Q3"; - $("#data-version").textContent = `数据截止:${lastWin} · 模拟数据`; - - /* 本期口径:最近 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) => ` -
-
${m.icon}${m.label}
-
${values[m.key] ?? "—"}
- ${m.sub} -
`).join(""); - $$("#metric-cards .metric-card").forEach((el) => { - el.onclick = () => (el.dataset.act === "desk" ? openReviewDesk() : switchView("evolution")); - }); - $("#pending-count").textContent = `${pending.length} 待办`; - - /* 主图:近 8 季度变化事件流(全岗位口径堆叠柱,点击柱段跳演化页) */ - CN._flowEvents = events; - drawEventFlow($("#trend-canvas"), events, 8); - - renderTrust(); - renderTaskTable(audit); - $("#btn-import-jd").onclick = () => openImportDialog(); - $("#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]) => ` -
${k}${v} - ${esc(sub)}
`).join(""); - } catch (e) { - box.innerHTML = `
-

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

-
`; - $("#trust-retry").onclick = renderTrust; - } -} - -/* 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) { - 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 `
- - -
-
${esc(e.skill)} ${verb[e.change_type] || esc(e.change_type)}
-
影响岗位:${esc(jobTitle(e.job_id))} · 证据 ${(e.source_ids || []).length} 条 JD · ${esc(e.window)}
-
- ${esc(e.change_type)} -
`; - }).join("") : `
暂无变化播报
`; - $$("#activity-feed .feed-item").forEach((el) => { - el.onclick = () => { - 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 = `
暂无待审核候选(可在图谱页触发新岗位发现)
`; - return; - } - box.innerHTML = pending.slice(0, 3).map((c) => { - const t = candidateTrust(c, CN.jobs); - const ready = ["高", "较高"].includes(t.level); - return `
-
${esc(c.title)} - ${ready ? "建议批准入图" : "建议继续观察"}
-
结论:四指标达标 ${t.okCount}/4(可信等级 ${t.level}) · 处理人:待分配
-
证据强度:${c.source_count} 条 JD · ${c.company_count} 家企业 · ${(c.support_windows || []).length} 个季度跨度
-
- - - -
-
`; - }).join("") + (pending.length > 3 - ? `` : ""); - box.querySelectorAll("[data-act]").forEach((b) => { - b.onclick = () => doReview(b.dataset.id, b.dataset.act); - }); - 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) { - 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 (!$("#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 = `
-

候选数据暂时加载失败:${esc(friendlyErr(e))}

-
`; - $("#desk-retry").onclick = () => renderReviewDesk(); - return; - } - if (!pending.length) { - box.innerHTML = `
暂无待审核候选(可在图谱页触发新岗位发现)
`; - 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]}` : "—"; - const resp = c.responsibilities || []; - const summary = resp[0] - || `面向${(c.industries || []).join("/") || "新兴"}领域的「${c.title}」岗位(职责描述待人工补充)`; - return `
-
候选:${esc(c.title)} - 可信等级:${t.level}
-
职责摘要:${esc(summary)}
- ${resp.length > 1 ? `
核心职责:${resp.slice(0, 5).map((d) => `${esc(d.replace(/;\s*$/, ""))}`).join(" ")}
` : ""} -
应用场景:${(c.scenarios || []).length - ? c.scenarios.map((s) => `${esc(s)}`).join(" ") - : "—"}
-
${t.dots.map((d) => - ``).join("")} - ${t.okCount}/4 指标达标(JD / 企业 / 增长 / 差异)
-
多源证据:${c.source_count} JD · ${c.company_count} 企业 · 时间跨度 ${esc(span)}
-
与相近岗位:${t.nearest.title - ? `「${esc(t.nearest.title)}」技能重合 ${(t.nearest.sim * 100).toFixed(0)}%,职责差异度 ${t.distinctness.toFixed(2)}` - : "暂无可比较岗位"}
-
必备:${(c.required_skills || []).slice(0, 6).map((s) => `${esc(s)}`).join(" ") || "—"} - ${(c.preferred_skills || []).length ? `
加分:${c.preferred_skills.slice(0, 4).map((s) => `${esc(s)}`).join(" ")}` : ""}
-
- - - - -
-
`; - }).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) => `${esc(s)}`).join(" ") || "—"; - openModal(`相近岗位比较:${esc(c.title)} vs ${esc(near.title)}`, ` -
技能重合 ${(t.nearest.sim * 100).toFixed(0)}% · 职责差异度 ${t.distinctness.toFixed(2)}(≥0.40 视为新岗位)
-
-
候选「${esc(c.title)}」必备
-
${chips(c.required_skills, "tag-blue")}
-
「${esc(near.title)}」必备
-
${chips(near.required_skills, "tag-green")}
-
候选独有技能
-
${chips((c.required_skills || []).filter((s) => !(near.required_skills || []).includes(s)), "tag-orange")}
-
`); - }; - }); - 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; - 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 }; -} - -/* 变化事件流:近 N 季度全岗位口径 新增/增强/减弱/退出 堆叠柱,点击柱段跳演化页对应岗位 */ -function drawEventFlow(canvas, events, n) { - const { ctx, W, H } = fitCanvas(canvas, 220); - ctx.clearRect(0, 0, W, H); - $("#cmd-stack-legend").innerHTML = Object.entries(CHANGE_META).map(([t, m]) => - `${t}`).join(""); - const windows = [...new Set(events.map((e) => e.window))].sort().slice(-n); - if (!windows.length) return; - const types = Object.keys(CHANGE_META); - const counts = windows.map((w) => { - const row = {}; - types.forEach((t) => { row[t] = events.filter((e) => e.window === w && e.change_type === t).length; }); - return row; - }); - const maxTotal = Math.max(1, ...counts.map((r) => types.reduce((a, t) => a + r[t], 0))); - const bw = W / windows.length; - const baseY = H - 22; - ctx.font = "10px sans-serif"; ctx.textAlign = "center"; - windows.forEach((w, i) => { - let y = baseY; - types.forEach((t) => { - const v = counts[i][t]; - if (!v) return; - const h = (v / maxTotal) * (H - 52); - ctx.fillStyle = CHANGE_META[t].color; - ctx.fillRect(i * bw + bw * 0.2, y - h, bw * 0.6, h); - y -= h; - }); - const total = types.reduce((a, t) => a + counts[i][t], 0); - if (total) { ctx.fillStyle = "#4e5969"; ctx.fillText(total, i * bw + bw / 2, y - 4); } - 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: "tag-cyan", color: "#7bc8a4" }, - "增强": { tag: "tag-purple", color: "#8f9ff0" }, - "减弱": { tag: "tag-orange", color: "#e8c07d" }, - "退出": { tag: "tag-red", color: "#d99a91" }, -}; -const STEADY_COLOR = "#86909c"; // 持续:中性蓝灰 -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().slice(-12); // 全岗位扫描仅近 12 季度 - // 统计每个岗位每季度事件数与类型分布 - 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; - /* 点击行 → 转入该岗位报告(默认 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")}`; } - return w; -} - -async function loadEvolution() { - const id = $("#evo-job-select").value; - if (!id) return; - try { - const data = await api(`/api/jobs/${encodeURIComponent(id)}/evolution`); - evoEvents = data.events; - } catch (e) { - evoEvents = []; - $("#evo-timeline").innerHTML = `
-

演化数据暂时加载失败:${esc(friendlyErr(e))}

-
`; - $("#evo-retry").onclick = loadEvolution; - return; - } - renderEvoStats(id); - renderEvoSummary(); - renderEvolution(); - runEvoCompare(); // 默认对比:最早事件前 → 今天 -} - -/* 本期结论:最近 2 个季度的新增/增强/减弱/退出清单(业务化文案,避免机器词) */ -function renderEvoSummary() { - const box = $("#evo-summary"); - if (!evoEvents.length) { - box.innerHTML = `
该岗位暂无演化事件,暂无本期结论
`; - return; - } - const wins = [...new Set(evoEvents.map((e) => e.window))].sort().slice(-2); - const order = ["新增", "增强", "减弱", "退出"]; - box.innerHTML = wins.map((w) => { - const evs = evoEvents.filter((e) => e.window === w); - const parts = order.map((t) => { - const list = evs.filter((e) => e.change_type === t); - if (!list.length) return ""; - const skills = list.map((e) => (t === "增强" || t === "减弱") - ? `${esc(e.skill)}(需求指数 ${e.prev_weight.toFixed(2)}→${e.curr_weight.toFixed(2)})` - : esc(e.skill)).join("、"); - const verb = { "新增": "成为新要求", "增强": "需求显著增强", "减弱": "需求明显减弱", "退出": "退出能力画像" }[t]; - return `${t}${skills} ${verb}`; - }).filter(Boolean).join(""); - return `
${esc(w)}
${parts}
`; - }).join(""); -} - -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(friendlyErr(e))}

-
`; - $("#evo-diff-retry").onclick = runEvoCompare; - } -} -$("#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(); - }; -}); - -/* 热度走势范围:默认最近 8 个季度,可切换全部 */ -let heatRange = 8; -$$("#evo-heat-range button").forEach((b) => { - b.onclick = () => { - $$("#evo-heat-range button").forEach((x) => x.classList.toggle("active", x === b)); - heatRange = b.dataset.range === "all" ? Infinity : 8; - drawHeat(); - }; -}); -function drawHeat() { - if (!CN._heat) return; - const kv = heatRange === Infinity ? CN._heat : CN._heat.slice(-heatRange); - drawHeatLine($("#evo-heat-canvas"), kv); -} - -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); - 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)); - drawHeat(); - - /* 时间轴 */ - const box = $("#evo-timeline"); - const jobTitle = (CN.jobs.find((j) => j.job_id === $("#evo-job-select").value) || {}).title || ""; - 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 || "").replace(/权重/g, "需求指数"))}
-
证据 JD ${e.source_ids.length} 条 · 点击查看原文
-
`; - }).join("") : `
该岗位暂无演化事件
`; - box.querySelectorAll(".evo-event").forEach((el) => { - el.onclick = () => { - const e = evoEvents[+el.dataset.i]; - showJdEvidence(e.source_ids || [], { - skill: e.skill, jobTitle, changeType: e.change_type, window: e.window, - }); - }; - }); -} - -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); - }); -} - -/* 能力生命线(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().slice(-8); - const edges = (g.edges || []).filter((e) => e.source === jobId && e.rel === "REQUIRES" && (e.intervals || []).length); - if (!windows.length || !edges.length) { - box.innerHTML = `
暂无该岗位的能力生命线数据
`; - return; - } - /* Top 12:当前仍生效优先,按当前需求指数降序 */ - const alive = (e) => e.intervals.some(([s, en]) => en === null); - 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 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 dot = dotType - ? `` : ""; - return `
- ${active ? `
` : ""}${dot}
`; - }; - box.innerHTML = ` -
-
- ${windows.map((w) => `
${esc(w.slice(2))}
`).join("")} -
状态
- ${rows.map((e) => { - const m = rowMeta(e); - return `
${esc(e.target)}
- ${windows.map((w) => cellHtml(e, w, m.color)).join("")} -
${m.label}
`; - }).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); }; - }); -} - -/* 能力更替流:旧核心(已退出必备) → 持续能力 → 新核心(近 4 季度新进入) */ -function renderEvoReplace(jobId) { - const box = $("#evo-replace"); - const g = CN.graphData || {}; - const wins = [...new Set((g.events || []).map((e) => e.window))].sort(); - const recent4 = new Set(wins.slice(-4)); - const edges = (g.edges || []).filter((e) => e.source === jobId && e.rel === "REQUIRES" && (e.intervals || []).length); - if (!edges.length) { - box.innerHTML = `
暂无能力更替数据
`; - return; - } - const alive = (e) => e.intervals.some(([s, en]) => en === null); - const start = (e) => e.intervals.map((i) => i[0]).sort()[0]; - const oldCore = edges.filter((e) => !alive(e)); - const cont = edges.filter((e) => alive(e) && !recent4.has(start(e))); - const newCore = edges.filter((e) => alive(e) && recent4.has(start(e))); - const chip = (e) => - `${esc(e.target)}`; - const col = (title, list, cls, note) => ` -
-
${title}(${list.length})
-
${note}
-
${list.slice(0, 10).map(chip).join("") || '无'}
-
`; - box.innerHTML = ` -
- ${col("旧核心(已退出)", oldCore, "old", "曾为核心/加分,当前已退出画像")} -
→
- ${col("持续能力", cont, "keep", "跨周期稳定生效,构成岗位基本盘")} -
→
- ${col("新核心(近 4 季度进入)", newCore, "new", "本期新增或升级的必备/加分能力")} -
`; - 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; // 最近一次解析的完整简历画像(结构化模板与导出用) - -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"); -}; - -/* 目标岗位信息卡:上传前让用户理解岗位要求(右栏 40%) */ -async function loadMatchJobInfo() { - const box = $("#match-job-info"); - if (!box) return; - const jobId = $("#match-job-select").value; - if (!jobId) { box.innerHTML = `
请先选择目标岗位
`; return; } - box.innerHTML = `
岗位信息加载中…
`; - try { - const d = await api(`/api/jobs/${encodeURIComponent(jobId)}`); - const def = d.definition || d; - const level = def.expected_years == null ? "—" : def.expected_years <= 2 ? "初级" : def.expected_years <= 5 ? "中级" : "高级"; - const chips = (list, cls, n) => (list || []).slice(0, n).map((s) => `${esc(s)}`).join(" ") || `无`; - box.innerHTML = ` -
${esc(def.title || jobId)}
-
${level} · 支撑 ${def.source_count ?? "—"} 条 JD · ${def.company_count ?? "—"} 家企业 · 数据截止 2026Q3
- ${(def.responsibilities || [])[0] ? `
${esc(def.responsibilities[0])}
` : ""} -
必备技能
${chips(def.required_skills, "tag-green", 6)}
-
加分技能
${chips(def.preferred_skills, "tag-cyan", 4)}
`; - } catch (e) { - box.innerHTML = `
-

岗位信息暂时加载失败:${esc(friendlyErr(e))}

-
`; - $("#jic-retry").onclick = loadMatchJobInfo; - } -} -$("#match-job-select").addEventListener("change", loadMatchJobInfo); - -$("#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 cnFetch("/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(friendlyErr({ message: body.detail || "" }, `服务响应异常(${resp.status})`)); - const p = body.data; - applyParsedProfile(p, `${f.name}(已脱敏)`); - toast(`已解析 ${f.name}:识别技能 ${p.skills.length} 项`, "success"); - } catch (err) { toast(friendlyErr(err, "简历解析失败,请重试"), "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; - 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, - scenarios: [], - }); - matchState.result = m; - 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 = () => { - switchView("path"); - $("#path-job-select").value = $("#match-job-select").value; - loadPathJob($("#match-job-select").value, matchState.skills).then(() => $("#btn-path").click()); -}; - -/* 总分 → 自然语言结论(答辩口径:分数只是结论的辅助) */ -function matchAdvice(score) { - if (score >= 80) return "强烈推荐投递:能力画像与目标岗位高度匹配,可优先安排面试。"; - if (score >= 60) return "建议补齐核心缺口后投递:已具备岗位基础,优先补足缺失的必备技能。"; - return "建议先系统学习再投递:当前与岗位要求差距较大,可按学习路径分阶段提升。"; -} - -function renderMatchResult(m) { - drawGauge($("#gauge-canvas"), m.total_score); - $("#match-verdict").innerHTML = m.is_match - ? `✓ 达到匹配阈值 目标岗位:${esc(m.job_title)}` - : `未达匹配阈值(60 分) 目标岗位:${esc(m.job_title)}`; - $("#match-advice").innerHTML = `诊断结论:${matchAdvice(m.total_score)}`; - 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("") || - `无命中(简历中未识别到本岗位所需技能)`; - /* 缺口技能:超过 5 项默认折叠,避免清单压垮用户 */ - const missing = [ - ...m.missing_required.map((s) => chip(s, "tag-red")), - ...m.missing_preferred.map((s) => chip(s, "tag-orange")), - ]; - const missBox = $("#missing-chips"); - if (!missing.length) { - missBox.innerHTML = `无`; - } else if (missing.length <= 5) { - missBox.innerHTML = missing.join(""); - } else { - const renderMiss = (expanded) => { - missBox.innerHTML = (expanded ? missing : missing.slice(0, 5)).join("") + - ``; - $("#miss-toggle").onclick = () => renderMiss(!expanded); - }; - renderMiss(false); - } - $("#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 cnFetch("/api/resume/export", { - method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ profile: lastProfile, match: matchState.result }), - }); - if (!resp.ok) throw new Error(`服务响应异常(${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(`导出失败:${friendlyErr(e)}`, "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 cnFetch("/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(friendlyErr({ message: body.detail || "" }, `服务响应异常(${resp.status})`)); - batchState.result = body.data; - renderBatchResult(body.data); - } catch (e2) { - box.innerHTML = `
-

批量匹配失败:${esc(friendlyErr(e2))}

-
`; - } -}; - -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(friendlyErr(e))}

-
`; - $("#path-pool-retry").onclick = () => loadPathJob(jobId); - } -} - -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", () => { - 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; - if (jobId !== pathState.jobId) await loadPathJob(jobId); - const btn = $("#btn-path"); - btn.disabled = true; - $("#path-result").innerHTML = `
正在生成学习路径:对齐岗位图谱 → 计算技能缺口 → 拆分阶段…
`; - try { - const data = await post("/api/learning-path", { job_id: jobId, known_skills: [...pathState.known] }); - lastPathData = data; - renderPath(data); - } catch (e) { - $("#path-result").innerHTML = `
-

学习路径生成失败:${esc(friendlyErr(e))}

-
`; - $("#path-retry").onclick = () => $("#btn-path").click(); - } finally { - btn.disabled = false; - } -}; - -function stageWeeks(n) { - const base = Math.max(1, Math.round(pathState.weeks / Math.max(1, 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 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 ? `
示范路线 · 完成人岗诊断后将替换为你的个人结果
` : ""} -
目标岗位${esc(lastPathData.title)}
-
当前匹配分${cur}
-
目标分60 分
-
预计周期${pathState.weeks} 周
-
-
-
学习记录:已完成 ${done}/${n} 阶段(${pct}%)· 完成阶段后可重新匹配验证
-
`; -} - -/* 未诊断时展示示范路线(当前选中岗,零已掌握),完成诊断后由个人结果替换 */ -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); - $("#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); - /* 阶段语义化命名:基础补齐 → 岗位核心(可多个)→ 项目验证 */ - const stageName = (i, n) => - i === 0 ? "基础补齐" : i === n - 1 ? "项目验证" : "岗位核心"; - box.innerHTML = (data._demo ? ` -
- 示范路线(以零已掌握技能生成):完成一次人岗诊断后,此处将自动替换为你的个人成长路线图 - -
` : "") + data.learning_path.map((st, i) => { - const done = pathState.done.has(st.stage); - return ` -
- ${done ? "✓" : st.stage} -
-
- 阶段 ${st.stage} · ${stageName(i, data.learning_path.length)}(${st.skills.length} 项技能) - 建议 ${weeks[i]} 周 -
- ${i > 0 ? `
前置:建议完成阶段 ${st.stage - 1} 后开始(按技能前置关系拓扑排序)
` : ""} -
${st.skills.map((s) => - `${esc(s)}`).join("")}
-
- 预计投入:${stageEffortText(weeks[i])} - 资源类型:${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); - 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(); -} - -$("#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); - -/* ================= 演示导览(6 步评委导览,右上角浮动步进条) ================= */ -const DEMO_JOB = "job_Java开发工程师"; -const DEMO_CANDIDATE = "job_大模型应用工程师"; -const DEMO_STEPS = [ - { title: "数据驾驶舱:多源数据总览", - desc: "JD 规模、岗位/技能/事件总量、演化趋势、清洗与审核状态", - go: () => switchView("dashboard") }, - { title: "新岗位候选:发现 → 人工审核 → 入图", - desc: "候选岗位不直接入图:四指标证据 + 人工确认,可回查证据 JD", - go: () => { - switchView("graph"); - const locate = () => { - const has = CN.graph && CN.graph.has && CN.graph.has(DEMO_CANDIDATE); - if (CN.gotoNode) CN.gotoNode(has ? DEMO_CANDIDATE : DEMO_JOB); - }; - if (CN.graph && CN.graph.ready) CN.graph.ready().then(locate); - else if (CN.gotoNode) CN.gotoNode(DEMO_CANDIDATE); - } }, - { title: "岗位局部图谱:Java 开发工程师", - desc: "技能点级局部图谱:必备/加分技能、支撑 JD 数与可读证据", - go: () => { - switchView("graph"); - const enter = () => { if (CN.graph && CN.graph.solo) CN.graph.solo(DEMO_JOB); }; - if (CN.graph && CN.graph.ready) CN.graph.ready().then(enter); - } }, - { title: "岗位演化报告:能力动态更新", - desc: "本期结论、新增/增强/减弱/退出、时点对比,每条可回查证据", - go: () => { - switchView("evolution"); - $("#evo-job-select").value = DEMO_JOB; - $("#evo-job-select").dispatchEvent(new Event("change")); - } }, - { title: "人岗诊断:示例简历自动跑通", - desc: "载入示例简历 → 解析确认 → 匹配结论 / 核心缺口 / 可迁移能力", - go: async () => { - switchView("match"); - $$(".match-tab").forEach((x) => x.classList.toggle("active", x.dataset.mtab === "single")); - $("#match-single").classList.remove("hidden"); - $("#match-batch").classList.add("hidden"); - $("#match-job-select").value = DEMO_JOB; - loadMatchJobInfo(); - $("#btn-load-sample").click(); - try { - const p = await post("/api/resume/parse", { text: $("#resume-text").value }); - applyParsedProfile(p, "演示示例简历"); - $("#btn-run-match").click(); - } catch (e) { toast(friendlyErr(e), "error"); } - } }, - { title: "学习路径:可执行的三阶段路线", - desc: "自动带入岗位与技能缺口:基础补齐 → 岗位核心 → 项目验证", - go: () => { - switchView("path"); - $("#path-job-select").value = DEMO_JOB; - loadPathJob(DEMO_JOB, matchState.skills).then(() => $("#btn-path").click()); - } }, -]; - -let tourIdx = -1; -function tourGo(i) { - tourIdx = Math.max(0, Math.min(DEMO_STEPS.length - 1, i)); - const step = DEMO_STEPS[tourIdx]; - $("#tour-bar").classList.remove("hidden"); - $("#tour-prog").textContent = `演示 ${tourIdx + 1}/${DEMO_STEPS.length}`; - $("#tour-title").textContent = step.title; - $("#tour-desc").textContent = step.desc; - $("#tour-prev").disabled = tourIdx === 0; - $("#tour-next").textContent = tourIdx === DEMO_STEPS.length - 1 ? "完成" : "下一步"; - Promise.resolve(step.go()).catch((e) => toast(friendlyErr(e), "error")); -} -function endTour() { - tourIdx = -1; - $("#tour-bar").classList.add("hidden"); -} -$("#btn-demo-tour").onclick = () => tourGo(0); -$("#tour-prev").onclick = () => tourGo(tourIdx - 1); -$("#tour-next").onclick = () => (tourIdx === DEMO_STEPS.length - 1 ? endTour() : tourGo(tourIdx + 1)); -$("#tour-exit").onclick = endTour; - -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. -
  3. 下载权重(约 2.2GB):ModelScope(国内推荐)modelscope.cn/models/BAAI/bge-m3 或 HuggingFace huggingface.co/BAAI/bge-m3
  4. -
  5. 目录内需包含 config.json、model.safetensors 与分词器文件,填入上方路径保存即可
  6. -
-
-
-
-
- - -
-
- - -
-
- - -
-
- 联网 API 配置引导 -
    -
  1. 开通阿里云百炼(DashScope),在控制台获取 API Key
  2. -
  3. 默认接口为 OpenAI 兼容模式,模型推荐 text-embedding-v4
  4. -
  5. 填入 Key 保存即生效;网络不可达时自动回退词表归一化
  6. -
-
-
-
-
如遇问题,请联系 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._flowEvents) drawEventFlow($("#trend-canvas"), CN._flowEvents, 8); - drawHeat(); -}); -(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开发工程师"; - loadMatchJobInfo(); // 匹配页右侧目标岗位信息卡 - /* 学习路径空状态:引导先做人岗诊断 */ - const pathGoto = $("#path-goto-match"); - if (pathGoto) pathGoto.onclick = () => switchView("match"); - 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(friendlyErr(e, "后端连接失败,请检查服务是否已启动"), "error"); - } -})(); +/* CareerNexus 职联 前端主脚本:外壳 + 驾驶舱 + 演化 + 匹配 + 学习路径。 + * 全部数据走 /api,完全离线(无外部依赖)。图谱工作台见 graph.js。 */ +"use strict"; + +/* ================= 公共工具 ================= */ +const $ = (s) => document.querySelector(s); +const $$ = (s) => Array.from(document.querySelectorAll(s)); + +/* 网络层统一封装:网络错误一律翻译为中文业务文案, + * 演示环境禁止出现 "Failed to fetch"、接口路径、内部文件名。 */ +async function cnFetch(path, options) { + try { + return await fetch(path, options); + } catch { + throw new Error("暂时无法连接服务,请检查后端是否已启动"); + } +} + +/* 把残余的技术型报错兜底翻译为业务文案(服务端中文 detail 原样保留) */ +function friendlyErr(e, fallback = "操作失败,请稍后重试") { + const raw = (e && e.message) || ""; + if (!raw) return fallback; + if (/failed to fetch|networkerror|load failed/i.test(raw)) return "暂时无法连接服务,请检查后端是否已启动"; + if (/\.jsonl|\.py|traceback/i.test(raw)) return fallback; // 内部文件名不外显 + return raw.replace(/HTTP (\d{3})/g, "服务响应异常($1)"); +} + +async function api(path, options) { + options = options || {}; + options.headers = Object.assign( + { "X-Access-Token": localStorage.getItem("cn_access_token") || "" }, + options.headers); + const resp = await cnFetch(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(friendlyErr({ message: 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 原文缓存(证据弹窗与来源列表共用,失败不缓存以允许重试) */ +const _jdCache = new Map(); +async function fetchJd(sid) { + if (_jdCache.has(sid)) return _jdCache.get(sid); + const p = api(`/api/jd?sid=${encodeURIComponent(sid)}`); + _jdCache.set(sid, p); + try { return await p; } catch (e) { _jdCache.delete(sid); throw e; } +} + +/* 来源列表行内联元信息:填充为「企业 · 平台 · 日期」可读文案(不显示内部 source_id) */ +CN.jdMetaLine = async function (el, sid) { + try { + const r = await fetchJd(sid); + el.textContent = `${r.company || "企业未披露"} · ${r.source || "公开招聘平台"} · ${r.publish_date || "时间未知"}`; + } catch { + el.textContent = "证据详情暂时读取失败,点击可重试查看"; + } +}; + +/* JD 证据弹窗(演化事件 / 图谱来源共用) + * sids:单条或数组(数组时支持上一条/下一条翻页) + * ctx:{ skill, jobTitle, changeType, window } 用于生成可读结论与命中依据 */ +async function showJdEvidence(sids, ctx = {}) { + const list = (Array.isArray(sids) ? sids : [sids]).filter(Boolean); + if (!list.length) return toast("该条目暂无关联的公开 JD 证据", "info"); + let idx = 0; + const conclusion = ctx.skill + ? `${ctx.skill}${ctx.changeType ? ` · ${ctx.changeType}` : ""}${ctx.jobTitle ? ` — ${ctx.jobTitle}` : ""}` + : ""; + async function render() { + const sid = list[idx]; + const foot = ` + ${list.length > 1 ? ` + + ${idx + 1} / ${list.length} + ` : ""} + `; + openModal("JD 原文证据", `
证据加载中…
`, foot); + $("#ev-close").onclick = closeModal; + if (list.length > 1) { + $("#ev-prev").onclick = () => { idx -= 1; render(); }; + $("#ev-next").onclick = () => { idx += 1; render(); }; + } + try { + const rec = await fetchJd(sid); + $("#modal-body").innerHTML = ` + ${conclusion ? `
${esc(conclusion)}${ctx.window ? `(${esc(ctx.window)})` : ""}
` : ""} +
+ ${esc(rec.job_title)}${esc(rec.company || "企业未披露")} + ${esc(rec.city || "—")}${esc(rec.salary || "—")} +
+
来源:${esc(rec.source || "公开招聘平台")} · ${esc(rec.company || "企业未披露")} · 发布时间:${esc(rec.publish_date || "未知")}
+
${esc(rec.text)}
+ ${ctx.skill ? `
命中依据:该 JD 对「${esc(ctx.skill)}」提出明确能力要求${ctx.changeType ? `(${esc(ctx.changeType)})` : ""}
` : ""} +
${esc(sid)}
`; + } catch (e) { + const gone = /不存在|非法|格式/.test((e && e.message) || ""); + $("#modal-body").innerHTML = ` +
+

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

+ +
`; + $("#ev-retry").onclick = render; + } + } + await render(); +} +CN.showJdEvidence = showJdEvidence; +CN.fetchJd = fetchJd; + +/* ================= 视图切换 / 侧栏 ================= */ +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).then(() => renderDemoPath()); // 未诊断时展示示范路线 + } +} +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"); + }); +})(); + +/* ================= 人才能力变化指挥台(数据驾驶舱) ================= */ +/* 首屏 KPI:新岗位候选 / 关键技能增强 / 待人工复核 / 影响岗位(图谱规模降级为一行小字) */ +const METRIC_DEFS = [ + { key: "candidates", label: "新岗位候选", act: "desk", sub: "聚类发现,待人工决策", icon: '' }, + { key: "boosted", label: "关键技能增强", act: "evolution", sub: "近 2 季度新增+增强事件", icon: '' }, + { key: "review", label: "待人工复核", act: "desk", sub: "候选岗位 + 候选技能关系", icon: '' }, + { key: "affected", label: "影响岗位", act: "evolution", sub: "近 2 季度发生变化的岗位", icon: '' }, +]; + +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; + CN._pendingCache = pending; + const s = health.graph || {}; + updateBanners(health); + const events = graph.events || []; + const lastWin = (events.at(-1) || {}).window || "2026Q3"; + $("#data-version").textContent = `数据截止:${lastWin} · 模拟数据`; + + /* 本期口径:最近 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) => ` +
+
${m.icon}${m.label}
+
${values[m.key] ?? "—"}
+ ${m.sub} +
`).join(""); + $$("#metric-cards .metric-card").forEach((el) => { + el.onclick = () => (el.dataset.act === "desk" ? openReviewDesk() : switchView("evolution")); + }); + $("#pending-count").textContent = `${pending.length} 待办`; + + /* 主图:近 8 季度变化事件流(全岗位口径堆叠柱,点击柱段跳演化页) */ + CN._flowEvents = events; + drawEventFlow($("#trend-canvas"), events, 8); + + renderTrust(); + renderTaskTable(audit); + $("#btn-import-jd").onclick = () => openImportDialog(); + $("#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]) => ` +
${k}${v} + ${esc(sub)}
`).join(""); + } catch (e) { + box.innerHTML = `
+

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

+
`; + $("#trust-retry").onclick = renderTrust; + } +} + +/* 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) { + 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 `
+ + +
+
${esc(e.skill)} ${verb[e.change_type] || esc(e.change_type)}
+
影响岗位:${esc(jobTitle(e.job_id))} · 证据 ${(e.source_ids || []).length} 条 JD · ${esc(e.window)}
+
+ ${esc(e.change_type)} +
`; + }).join("") : `
暂无变化播报
`; + $$("#activity-feed .feed-item").forEach((el) => { + el.onclick = () => { + 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 = `
暂无待审核候选(可在图谱页触发新岗位发现)
`; + return; + } + box.innerHTML = pending.slice(0, 3).map((c) => { + const t = candidateTrust(c, CN.jobs); + const ready = ["高", "较高"].includes(t.level); + return `
+
${esc(c.title)} + ${ready ? "建议批准入图" : "建议继续观察"}
+
结论:四指标达标 ${t.okCount}/4(可信等级 ${t.level}) · 处理人:待分配
+
证据强度:${c.source_count} 条 JD · ${c.company_count} 家企业 · ${(c.support_windows || []).length} 个季度跨度
+
+ + + +
+
`; + }).join("") + (pending.length > 3 + ? `` : ""); + box.querySelectorAll("[data-act]").forEach((b) => { + b.onclick = () => doReview(b.dataset.id, b.dataset.act); + }); + 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) { + 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 (!$("#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 = `
+

候选数据暂时加载失败:${esc(friendlyErr(e))}

+
`; + $("#desk-retry").onclick = () => renderReviewDesk(); + return; + } + if (!pending.length) { + box.innerHTML = `
暂无待审核候选(可在图谱页触发新岗位发现)
`; + 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]}` : "—"; + const resp = c.responsibilities || []; + const summary = resp[0] + || `面向${(c.industries || []).join("/") || "新兴"}领域的「${c.title}」岗位(职责描述待人工补充)`; + return `
+
候选:${esc(c.title)} + 可信等级:${t.level}
+
职责摘要:${esc(summary)}
+ ${resp.length > 1 ? `
核心职责:${resp.slice(0, 5).map((d) => `${esc(d.replace(/;\s*$/, ""))}`).join(" ")}
` : ""} +
应用场景:${(c.scenarios || []).length + ? c.scenarios.map((s) => `${esc(s)}`).join(" ") + : "—"}
+
${t.dots.map((d) => + ``).join("")} + ${t.okCount}/4 指标达标(JD / 企业 / 增长 / 差异)
+
多源证据:${c.source_count} JD · ${c.company_count} 企业 · 时间跨度 ${esc(span)}
+
与相近岗位:${t.nearest.title + ? `「${esc(t.nearest.title)}」技能重合 ${(t.nearest.sim * 100).toFixed(0)}%,职责差异度 ${t.distinctness.toFixed(2)}` + : "暂无可比较岗位"}
+
必备:${(c.required_skills || []).slice(0, 6).map((s) => `${esc(s)}`).join(" ") || "—"} + ${(c.preferred_skills || []).length ? `
加分:${c.preferred_skills.slice(0, 4).map((s) => `${esc(s)}`).join(" ")}` : ""}
+
+ + + + +
+
`; + }).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) => `${esc(s)}`).join(" ") || "—"; + openModal(`相近岗位比较:${esc(c.title)} vs ${esc(near.title)}`, ` +
技能重合 ${(t.nearest.sim * 100).toFixed(0)}% · 职责差异度 ${t.distinctness.toFixed(2)}(≥0.40 视为新岗位)
+
+
候选「${esc(c.title)}」必备
+
${chips(c.required_skills, "tag-blue")}
+
「${esc(near.title)}」必备
+
${chips(near.required_skills, "tag-green")}
+
候选独有技能
+
${chips((c.required_skills || []).filter((s) => !(near.required_skills || []).includes(s)), "tag-orange")}
+
`); + }; + }); + 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; + 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 }; +} + +/* 变化事件流:近 N 季度全岗位口径 新增/增强/减弱/退出 堆叠柱,点击柱段跳演化页对应岗位 */ +function drawEventFlow(canvas, events, n) { + const { ctx, W, H } = fitCanvas(canvas, 220); + ctx.clearRect(0, 0, W, H); + $("#cmd-stack-legend").innerHTML = Object.entries(CHANGE_META).map(([t, m]) => + `${t}`).join(""); + const windows = [...new Set(events.map((e) => e.window))].sort().slice(-n); + if (!windows.length) return; + const types = Object.keys(CHANGE_META); + const counts = windows.map((w) => { + const row = {}; + types.forEach((t) => { row[t] = events.filter((e) => e.window === w && e.change_type === t).length; }); + return row; + }); + const maxTotal = Math.max(1, ...counts.map((r) => types.reduce((a, t) => a + r[t], 0))); + const bw = W / windows.length; + const baseY = H - 22; + ctx.font = "10px sans-serif"; ctx.textAlign = "center"; + windows.forEach((w, i) => { + let y = baseY; + types.forEach((t) => { + const v = counts[i][t]; + if (!v) return; + const h = (v / maxTotal) * (H - 52); + ctx.fillStyle = CHANGE_META[t].color; + ctx.fillRect(i * bw + bw * 0.2, y - h, bw * 0.6, h); + y -= h; + }); + const total = types.reduce((a, t) => a + counts[i][t], 0); + if (total) { ctx.fillStyle = "#4e5969"; ctx.fillText(total, i * bw + bw / 2, y - 4); } + 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: "tag-cyan", color: "#7bc8a4" }, + "增强": { tag: "tag-purple", color: "#8f9ff0" }, + "减弱": { tag: "tag-orange", color: "#e8c07d" }, + "退出": { tag: "tag-red", color: "#d99a91" }, +}; +const STEADY_COLOR = "#86909c"; // 持续:中性蓝灰 +let evoEvents = []; + +/* 全部岗位 × 季度 演化热力图 */ +function renderEvoScan() { + const box = $("#evo-scan"); + const events = (CN.graphData && CN.graphData.events) || []; + const jobs = CN.jobs || []; + if (!events.length || !jobs.length) { + box.innerHTML = `
暂无演化事件数据
`; + return; + } + const winSet = new Set([...new Set(events.map((e) => e.window))].sort().slice(-12)); + const per = {}; + events.forEach((e) => { + if (!winSet.has(e.window)) return; + const p = per[e.job_id] = per[e.job_id] || { add: 0, out: 0, other: 0 }; + if (e.change_type === "新增") p.add++; + else if (e.change_type === "退出") p.out++; + else p.other++; + }); + const rows = jobs.map((j) => ({ j, ...(per[j.job_id] || { add: 0, out: 0, other: 0 }) })) + .map((r) => ({ ...r, total: r.add + r.out + r.other })) + .filter((r) => r.total > 0) + .sort((a, b) => b.total - a.total); + const max = Math.max(1, ...rows.map((r) => r.total)); + box.innerHTML = rows.map((r) => ` +
+ ${esc(r.j.title)} + + ${r.total} +
`).join("") + ` +
横条构成:新增技能 + 退出技能 + 增强/减弱 + (近 12 季度,点击行进入该岗位报告)
`; + box.querySelectorAll(".scan-row").forEach((el) => { + el.onclick = () => { + $("#evo-job-select").value = el.dataset.job; + $$("#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") renderEvoScan(); + }; +}); + +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; + try { + const data = await api(`/api/jobs/${encodeURIComponent(id)}/evolution`); + evoEvents = data.events; + } catch (e) { + evoEvents = []; + $("#evo-timeline").innerHTML = `
+

演化数据暂时加载失败:${esc(friendlyErr(e))}

+
`; + $("#evo-retry").onclick = loadEvolution; + return; + } + renderEvoStats(id); + renderEvoSummary(); + renderEvolution(); + runEvoCompare(); // 默认对比:最早事件前 → 今天 +} + +/* 本期结论:最近 2 个季度的新增/增强/减弱/退出清单(业务化文案,避免机器词) */ +function renderEvoSummary() { + const box = $("#evo-summary"); + if (!evoEvents.length) { + box.innerHTML = `
该岗位暂无演化事件,暂无本期结论
`; + return; + } + const wins = [...new Set(evoEvents.map((e) => e.window))].sort().slice(-2); + const order = ["新增", "增强", "减弱", "退出"]; + box.innerHTML = wins.map((w) => { + const evs = evoEvents.filter((e) => e.window === w); + const parts = order.map((t) => { + const list = evs.filter((e) => e.change_type === t); + if (!list.length) return ""; + const skills = list.map((e) => (t === "增强" || t === "减弱") + ? `${esc(e.skill)}(需求指数 ${e.prev_weight.toFixed(2)}→${e.curr_weight.toFixed(2)})` + : esc(e.skill)).join("、"); + const verb = { "新增": "成为新要求", "增强": "需求显著增强", "减弱": "需求明显减弱", "退出": "退出能力画像" }[t]; + return `${t}${skills} ${verb}`; + }).filter(Boolean).join(""); + return `
${esc(w)}
${parts}
`; + }).join(""); +} + +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(friendlyErr(e))}

+
`; + $("#evo-diff-retry").onclick = runEvoCompare; + } +} +$("#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(); + }; +}); + +/* 热度走势范围:默认最近 8 个季度,可切换全部 */ +let heatRange = 8; +$$("#evo-heat-range button").forEach((b) => { + b.onclick = () => { + $$("#evo-heat-range button").forEach((x) => x.classList.toggle("active", x === b)); + heatRange = b.dataset.range === "all" ? Infinity : 8; + drawHeat(); + }; +}); +function drawHeat() { + if (!CN._heat) return; + const kv = heatRange === Infinity ? CN._heat : CN._heat.slice(-heatRange); + drawHeatLine($("#evo-heat-canvas"), kv); +} + +function renderEvolution() { + const gran = $$("#evo-gran button").find((b) => b.classList.contains("active"))?.dataset.gran || "quarter"; + drawStackBars($("#evo-stack-canvas"), evoEvents); + 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); + 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)); + drawHeat(); + + /* 时间轴 */ + const box = $("#evo-timeline"); + const jobTitle = (CN.jobs.find((j) => j.job_id === $("#evo-job-select").value) || {}).title || ""; + 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 || "").replace(/权重/g, "需求指数"))}
+
证据 JD ${e.source_ids.length} 条 · 点击查看原文
+
`; + }).join("") : `
该岗位暂无演化事件
`; + box.querySelectorAll(".evo-event").forEach((el) => { + el.onclick = () => { + const e = evoEvents[+el.dataset.i]; + showJdEvidence(e.source_ids || [], { + skill: e.skill, jobTitle, changeType: e.change_type, window: e.window, + }); + }; + }); +} + +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); + }); +} + +/* 能力生命线(Swimlane):每个核心技能一行(近 8 季度),线段=生效区间,圆点=变更事件。 + * 行状态取该技能最近一次事件:新增=青绿 / 增强=蓝紫 / 减弱=琥珀 / 退出=砖红 / 持续=蓝灰 */ +/* 能力更替流:旧核心(已退出必备) → 持续能力 → 新核心(近 4 季度新进入) */ +function renderEvoReplace(jobId) { + const box = $("#evo-replace"); + const g = CN.graphData || {}; + const wins = [...new Set((g.events || []).map((e) => e.window))].sort(); + const recent4 = new Set(wins.slice(-4)); + const edges = (g.edges || []).filter((e) => e.source === jobId && e.rel === "REQUIRES" && (e.intervals || []).length); + if (!edges.length) { + box.innerHTML = `
暂无能力更替数据
`; + return; + } + const alive = (e) => e.intervals.some(([s, en]) => en === null); + const start = (e) => e.intervals.map((i) => i[0]).sort()[0]; + const oldCore = edges.filter((e) => !alive(e)); + const cont = edges.filter((e) => alive(e) && !recent4.has(start(e))); + const newCore = edges.filter((e) => alive(e) && recent4.has(start(e))); + const chip = (e) => + `${esc(e.target)}`; + const col = (title, list, cls, note) => ` +
+
${title}(${list.length})
+
${note}
+
${list.slice(0, 10).map(chip).join("") || '无'}
+
`; + box.innerHTML = ` +
+ ${col("旧核心(已退出)", oldCore, "old", "曾为核心/加分,当前已退出画像")} +
→
+ ${col("持续能力", cont, "keep", "跨周期稳定生效,构成岗位基本盘")} +
→
+ ${col("新核心(近 4 季度进入)", newCore, "new", "本期新增或升级的必备/加分能力")} +
`; + 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; // 最近一次解析的完整简历画像(结构化模板与导出用) + +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"); +}; + +/* 目标岗位信息卡:上传前让用户理解岗位要求(右栏 40%) */ +async function loadMatchJobInfo() { + const box = $("#match-job-info"); + if (!box) return; + const jobId = $("#match-job-select").value; + if (!jobId) { box.innerHTML = `
请先选择目标岗位
`; return; } + box.innerHTML = `
岗位信息加载中…
`; + try { + const d = await api(`/api/jobs/${encodeURIComponent(jobId)}`); + const def = d.definition || d; + const level = def.expected_years == null ? "—" : def.expected_years <= 2 ? "初级" : def.expected_years <= 5 ? "中级" : "高级"; + const chips = (list, cls, n) => (list || []).slice(0, n).map((s) => `${esc(s)}`).join(" ") || `无`; + box.innerHTML = ` +
${esc(def.title || jobId)}
+
${level} · 支撑 ${def.source_count ?? "—"} 条 JD · ${def.company_count ?? "—"} 家企业 · 数据截止 2026Q3
+ ${(def.responsibilities || [])[0] ? `
${esc(def.responsibilities[0])}
` : ""} +
必备技能
${chips(def.required_skills, "tag-green", 6)}
+
加分技能
${chips(def.preferred_skills, "tag-cyan", 4)}
`; + } catch (e) { + box.innerHTML = `
+

岗位信息暂时加载失败:${esc(friendlyErr(e))}

+
`; + $("#jic-retry").onclick = loadMatchJobInfo; + } +} +$("#match-job-select").addEventListener("change", loadMatchJobInfo); + +$("#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 cnFetch("/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(friendlyErr({ message: body.detail || "" }, `服务响应异常(${resp.status})`)); + const p = body.data; + applyParsedProfile(p, `${f.name}(已脱敏)`); + toast(`已解析 ${f.name}:识别技能 ${p.skills.length} 项`, "success"); + } catch (err) { toast(friendlyErr(err, "简历解析失败,请重试"), "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; + 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, + scenarios: [], + }); + matchState.result = m; + 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 = () => { + switchView("path"); + $("#path-job-select").value = $("#match-job-select").value; + loadPathJob($("#match-job-select").value, matchState.skills).then(() => $("#btn-path").click()); +}; + +/* 总分 → 自然语言结论(答辩口径:分数只是结论的辅助) */ +function matchAdvice(score) { + if (score >= 80) return "强烈推荐投递:能力画像与目标岗位高度匹配,可优先安排面试。"; + if (score >= 60) return "建议补齐核心缺口后投递:已具备岗位基础,优先补足缺失的必备技能。"; + return "建议先系统学习再投递:当前与岗位要求差距较大,可按学习路径分阶段提升。"; +} + +function renderMatchResult(m) { + drawGauge($("#gauge-canvas"), m.total_score); + $("#match-verdict").innerHTML = m.is_match + ? `✓ 达到匹配阈值 目标岗位:${esc(m.job_title)}` + : `未达匹配阈值(60 分) 目标岗位:${esc(m.job_title)}`; + $("#match-advice").innerHTML = `诊断结论:${matchAdvice(m.total_score)}`; + 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("") || + `无命中(简历中未识别到本岗位所需技能)`; + /* 缺口技能:超过 5 项默认折叠,避免清单压垮用户 */ + const missing = [ + ...m.missing_required.map((s) => chip(s, "tag-red")), + ...m.missing_preferred.map((s) => chip(s, "tag-orange")), + ]; + const missBox = $("#missing-chips"); + if (!missing.length) { + missBox.innerHTML = `无`; + } else if (missing.length <= 5) { + missBox.innerHTML = missing.join(""); + } else { + const renderMiss = (expanded) => { + missBox.innerHTML = (expanded ? missing : missing.slice(0, 5)).join("") + + ``; + $("#miss-toggle").onclick = () => renderMiss(!expanded); + }; + renderMiss(false); + } + $("#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 cnFetch("/api/resume/export", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ profile: lastProfile, match: matchState.result }), + }); + if (!resp.ok) throw new Error(`服务响应异常(${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(`导出失败:${friendlyErr(e)}`, "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 cnFetch("/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(friendlyErr({ message: body.detail || "" }, `服务响应异常(${resp.status})`)); + batchState.result = body.data; + renderBatchResult(body.data); + } catch (e2) { + box.innerHTML = `
+

批量匹配失败:${esc(friendlyErr(e2))}

+
`; + } +}; + +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(friendlyErr(e))}

+
`; + $("#path-pool-retry").onclick = () => loadPathJob(jobId); + } +} + +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", () => { + 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; + if (jobId !== pathState.jobId) await loadPathJob(jobId); + const btn = $("#btn-path"); + btn.disabled = true; + $("#path-result").innerHTML = `
正在生成学习路径:对齐岗位图谱 → 计算技能缺口 → 拆分阶段…
`; + try { + const data = await post("/api/learning-path", { job_id: jobId, known_skills: [...pathState.known] }); + lastPathData = data; + renderPath(data); + } catch (e) { + $("#path-result").innerHTML = `
+

学习路径生成失败:${esc(friendlyErr(e))}

+
`; + $("#path-retry").onclick = () => $("#btn-path").click(); + } finally { + btn.disabled = false; + } +}; + +function stageWeeks(n) { + const base = Math.max(1, Math.round(pathState.weeks / Math.max(1, 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 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 ? `
示范路线 · 完成人岗诊断后将替换为你的个人结果
` : ""} +
目标岗位${esc(lastPathData.title)}
+
当前匹配分${cur}
+
目标分60 分
+
预计周期${pathState.weeks} 周
+
+
+
学习记录:已完成 ${done}/${n} 阶段(${pct}%)· 完成阶段后可重新匹配验证
+
`; +} + +/* 未诊断时展示示范路线(当前选中岗,零已掌握),完成诊断后由个人结果替换 */ +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); + $("#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); + /* 阶段语义化命名:基础补齐 → 岗位核心(可多个)→ 项目验证 */ + const stageName = (i, n) => + i === 0 ? "基础补齐" : i === n - 1 ? "项目验证" : "岗位核心"; + box.innerHTML = (data._demo ? ` +
+ 示范路线(以零已掌握技能生成):完成一次人岗诊断后,此处将自动替换为你的个人成长路线图 + +
` : "") + data.learning_path.map((st, i) => { + const done = pathState.done.has(st.stage); + return ` +
+ ${done ? "✓" : st.stage} +
+
+ 阶段 ${st.stage} · ${stageName(i, data.learning_path.length)}(${st.skills.length} 项技能) + 建议 ${weeks[i]} 周 +
+ ${i > 0 ? `
前置:建议完成阶段 ${st.stage - 1} 后开始(按技能前置关系拓扑排序)
` : ""} +
${st.skills.map((s) => + `${esc(s)}`).join("")}
+
+ 预计投入:${stageEffortText(weeks[i])} + 资源类型:${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); + 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(); +} + +$("#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); + +/* ================= 演示导览(6 步评委导览,右上角浮动步进条) ================= */ +const DEMO_JOB = "job_Java开发工程师"; +const DEMO_CANDIDATE = "job_大模型应用工程师"; +const DEMO_STEPS = [ + { title: "数据驾驶舱:多源数据总览", + desc: "JD 规模、岗位/技能/事件总量、演化趋势、清洗与审核状态", + go: () => switchView("dashboard") }, + { title: "新岗位候选:发现 → 人工审核 → 入图", + desc: "候选岗位不直接入图:四指标证据 + 人工确认,可回查证据 JD", + go: () => { + switchView("graph"); + const locate = () => { + const has = CN.graph && CN.graph.has && CN.graph.has(DEMO_CANDIDATE); + if (CN.gotoNode) CN.gotoNode(has ? DEMO_CANDIDATE : DEMO_JOB); + }; + if (CN.graph && CN.graph.ready) CN.graph.ready().then(locate); + else if (CN.gotoNode) CN.gotoNode(DEMO_CANDIDATE); + } }, + { title: "岗位局部图谱:Java 开发工程师", + desc: "技能点级局部图谱:必备/加分技能、支撑 JD 数与可读证据", + go: () => { + switchView("graph"); + const enter = () => { if (CN.graph && CN.graph.solo) CN.graph.solo(DEMO_JOB); }; + if (CN.graph && CN.graph.ready) CN.graph.ready().then(enter); + } }, + { title: "岗位演化报告:能力动态更新", + desc: "本期结论、新增/增强/减弱/退出、时点对比,每条可回查证据", + go: () => { + switchView("evolution"); + $("#evo-job-select").value = DEMO_JOB; + $("#evo-job-select").dispatchEvent(new Event("change")); + } }, + { title: "人岗诊断:示例简历自动跑通", + desc: "载入示例简历 → 解析确认 → 匹配结论 / 核心缺口 / 可迁移能力", + go: async () => { + switchView("match"); + $$(".match-tab").forEach((x) => x.classList.toggle("active", x.dataset.mtab === "single")); + $("#match-single").classList.remove("hidden"); + $("#match-batch").classList.add("hidden"); + $("#match-job-select").value = DEMO_JOB; + loadMatchJobInfo(); + $("#btn-load-sample").click(); + try { + const p = await post("/api/resume/parse", { text: $("#resume-text").value }); + applyParsedProfile(p, "演示示例简历"); + $("#btn-run-match").click(); + } catch (e) { toast(friendlyErr(e), "error"); } + } }, + { title: "学习路径:可执行的三阶段路线", + desc: "自动带入岗位与技能缺口:基础补齐 → 岗位核心 → 项目验证", + go: () => { + switchView("path"); + $("#path-job-select").value = DEMO_JOB; + loadPathJob(DEMO_JOB, matchState.skills).then(() => $("#btn-path").click()); + } }, +]; + +let tourIdx = -1; +function tourGo(i) { + tourIdx = Math.max(0, Math.min(DEMO_STEPS.length - 1, i)); + const step = DEMO_STEPS[tourIdx]; + $("#tour-bar").classList.remove("hidden"); + $("#tour-prog").textContent = `演示 ${tourIdx + 1}/${DEMO_STEPS.length}`; + $("#tour-title").textContent = step.title; + $("#tour-desc").textContent = step.desc; + $("#tour-prev").disabled = tourIdx === 0; + $("#tour-next").textContent = tourIdx === DEMO_STEPS.length - 1 ? "完成" : "下一步"; + Promise.resolve(step.go()).catch((e) => toast(friendlyErr(e), "error")); +} +function endTour() { + tourIdx = -1; + $("#tour-bar").classList.add("hidden"); +} +$("#btn-demo-tour").onclick = () => tourGo(0); +$("#tour-prev").onclick = () => tourGo(tourIdx - 1); +$("#tour-next").onclick = () => (tourIdx === DEMO_STEPS.length - 1 ? endTour() : tourGo(tourIdx + 1)); +$("#tour-exit").onclick = endTour; + +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. +
  3. 下载权重(约 2.2GB):ModelScope(国内推荐)modelscope.cn/models/BAAI/bge-m3 或 HuggingFace huggingface.co/BAAI/bge-m3
  4. +
  5. 目录内需包含 config.json、model.safetensors 与分词器文件,填入上方路径保存即可
  6. +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ 联网 API 配置引导 +
    +
  1. 开通阿里云百炼(DashScope),在控制台获取 API Key
  2. +
  3. 默认接口为 OpenAI 兼容模式,模型推荐 text-embedding-v4
  4. +
  5. 填入 Key 保存即生效;网络不可达时自动回退词表归一化
  6. +
+
+
+
+
如遇问题,请联系 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._flowEvents) drawEventFlow($("#trend-canvas"), CN._flowEvents, 8); + drawHeat(); +}); +(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开发工程师"; + loadMatchJobInfo(); // 匹配页右侧目标岗位信息卡 + /* 学习路径空状态:引导先做人岗诊断 */ + const pathGoto = $("#path-goto-match"); + if (pathGoto) pathGoto.onclick = () => switchView("match"); + await loadDashboard(); // 先加载图谱缓存(演化页甘特图依赖) + await loadEvolution(); + renderEvoScan(); + /* 分享链接:#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(friendlyErr(e, "后端连接失败,请检查服务是否已启动"), "error"); + } +})(); diff --git a/webui/index.html b/webui/index.html index 9f0e40d..d6d45be 100644 --- a/webui/index.html +++ b/webui/index.html @@ -298,10 +298,10 @@
@@ -313,11 +313,6 @@
是什么:系统根据近两个季度的招聘 JD 自动归纳出的能力变化结论。新增=以前不要求、现在要求;增强=要求变重;减弱=要求变少;退出=不再要求。每条结论都可追溯到原始 JD 证据。
加载中…
- -
能力生命线(近 8 季度 · 按当前需求指数取 Top 12;线段 = 生效区间,圆点 = 变更事件)
-
怎么看:每行是一项核心技能的"在岗生命周期"——横线表示这段时间里该技能属于岗位要求,彩色圆点标记发生变化的时间点(颜色含义见图例)。一眼看出技能何时进入、何时增强、何时退出。
-
-
能力更替流(旧核心 → 持续能力 → 新核心)
是什么:把岗位技能按演化结果分三列——左边是已退出的旧要求,中间是一直有效的持续能力,右边是新进入的要求。用于回答"这个岗位正在从什么技术栈转向什么技术栈"。
diff --git a/webui/style.css b/webui/style.css index 5c06e19..eb9012c 100644 --- a/webui/style.css +++ b/webui/style.css @@ -1168,3 +1168,15 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: /* 演化页图表说明文字 */ .evo-chart-desc { font-size: 12px; color: var(--text-3); line-height: 1.7; margin: -2px 0 8px; } + +/* 全岗位扫描:横条排行 */ +.evo-scan { margin-bottom: 16px; } +.scan-row { + display: flex; align-items: center; gap: 10px; padding: 5px 4px; + cursor: pointer; border-radius: var(--radius-sm); +} +.scan-row:hover { background: var(--bg-hover); } +.scan-label { flex: none; width: 132px; font-size: 13px; color: var(--text-1); text-align: right; } +.scan-track { flex: 1; display: flex; height: 14px; background: var(--bg-page); border-radius: 3px; overflow: hidden; } +.scan-bar { display: block; height: 100%; } +.scan-num { flex: none; width: 34px; font-size: 12px; color: var(--text-2); font-variant-numeric: tabular-nums; }