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 = `
-
-
${esc(title)}
-
${bodyHtml}
-
-
`;
- 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 ``;
- }).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 = `
-
-
- | ${photo} |
- ${kv("姓名", p.name)}${kv("性别", p.gender)}
-
- ${kv("学历", p.education)}${kv("工作年限", p.years ? p.years + " 年" : "")}
- ${kv("手机", p.phone)}${kv("邮箱", p.email)}
- | 家庭住址 | ${esc(p.address || "—")} |
- ${secs.map((sec) => `
-
- | ${esc(sec.title)} |
- ${highlightHtml(sec.text, clsOf)} |
-
`).join("")}
-
`;
-}
-
-$("#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.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 兼容,保存即热生效)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
语义向量匹配(可选增强,保存即热生效)
-
-
-
-
-
-
- 自动完成全部配置,推荐首次使用:
-
① 安装 sentence-transformers 依赖(PyPI 镜像站)
-
② 从 ModelScope 镜像站下载 BGE-M3 权重(约 2.2GB)
-
③ 自动写入配置并启用语义匹配。全程约 3–8 分钟,期间可继续使用其他功能。
-
-
-
-
-
-
-
-
-
手动配置方式(不使用一键配置时)
-
- - 安装依赖:
pip install sentence-transformers
- - 下载权重(约 2.2GB):ModelScope(国内推荐)
modelscope.cn/models/BAAI/bge-m3 或 HuggingFace huggingface.co/BAAI/bge-m3
- - 目录内需包含
config.json、model.safetensors 与分词器文件,填入上方路径保存即可
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
联网 API 配置引导
-
- - 开通阿里云百炼(DashScope),在控制台获取 API Key
- - 默认接口为 OpenAI 兼容模式,模型推荐
text-embedding-v4
- - 填入 Key 保存即生效;网络不可达时自动回退词表归一化
-
-
-
-
- `,
- `${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 = `
+
+
${esc(title)}
+
${bodyHtml}
+
+
`;
+ 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 ``;
+ }).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 = `
+
+
+ | ${photo} |
+ ${kv("姓名", p.name)}${kv("性别", p.gender)}
+
+ ${kv("学历", p.education)}${kv("工作年限", p.years ? p.years + " 年" : "")}
+ ${kv("手机", p.phone)}${kv("邮箱", p.email)}
+ | 家庭住址 | ${esc(p.address || "—")} |
+ ${secs.map((sec) => `
+
+ | ${esc(sec.title)} |
+ ${highlightHtml(sec.text, clsOf)} |
+
`).join("")}
+
`;
+}
+
+$("#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.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 兼容,保存即热生效)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
语义向量匹配(可选增强,保存即热生效)
+
+
+
+
+
+
+ 自动完成全部配置,推荐首次使用:
+
① 安装 sentence-transformers 依赖(PyPI 镜像站)
+
② 从 ModelScope 镜像站下载 BGE-M3 权重(约 2.2GB)
+
③ 自动写入配置并启用语义匹配。全程约 3–8 分钟,期间可继续使用其他功能。
+
+
+
+
+
+
+
+
+
手动配置方式(不使用一键配置时)
+
+ - 安装依赖:
pip install sentence-transformers
+ - 下载权重(约 2.2GB):ModelScope(国内推荐)
modelscope.cn/models/BAAI/bge-m3 或 HuggingFace huggingface.co/BAAI/bge-m3
+ - 目录内需包含
config.json、model.safetensors 与分词器文件,填入上方路径保存即可
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
联网 API 配置引导
+
+ - 开通阿里云百炼(DashScope),在控制台获取 API Key
+ - 默认接口为 OpenAI 兼容模式,模型推荐
text-embedding-v4
+ - 填入 Key 保存即生效;网络不可达时自动回退词表归一化
+
+
+
+
+ `,
+ `${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 @@
-
-
全部岗位演化热力图 · 近 12 季度(每格 = 该季度演化事件数,点击行切换岗位并回到岗位报告)
-
怎么看:每行一个岗位、每列一个季度,颜色越深说明该岗位当季能力变化越多。用于快速发现"哪些岗位正在剧烈变化";点击行标签进入该岗位的详细报告。
-
+
+
全岗位扫描 · 近 12 季度变化量排行(点击行进入该岗位报告)
+
怎么看:每行一个岗位,横条长度=近 12 个季度的能力变化总量;绿色段=新增的技能,红色段=退出的技能。条越长说明这个岗位的能力要求变化越剧烈。
+
@@ -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; }