Files
CareerNexus/webui/app.js
T

2266 lines
112 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 = `
<div class="sysdlg" role="dialog" aria-modal="true">
<div class="sysdlg-title">${esc(title)}</div>
<div class="sysdlg-body">${bodyHtml}</div>
<div class="sysdlg-foot">
${cancelText ? `<button class="btn btn-secondary btn-sm" data-act="cancel">${esc(cancelText)}</button>` : ""}
<button class="btn ${danger ? "btn-danger" : "btn-primary"} btn-sm" data-act="ok">${esc(okText)}</button>
</div>
</div>`;
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 ? `<div class="sysdlg-label">${esc(label)}</div>` : ""}
<input class="input sysdlg-input" value="${esc(value)}" placeholder="${esc(placeholder)}">`,
onOk: (w) => w.querySelector("input").value.trim() || null,
});
}
function sysConfirm({ title, text, okText = "确定", danger = false }) {
return sysDialog({ title, bodyHtml: `<div class="sysdlg-text">${esc(text)}</div>`, okText, danger });
}
function sysAlert({ title, text, okText = "知道了" }) {
return sysDialog({ title, bodyHtml: `<div class="sysdlg-text">${esc(text)}</div>`, okText, cancelText: "" });
}
/* 复制展示框:readonly 输入 + 一键复制(clipboard 失败时退化为手动全选复制) */
function sysCopy({ title, text }) {
return sysDialog({
title,
okText: "复制", cancelText: "关闭",
bodyHtml: `<input class="input sysdlg-input" value="${esc(text)}" readonly>`,
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 ? `
<button class="btn btn-secondary btn-sm" id="ev-prev" ${idx === 0 ? "disabled" : ""}>上一条</button>
<span class="text-weak text-aux" style="align-self:center">${idx + 1} / ${list.length}</span>
<button class="btn btn-secondary btn-sm" id="ev-next" ${idx === list.length - 1 ? "disabled" : ""}>下一条</button>` : ""}
<button class="btn btn-primary btn-sm" id="ev-close">关闭</button>`;
openModal("JD 原文证据", `<div class="text-weak">证据加载中…</div>`, 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 ? `<div class="ev-conclusion">${esc(conclusion)}${ctx.window ? `<span class="text-weak">(${esc(ctx.window)})</span>` : ""}</div>` : ""}
<div class="jd-meta">
<span><b>${esc(rec.job_title)}</b></span><span>${esc(rec.company || "企业未披露")}</span>
<span>${esc(rec.city || "—")}</span><span>${esc(rec.salary || "—")}</span>
</div>
<div class="ev-source">来源:${esc(rec.source || "公开招聘平台")} · ${esc(rec.company || "企业未披露")} · 发布时间:${esc(rec.publish_date || "未知")}</div>
<div class="jd-evidence">${esc(rec.text)}</div>
${ctx.skill ? `<div class="ev-basis">命中依据:该 JD 对「${esc(ctx.skill)}」提出明确能力要求${ctx.changeType ? `(${esc(ctx.changeType)})` : ""}</div>` : ""}
<div class="ev-sid">${esc(sid)}</div>`;
} catch (e) {
const gone = /不存在|非法|格式/.test((e && e.message) || "");
$("#modal-body").innerHTML = `
<div class="empty small">
<p>${gone ? "该能力当前没有可展示的公开原文,已进入人工核验队列"
: "暂时无法读取该条证据,请检查服务后重试"}</p>
<button class="btn btn-secondary btn-sm" id="ev-retry">重试</button>
</div>`;
$("#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 `<div class="sd-item" data-id="${esc(n.id)}">
<span class="tag ${m.tag}">${m.name}</span><span>${esc(n.title || n.id)}</span>
<span class="text-weak text-aux">${esc(n.category || "")}</span></div>`;
}).join("") : `<div class="sd-item text-weak">无匹配结果</div>`;
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: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>' },
{ key: "boosted", label: "关键技能增强", act: "evolution", sub: "近 2 季度新增+增强事件", icon: '<svg viewBox="0 0 24 24"><path d="M3 17l5.5-5.5 3.5 3.5L20 7"/><path d="M15 7h5v5"/></svg>' },
{ key: "review", label: "待人工复核", act: "desk", sub: "候选岗位 + 候选技能关系", icon: '<svg viewBox="0 0 24 24"><path d="M9 11l3 3 8-8"/><path d="M21 12v6a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2h11"/></svg>' },
{ key: "affected", label: "影响岗位", act: "evolution", sub: "近 2 季度发生变化的岗位", icon: '<svg viewBox="0 0 24 24"><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V5a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>' },
];
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) => `
<div class="metric-card" data-act="${m.act}">
<div class="mc-label">${m.icon}${m.label}</div>
<div class="mc-num">${values[m.key] ?? "—"}</div>
<span class="mc-delta flat">${m.sub}</span>
</div>`).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]) => `
<div class="trust-row"><span class="trust-k">${k}</span><b class="trust-v">${v}</b>
<span class="trust-sub">${esc(sub)}</span></div>`).join("");
} catch (e) {
box.innerHTML = `<div class="empty small">
<p>可信度统计暂时加载失败:${esc(friendlyErr(e))}</p>
<button class="btn btn-secondary btn-sm" id="trust-retry">重试</button></div>`;
$("#trust-retry").onclick = renderTrust;
}
}
/* JD 数据导入对话框:上传文件 / 粘贴文本 / 直接全量重建 */
async function openImportDialog() {
const choice = await sysDialog({
title: "JD 数据导入",
okText: "开始导入",
bodyHtml: `
<div class="sysdlg-text">支持 <b>.jsonl / .csv / .txt</b> 文件上传(UTF-8,≤10MB),或直接粘贴 JSONL 文本(每行一条记录);两者都留空则仅触发全量重建。</div>
<div class="sysdlg-label">上传文件</div>
<input type="file" id="imp-file" class="input sysdlg-input" accept=".jsonl,.csv,.txt">
<div class="sysdlg-label">或粘贴文本</div>
<textarea id="imp-text" class="input sysdlg-input" rows="5" placeholder='{"jid":"j1","job_title":"Java开发工程师","company":"示例科技","publish_date":"2025-03-01","text":"负责后端开发,熟悉 Java、Spring Boot、MySQL"}'></textarea>`,
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 `<tr><td><span class="badge ${badge}">${act}</span></td>
<td>${esc(obj)}</td><td>${esc(r.operator || "—")}</td>
<td>${esc(r.time || "—")}</td><td>${esc(r.reason || "—")}</td></tr>`;
}).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 `<div class="feed-item bcast" data-i="${i}">
<span class="fi-icon" style="color:${m.color};background:${m.color}1a">
<svg viewBox="0 0 24 24"><path d="M12 2l2.4 4.9 5.4.8-3.9 3.8.9 5.4-4.8-2.5-4.8 2.5.9-5.4L4.2 7.7l5.4-.8L12 2z"/></svg></span>
<div class="bcast-body">
<div class="fi-text"><b>${esc(e.skill)}</b> ${verb[e.change_type] || esc(e.change_type)}</div>
<div class="fi-time">影响岗位:${esc(jobTitle(e.job_id))} · 证据 ${(e.source_ids || []).length} 条 JD · ${esc(e.window)}</div>
</div>
<span class="tag ${m.tag}">${esc(e.change_type)}</span>
</div>`;
}).join("") : `<div class="empty small">暂无变化播报</div>`;
$$("#activity-feed .feed-item").forEach((el) => {
el.onclick = () => {
const e = items[+el.dataset.i];
switchView("evolution");
$("#evo-job-select").value = e.job_id;
loadEvolution();
};
});
}
/* 待办行动卡(最多 3 张:结论 + 证据强度 + 处理按钮;完整台账见审核台/下方台账) */
function renderPending(pending) {
const box = $("#pending-list");
if (!pending.length) {
box.innerHTML = `<div class="empty small">暂无待审核候选(可在图谱页触发新岗位发现)</div>`;
return;
}
box.innerHTML = pending.slice(0, 3).map((c) => {
const t = candidateTrust(c, CN.jobs);
const ready = ["高", "较高"].includes(t.level);
return `<div class="review-item action-card">
<div class="ri-head"><b>${esc(c.title)}</b>
<span class="tag ${ready ? "tag-green" : "tag-orange"}">${ready ? "建议批准入图" : "建议继续观察"}</span></div>
<div class="ri-meta">结论:四指标达标 ${t.okCount}/4(可信等级 ${t.level}) · 处理人:待分配</div>
<div class="ri-meta">证据强度:${c.source_count} 条 JD · ${c.company_count} 家企业 · ${(c.support_windows || []).length} 个季度跨度</div>
<div class="ri-actions">
<button class="btn btn-primary btn-sm" data-act="accept" data-id="${esc(c.job_id)}">批准入图</button>
<button class="btn btn-secondary btn-sm" data-act="reject" data-id="${esc(c.job_id)}">退回观察</button>
<button class="btn btn-text btn-sm" data-desk="${esc(c.job_id)}">审核台查看</button>
</div>
</div>`;
}).join("") + (pending.length > 3
? `<button class="btn btn-text btn-sm" id="pending-more">查看全部 ${pending.length} 个候选 →</button>` : "");
box.querySelectorAll("[data-act]").forEach((b) => {
b.onclick = () => doReview(b.dataset.id, b.dataset.act);
});
box.querySelectorAll("[data-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 = `<div class="empty small">
<p>候选数据暂时加载失败:${esc(friendlyErr(e))}</p>
<button class="btn btn-secondary btn-sm" id="desk-retry">重试</button></div>`;
$("#desk-retry").onclick = () => renderReviewDesk();
return;
}
if (!pending.length) {
box.innerHTML = `<div class="empty small">暂无待审核候选(可在图谱页触发新岗位发现)</div>`;
return;
}
const levelTag = { "高": "tag-green", "较高": "tag-cyan", "中": "tag-orange", "低": "tag-red" };
box.innerHTML = pending.map((c) => {
const t = candidateTrust(c, CN.jobs);
const wins = (c.support_windows || []).slice().sort();
const span = wins.length ? `${wins[0]} ~ ${wins[wins.length - 1]}` : "—";
const resp = c.responsibilities || [];
const summary = resp[0]
|| `面向${(c.industries || []).join("/") || "新兴"}领域的「${c.title}」岗位(职责描述待人工补充)`;
return `<div class="cand-card" data-cand="${esc(c.job_id)}">
<div class="cc-head"><b>候选:${esc(c.title)}</b>
<span class="tag ${levelTag[t.level] || "tag-gray"}">可信等级:${t.level}</span></div>
<div class="cc-def">职责摘要:${esc(summary)}</div>
${resp.length > 1 ? `<div class="cc-duties">核心职责:${resp.slice(0, 5).map((d) => `<span class="tag tag-cyan">${esc(d.replace(/;\s*$/, ""))}</span>`).join(" ")}</div>` : ""}
<div class="cc-scens">应用场景:${(c.scenarios || []).length
? c.scenarios.map((s) => `<span class="tag tag-purple">${esc(s)}</span>`).join(" ")
: "—"}</div>
<div class="cc-dots">${t.dots.map((d) =>
`<span class="cred-dot ${d.ok ? "on" : ""}" title="${esc(d.label)}:${esc(d.value)}${d.ok ? "(达标)" : "(未达标)"}"></span>`).join("")}
<span class="cc-dots-label">${t.okCount}/4 指标达标(JD / 企业 / 增长 / 差异)</span></div>
<div class="cc-meta">多源证据:${c.source_count} JD · ${c.company_count} 企业 · 时间跨度 ${esc(span)}</div>
<div class="cc-diff">与相近岗位:${t.nearest.title
? `「${esc(t.nearest.title)}」技能重合 ${(t.nearest.sim * 100).toFixed(0)}%,职责差异度 ${t.distinctness.toFixed(2)}`
: "暂无可比较岗位"}</div>
<div class="cc-skills">必备:${(c.required_skills || []).slice(0, 6).map((s) => `<span class="tag tag-blue">${esc(s)}</span>`).join(" ") || "—"}
${(c.preferred_skills || []).length ? `<br>加分:${c.preferred_skills.slice(0, 4).map((s) => `<span class="tag tag-gray">${esc(s)}</span>`).join(" ")}` : ""}</div>
<div class="cc-actions">
<button class="btn btn-secondary btn-sm" data-ev="${esc(c.job_id)}">查看证据原文</button>
<button class="btn btn-secondary btn-sm" data-cmp="${esc(c.job_id)}">相近岗位比较</button>
<button class="btn btn-primary btn-sm" data-act="accept" data-id="${esc(c.job_id)}">人工批准入图</button>
<button class="btn btn-danger btn-sm" data-act="reject" data-id="${esc(c.job_id)}">退回继续观察</button>
</div>
</div>`;
}).join("");
box.querySelectorAll("[data-act]").forEach((b) => {
b.onclick = () => doReview(b.dataset.id, b.dataset.act);
});
box.querySelectorAll("[data-ev]").forEach((b) => {
b.onclick = () => {
const c = pending.find((x) => x.job_id === b.dataset.ev);
showJdEvidence((c && c.support_source_ids) || [], { jobTitle: (c || {}).title || b.dataset.ev });
};
});
box.querySelectorAll("[data-cmp]").forEach((b) => {
b.onclick = () => {
const c = pending.find((x) => x.job_id === b.dataset.cmp);
const t = candidateTrust(c, CN.jobs);
const near = (CN.jobs || []).find((j) => j.title === t.nearest.title);
if (!near) return toast("暂无可比较的相近岗位", "info");
const chips = (list, cls) => (list || []).map((s) => `<span class="tag ${cls}">${esc(s)}</span>`).join(" ") || "—";
openModal(`相近岗位比较:${esc(c.title)} vs ${esc(near.title)}`, `
<div class="cmp-meta">技能重合 ${(t.nearest.sim * 100).toFixed(0)}% · 职责差异度 ${t.distinctness.toFixed(2)}(≥0.40 视为新岗位)</div>
<div class="diff-grid">
<div class="diff-col"><div class="diff-title">候选「${esc(c.title)}」必备</div>
<div class="diff-chips">${chips(c.required_skills, "tag-blue")}</div></div>
<div class="diff-col"><div class="diff-title">「${esc(near.title)}」必备</div>
<div class="diff-chips">${chips(near.required_skills, "tag-green")}</div></div>
<div class="diff-col"><div class="diff-title">候选独有技能</div>
<div class="diff-chips">${chips((c.required_skills || []).filter((s) => !(near.required_skills || []).includes(s)), "tag-orange")}</div></div>
</div>`);
};
});
if (focusId) {
const el = box.querySelector(`[data-cand="${focusId}"]`);
if (el) { el.scrollIntoView({ block: "center" }); el.classList.add("flash"); }
}
}
/* 画布自适应:占满父容器(宽 100%、高 100%)+ dpr 高清,返回 CSS 像素坐标系下的 ctx/W/H */
function fitCanvas(canvas, cssH) {
const dpr = window.devicePixelRatio || 1;
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]) =>
`<span><i style="background:${m.color}"></i>${t}</span>`).join("");
const windows = [...new Set(events.map((e) => e.window))].sort().slice(-n);
if (!windows.length) return;
const types = Object.keys(CHANGE_META);
const counts = windows.map((w) => {
const row = {};
types.forEach((t) => { row[t] = events.filter((e) => e.window === w && e.change_type === t).length; });
return row;
});
const maxTotal = Math.max(1, ...counts.map((r) => types.reduce((a, t) => a + r[t], 0)));
const bw = W / windows.length;
const baseY = H - 22;
ctx.font = "10px sans-serif"; ctx.textAlign = "center";
windows.forEach((w, i) => {
let y = baseY;
types.forEach((t) => {
const v = counts[i][t];
if (!v) return;
const h = (v / maxTotal) * (H - 52);
ctx.fillStyle = CHANGE_META[t].color;
ctx.fillRect(i * bw + bw * 0.2, y - h, bw * 0.6, h);
y -= h;
});
const total = types.reduce((a, t) => a + counts[i][t], 0);
if (total) { ctx.fillStyle = "#4e5969"; ctx.fillText(total, i * bw + bw / 2, y - 4); }
ctx.fillStyle = "#86909c";
ctx.fillText(w.slice(2), i * bw + bw / 2, H - 6);
});
canvas._bars = windows.map((w, i) => ({ w, x0: i * bw, x1: (i + 1) * bw }));
canvas.onclick = (ev) => {
const rect = canvas.getBoundingClientRect();
const x = ev.clientX - rect.left;
const bar = (canvas._bars || []).find((b) => x >= b.x0 && x < b.x1);
if (!bar) return;
/* 跳到该季度事件最多的岗位演化报告 */
const byJob = {};
events.filter((e) => e.window === bar.w).forEach((e) => { byJob[e.job_id] = (byJob[e.job_id] || 0) + 1; });
const top = Object.entries(byJob).sort((a, b) => b[1] - a[1])[0];
switchView("evolution");
if (top && $("#evo-job-select").querySelector(`option[value="${top[0]}"]`)) {
$("#evo-job-select").value = top[0];
loadEvolution();
}
toast(`已定位 ${bar.w} 季度变化最多的岗位`, "info");
};
}
/* ================= 岗位演化 ================= */
/* 变化类型视觉语义:新增=青绿 / 增强=蓝紫 / 减弱=琥珀 / 退出=砖红(持续=中性蓝灰) */
const CHANGE_META = {
// 低饱和语义色:图表填充用柔和色,文字标签仍用 tag 类的标准色
"新增": { tag: "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 = `<div class="empty small">暂无演化事件数据</div>`;
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) => `
<div class="scan-row" data-job="${esc(r.j.job_id)}"
title="${esc(r.j.title)}:新增 ${r.add} · 退出 ${r.out} · 增强/减弱 ${r.other}(点击进岗位报告)">
<span class="scan-label">${esc(r.j.title)}</span>
<span class="scan-track"><i class="scan-bar" style="width:${(r.add / max) * 100}%;background:var(--success)"></i><i class="scan-bar" style="width:${(r.out / max) * 100}%;background:var(--danger)"></i><i class="scan-bar" style="width:${(r.other / max) * 100}%;background:var(--primary)"></i></span>
<span class="scan-num">${r.total}</span>
</div>`).join("") + `
<div class="hm-legend"><span>横条构成:</span><i class="hm-l" style="background:var(--success)"></i>新增技能
<i class="hm-l" style="background:var(--danger)"></i>退出技能
<i class="hm-l" style="background:var(--primary)"></i>增强/减弱
<span class="text-weak">(近 12 季度,点击行进入该岗位报告)</span></div>`;
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 = `<div class="empty small">
<p>演化数据暂时加载失败:${esc(friendlyErr(e))}</p>
<button class="btn btn-secondary btn-sm" id="evo-retry">重试</button></div>`;
$("#evo-retry").onclick = loadEvolution;
return;
}
renderEvoStats(id);
renderEvoSummary();
renderEvolution();
runEvoCompare(); // 默认对比:最早事件前 → 今天
}
/* 本期结论:最近 2 个季度的新增/增强/减弱/退出清单(业务化文案,避免机器词) */
function renderEvoSummary() {
const box = $("#evo-summary");
if (!evoEvents.length) {
box.innerHTML = `<div class="empty small">该岗位暂无演化事件,暂无本期结论</div>`;
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 `<span class="evs-item"><span class="tag ${CHANGE_META[t].tag}">${t}</span>${skills} ${verb}</span>`;
}).filter(Boolean).join("");
return `<div class="evs-row"><b class="evs-win">${esc(w)}</b><div class="evs-items">${parts}</div></div>`;
}).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]) =>
`<div class="evo-stat"><span>${k}</span><b>${v}</b></div>`).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 = `<div class="empty small">对比计算中…</div>`;
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) =>
`<span class="tag ${cls} diff-chip" data-skill="${esc(s)}">${esc(s)}${extra ? `<i>${extra}</i>` : ""}</span>`;
const col = (title, list, cls, note) => `
<div class="diff-col">
<div class="diff-title">${title}(${list.length})</div>
<div class="diff-chips">${list.map((s) => chip(s, cls,
note === "升" && setA.get(s) !== setB.get(s) ? "↑" : "")).join("") ||
'<span class="text-weak text-aux">无</span>'}</div>
</div>`;
box.innerHTML = `
<div class="diff-meta">${esc(atA)}(${esc(sa.window)}):${setA.size} 项技能 → ${esc(atB)}(${esc(sb.window)}):${setB.size} 项技能</div>
<div class="diff-grid">
${col(`仅 ${esc(atA)} 有(已退出)`, onlyA, "tag-red")}
${col("两个时点共有(保留)", both, "tag-gray", "升")}
${col(`仅 ${esc(atB)} 有(新增)`, onlyB, "tag-green")}
</div>`;
box.querySelectorAll(".diff-chip").forEach((el) => {
el.onclick = () => { switchView("graph"); if (CN.gotoNode) CN.gotoNode(el.dataset.skill); };
});
} catch (e) {
box.innerHTML = `<div class="empty small">
<p>时点对比暂时失败:${esc(friendlyErr(e))}</p>
<button class="btn btn-secondary btn-sm" id="evo-diff-retry">重试</button></div>`;
$("#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 `<div class="evo-event t-${e.change_type}" data-i="${i}">
<div class="ee-head"><span class="ee-window">${esc(e.window)}</span>
<span class="tag ${m.tag}">${esc(e.change_type)}</span>
<span class="ee-skill">${esc(e.skill)}</span>
<span class="text-weak text-aux">需求指数 ${e.prev_weight.toFixed(2)} → ${e.curr_weight.toFixed(2)}</span></div>
<div class="ee-desc">${esc((e.description || "").replace(/权重/g, "需求指数"))}</div>
<div class="ee-src">证据 JD ${e.source_ids.length} 条 · 点击查看原文</div>
</div>`;
}).join("") : `<div class="empty small">该岗位暂无演化事件</div>`;
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]) =>
`<span><i style="background:${m.color}"></i>${t}</span>`).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 = `<div class="empty small">暂无能力更替数据</div>`;
return;
}
const alive = (e) => e.intervals.some(([s, en]) => en === null);
const start = (e) => e.intervals.map((i) => i[0]).sort()[0];
const oldCore = edges.filter((e) => !alive(e));
const cont = edges.filter((e) => alive(e) && !recent4.has(start(e)));
const newCore = edges.filter((e) => alive(e) && recent4.has(start(e)));
const chip = (e) =>
`<span class="tag ${alive(e) ? (e.importance === "required" ? "tag-blue" : "tag-cyan") : "tag-red"} rp-chip" data-skill="${esc(e.target)}">${esc(e.target)}</span>`;
const col = (title, list, cls, note) => `
<div class="rp-col ${cls}">
<div class="rp-title">${title}(${list.length})</div>
<div class="rp-note">${note}</div>
<div class="rp-chips">${list.slice(0, 10).map(chip).join("") || '<span class="text-weak text-aux">无</span>'}</div>
</div>`;
box.innerHTML = `
<div class="rp-grid">
${col("旧核心(已退出)", oldCore, "old", "曾为核心/加分,当前已退出画像")}
<div class="rp-arrow">→</div>
${col("持续能力", cont, "keep", "跨周期稳定生效,构成岗位基本盘")}
<div class="rp-arrow">→</div>
${col("新核心(近 4 季度进入)", newCore, "new", "本期新增或升级的必备/加分能力")}
</div>`;
box.querySelectorAll(".rp-chip").forEach((el) => {
el.onclick = () => { switchView("graph"); if (CN.gotoNode) CN.gotoNode(el.dataset.skill); };
});
}
/* ================= 人岗匹配(三步向导) ================= */
const matchState = { step: 1, skills: [], years: 0, result: null, profileText: "" };
let lastProfile = null; // 最近一次解析的完整简历画像(结构化模板与导出用)
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 = '<svg viewBox="0 0 24 24" style="width:13px;height:13px"><path d="M20 6L9 17l-5-5"/></svg>';
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 = `<div class="empty small">请先选择目标岗位</div>`; return; }
box.innerHTML = `<div class="text-weak text-aux">岗位信息加载中…</div>`;
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) => `<span class="tag ${cls}">${esc(s)}</span>`).join(" ") || `<span class="text-weak text-aux">无</span>`;
box.innerHTML = `
<div class="jic-title">${esc(def.title || jobId)}</div>
<div class="jic-meta">${level} · 支撑 ${def.source_count ?? "—"} 条 JD · ${def.company_count ?? "—"} 家企业 · 数据截止 2026Q3</div>
${(def.responsibilities || [])[0] ? `<div class="jic-def">${esc(def.responsibilities[0])}</div>` : ""}
<div class="jic-sec"><span class="jic-label">必备技能</span><div class="jic-chips">${chips(def.required_skills, "tag-green", 6)}</div></div>
<div class="jic-sec"><span class="jic-label">加分技能</span><div class="jic-chips">${chips(def.preferred_skills, "tag-cyan", 4)}</div></div>`;
} catch (e) {
box.innerHTML = `<div class="empty small">
<p>岗位信息暂时加载失败:${esc(friendlyErr(e))}</p>
<button class="btn btn-secondary btn-sm" id="jic-retry">重试</button></div>`;
$("#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 || "文本")},已自动脱敏):识别技能 <b>${p.skills.length}</b> 项 · ` +
`工作年限约 <b>${p.years || 0}</b> 年 · 场景经验 <b>${(p.scenarios || []).join("、") || "未识别"}</b>`;
$("#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) => `<span class="skill-chip">${esc(s)}<button data-i="${i}" title="删除">×</button></span>`).join("")
: `<span class="text-weak text-aux">暂无技能,请在下方输入添加</span>`;
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
? `<span class="tag tag-green">✓ 达到匹配阈值</span> <span class="text-secondary">目标岗位:${esc(m.job_title)}</span>`
: `<span class="tag tag-orange">未达匹配阈值(60 分)</span> <span class="text-secondary">目标岗位:${esc(m.job_title)}</span>`;
$("#match-advice").innerHTML = `<b>诊断结论:</b>${matchAdvice(m.total_score)}`;
drawRadar($("#radar-canvas"), m.dimension_scores);
const chip = (s, cls) => `<span class="tag ${cls}">${esc(s)}</span>`;
$("#matched-chips").innerHTML =
[...m.matched_required.map((s) => chip(s, "tag-green")), ...m.matched_preferred.map((s) => chip(s, "tag-cyan"))].join("") ||
`<span class="text-weak text-aux">无命中(简历中未识别到本岗位所需技能)</span>`;
/* 缺口技能:超过 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 = `<span class="text-weak text-aux">无</span>`;
} else if (missing.length <= 5) {
missBox.innerHTML = missing.join("");
} else {
const renderMiss = (expanded) => {
missBox.innerHTML = (expanded ? missing : missing.slice(0, 5)).join("") +
`<button class="link-btn" id="miss-toggle">${expanded ? "收起" : `展开全部(共 ${missing.length} 项)`}</button>`;
$("#miss-toggle").onclick = () => renderMiss(!expanded);
};
renderMiss(false);
}
$("#transfer-chips").innerHTML = m.transferable_skills.map((s) => chip(s, "tag-purple")).join("") ||
`<span class="text-weak text-aux">暂无可迁移技能</span>`;
// 声明掌握但与本岗位无关的技能:显式告知"不计入评分",避免用户疑惑
// (后端已做大小写/别名归一化,这里比较时统一小写,避免 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) =>
`<span class="hl ${clsOf.get(hit.toLowerCase())}">${hit}</span>`);
}
function renderResumeHighlight(m) {
const box = $("#resume-highlight");
const p = lastProfile;
if (!p || !(p.raw_masked_text || "").trim()) {
box.innerHTML = `<div class="empty"><p>无简历文本(本次以技能清单直接匹配)</p></div>`;
return;
}
const clsOf = skillClsMap(m);
const photo = p.photo_b64
? `<img class="rd-photo" src="${p.photo_b64}" alt="证件照">`
: `<div class="rd-photo rd-photo-empty"><svg viewBox="0 0 24 24"><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3.6-7 8-7s8 3 8 7"/></svg><span>无照片</span></div>`;
const kv = (k, v) => `<td class="rd-k">${k}</td><td class="rd-v">${esc(v || "—")}</td>`;
const secs = (p.sections && p.sections.length)
? p.sections : [{ title: "简历全文", text: p.raw_masked_text }];
box.innerHTML = `
<table class="rd-table">
<tr>
<td class="rd-photo-cell" rowspan="3">${photo}</td>
${kv("姓名", p.name)}${kv("性别", p.gender)}
</tr>
<tr>${kv("学历", p.education)}${kv("工作年限", p.years ? p.years + " 年" : "")}</tr>
<tr>${kv("手机", p.phone)}${kv("邮箱", p.email)}</tr>
<tr><td class="rd-k">家庭住址</td><td class="rd-v" colspan="4">${esc(p.address || "—")}</td></tr>
${secs.map((sec) => `
<tr>
<td class="rd-sec-k">${esc(sec.title)}</td>
<td class="rd-sec-v" colspan="5">${highlightHtml(sec.text, clsOf)}</td>
</tr>`).join("")}
</table>`;
}
$("#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 = `<div class="text-weak text-aux">计算中…</div>`;
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 }) => `
<div class="rec-job-card" data-id="${esc(job.job_id)}">
<div class="rj-title">${esc(job.title)} <span class="tag tag-gray">${esc(job.category || "")}</span></div>
<div class="rj-score">${m.total_score}<span class="text-aux text-weak"> 分</span></div>
<div class="rj-miss">缺口:${m.missing_required.slice(0, 3).map(esc).join("、") || "无"}</div>
</div>`).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 };
/* 子页签切换 */
$$("#view-match .match-tab").forEach((b) => {
b.onclick = () => {
$$("#view-match .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) => `
<span class="batch-file">${esc(f.name)}<i>${(f.size / 1024).toFixed(0)}KB</i>
<button data-i="${i}" title="移除">×</button></span>`).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 = `<div class="empty small">批量解析与匹配中(${batchState.files.length} 份)…</div>`;
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 = `<div class="empty small">
<p>批量匹配失败:${esc(friendlyErr(e2))}</p>
<button class="btn btn-secondary btn-sm" onclick="document.querySelector('#btn-batch-run').click()">重试</button></div>`;
}
};
function renderBatchResult(data) {
$("#btn-batch-export").classList.remove("hidden");
const rows = data.results.map((r, i) => {
if (r.error) {
return `<tr class="batch-err"><td>${i + 1}</td><td colspan="2">${esc(r.filename)}</td>
<td colspan="5" class="text-weak">${esc(r.error)}</td></tr>`;
}
const pct = Math.round(r.total_score);
return `<tr>
<td>${i + 1}</td>
<td><b>${esc(r.name)}</b><div class="text-aux text-weak">${esc(r.filename)}</div></td>
<td><div class="batch-score"><div class="batch-score-bar" style="width:${pct}%"></div><b>${r.total_score}</b></div></td>
<td>${r.years || 0} 年</td>
<td class="text-ok">${(r.matched || []).slice(0, 4).map(esc).join("、") || "—"}</td>
<td class="text-bad">${(r.missing || []).slice(0, 4).map(esc).join("、") || "—"}</td>
<td>${r.is_match ? '<span class="tag tag-green">达标</span>' : '<span class="tag tag-orange">未达标</span>'}</td>
</tr>`;
}).join("");
$("#batch-result").innerHTML = `
<div class="batch-summary">共 ${data.total} 份,成功匹配 ${data.ok} 份,目标岗位:<b>${esc(data.job_title)}</b>(按总分降序)</div>
<table class="batch-table">
<thead><tr><th>#</th><th>候选人</th><th>总分</th><th>年限</th><th>命中技能</th><th>关键缺口</th><th>结论</th></tr></thead>
<tbody>${rows}</tbody>
</table>`;
}
$("#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 = `<div class="pool-loading">加载技能清单…</div>`;
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 = `<div class="pool-loading">
<p>技能清单暂时加载失败:${esc(friendlyErr(e))}</p>
<button class="btn btn-secondary btn-sm" id="path-pool-retry">重试</button></div>`;
$("#path-pool-retry").onclick = () => loadPathJob(jobId);
}
}
function renderSkillPool() {
const pool = $("#path-skill-pool");
const group = (title, skills) => `
<div class="pool-group">
<div class="pool-group-title">${title}(${skills.length})</div>
<div class="pool-chips">${skills.map((s) =>
`<span class="pool-chip${pathState.known.has(s) ? " known" : ""}" data-skill="${esc(s)}">${esc(s)}</span>`).join("")}</div>
</div>`;
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 = `<div class="empty small">正在生成学习路径:对齐岗位图谱 → 计算技能缺口 → 拆分阶段…</div>`;
try {
const data = await post("/api/learning-path", { job_id: jobId, known_skills: [...pathState.known] });
lastPathData = data;
renderPath(data);
} catch (e) {
$("#path-result").innerHTML = `<div class="empty">
<p>学习路径生成失败:${esc(friendlyErr(e))}</p>
<button class="btn btn-secondary btn-sm" id="path-retry">重试</button></div>`;
$("#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 ? `<div class="po-demo">示范路线 · 完成人岗诊断后将替换为你的个人结果</div>` : ""}
<div class="po-item">目标岗位<b>${esc(lastPathData.title)}</b></div>
<div class="po-item">当前匹配分<b class="po-primary">${cur}</b></div>
<div class="po-item">目标分<b>60 分</b></div>
<div class="po-item">预计周期<b>${pathState.weeks} 周</b></div>
<div class="po-progress">
<div class="progress"><div class="progress-bar" style="width:${pct}%"></div></div>
<div class="path-summary-tip">学习记录:已完成 ${done}/${n} 阶段(${pct}%)· 完成阶段后可重新匹配验证</div>
</div>`;
}
/* 未诊断时展示示范路线(当前选中岗,零已掌握),完成诊断后由个人结果替换 */
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 = `<div class="path-done">✓ 已覆盖「${esc(data.title)}」全部必备技能,无需补课</div>`;
return;
}
const weeks = stageWeeks(data.learning_path.length);
/* 阶段语义化命名:基础补齐 → 岗位核心(可多个)→ 项目验证 */
const stageName = (i, n) =>
i === 0 ? "基础补齐" : i === n - 1 ? "项目验证" : "岗位核心";
box.innerHTML = (data._demo ? `
<div class="path-demo-banner">
<b>示范路线</b>(以零已掌握技能生成):完成一次人岗诊断后,此处将自动替换为你的个人成长路线图
<button class="btn btn-secondary btn-sm" id="path-goto-match-2">去完成诊断</button>
</div>` : "") + data.learning_path.map((st, i) => {
const done = pathState.done.has(st.stage);
return `
<div class="stage-card${done ? " done" : ""}" data-stage="${st.stage}">
<span class="stage-no">${done ? "✓" : st.stage}</span>
<div class="stage-body">
<div class="stage-head">
<span class="stage-title">阶段 ${st.stage} · ${stageName(i, data.learning_path.length)}(${st.skills.length} 项技能)</span>
<span class="stage-weeks">建议 ${weeks[i]} 周</span>
</div>
${i > 0 ? `<div class="stage-prereq">前置:建议完成阶段 ${st.stage - 1} 后开始(按技能前置关系拓扑排序)</div>` : ""}
<div class="stage-skills">${st.skills.map((s) =>
`<span class="tag tag-blue" data-skill="${esc(s)}" title="点击跳转图谱节点">${esc(s)}</span>`).join("")}</div>
<div class="stage-meta">
<span>预计投入:<b>${stageEffortText(weeks[i])}</b></span>
<span>资源类型:<b>${RESOURCE_TYPES[i % RESOURCE_TYPES.length]}</b></span>
<span>技能性质:<b>必备补齐</b></span>
${st.acceptance ? `<span>能力类别:<b>${esc(st.acceptance.category)}</b></span>` : ""}
</div>
${st.acceptance ? `
<details class="stage-acc" ${i === 0 ? "open" : ""}>
<summary>验收标准(产出物 1 项 · 量化验收 ${st.acceptance.criteria.length} 项 · 自测 ${st.acceptance.self_check.length} 问)</summary>
<div class="acc-block">
<div class="acc-title">产出物</div>
<div class="acc-deliverable">${esc(st.acceptance.deliverable)}</div>
<div class="acc-title">量化验收项</div>
<ul class="acc-list">${st.acceptance.criteria.map((c, k) =>
`<li><span class="acc-no">${k + 1}</span>${esc(c)}</li>`).join("")}</ul>
<div class="acc-title">自测问答(能口头讲清才算通过)</div>
<ul class="acc-list quiz">${st.acceptance.self_check.map((q, k) =>
`<li><span class="acc-no">Q${k + 1}</span>${esc(q)}</li>`).join("")}</ul>
</div>
</details>` : ""}
<div class="stage-foot">
<button class="btn ${done ? "btn-secondary" : "btn-primary"} btn-sm stage-check" data-stage="${st.stage}">
${done ? "已完成 ✓(点击取消)" : "标记完成"}</button>
</div>
</div>
</div>`;
}).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");
$$("#view-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 `<span class="tag tag-green">已就绪(${emb.provider === "api" ? "联网 API" : "本地权重"},语义召回已启用)</span>`;
}
if (emb.provider === "api") {
return emb.api_key_masked
? `<span class="tag tag-orange">已配置 Key,保存后生效</span>`
: `<span class="tag tag-gray">未配置 API Key(当前使用词表归一化匹配)</span>`;
}
if (emb.configured) {
const reasons = [
!emb.weights_present ? "权重文件缺失" : "",
!emb.deps_installed ? "未安装 sentence-transformers" : "",
].filter(Boolean).join(";");
return `<span class="tag tag-orange">已配置未就绪:${esc(reasons)}</span>`;
}
return `<span class="tag tag-gray">未配置(当前使用词表归一化匹配)</span>`;
}
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
? `<span class="tag tag-orange">离线回退</span>` : `<span class="tag tag-green">联网就绪</span>`;
openModal(firstRun ? "欢迎使用 CareerNexus 职联 · 初始设置" : "系统设置", `
${firstRun ? `<div class="cfg-welcome">
<b>首次使用,建议先完成基础配置</b>(均可稍后通过右上角 ⚙ 修改):
<br>· 配置 <b>LLM API 密钥</b>可启用大模型抽取(不配则离线确定性方案兜底)
<br>· 配置<b>语义向量模型</b>(本地 BGE-M3 或联网 API)可启用语义相似匹配
<br>· 需要对外提供服务时,再调整<b>监听地址与访问令牌</b>
</div>` : ""}
<div class="cfg-status">
<span>当前监听 <b>${esc(cfg.running.host)}:${cfg.running.port}</b></span>
<span>LLM 状态 ${modeBadge}</span>
${cfg.restart_required ? `<span class="tag tag-orange">监听变更待重启</span>` : ""}
</div>
${needToken ? `<div class="cfg-warn">该服务已启用访问令牌,请填写后保存。</div>` : ""}
<div class="cfg-sec">
<div class="cfg-sec-title">服务监听(修改后需重启生效)</div>
<div class="form-item">
<label class="form-label">监听地址</label>
<div class="seg" id="cfg-host">
<button class="seg-item ${cfg.saved.host === "127.0.0.1" ? "active" : ""}" data-host="127.0.0.1">127.0.0.1(仅本机)</button>
<button class="seg-item ${cfg.saved.host === "0.0.0.0" ? "active" : ""}" data-host="0.0.0.0">0.0.0.0(对外开放)</button>
</div>
</div>
<div class="form-item">
<label class="form-label">端口</label>
<input id="cfg-port" type="number" class="input" min="1024" max="65535" value="${cfg.saved.port}">
</div>
<div class="form-item">
<label class="form-label">访问令牌<span class="form-label-tip">对外开放时建议设置;${cfg.has_access_token ? "当前:" + esc(cfg.access_token_masked) : "当前未设置"}</span></label>
<div class="cfg-token-row">
<input id="cfg-token" class="input" type="password" placeholder="留空则清除令牌" autocomplete="new-password">
<button class="btn btn-secondary btn-sm" id="cfg-token-gen">随机生成</button>
<button class="btn btn-secondary btn-sm" id="cfg-token-show">显示</button>
</div>
</div>
<div class="form-item">
<label class="form-label">演示模式<span class="form-label-tip">开启后不弹初始设置、警告降级为顶栏状态点</span></label>
<div class="seg" id="cfg-demo">
<button class="seg-item ${cfg.demo_mode !== false ? "active" : ""}" data-demo="1">开启(评审演示)</button>
<button class="seg-item ${cfg.demo_mode === false ? "active" : ""}" data-demo="0">关闭(开发调试)</button>
</div>
</div>
</div>
<div class="cfg-sec">
<div class="cfg-sec-title">LLM API(星火 / OpenAI 兼容,保存即热生效)</div>
<div class="form-item">
<label class="form-label">Base URL</label>
<input id="cfg-llm-url" class="input" value="${esc(cfg.llm.base_url)}" placeholder="https://spark-api-open.xf-yun.com/v1">
</div>
<div class="form-item">
<label class="form-label">API Key<span class="form-label-tip">${cfg.llm.configured ? "当前:" + esc(cfg.llm.api_key_masked) : "未配置(离线回退中)"}</span></label>
<input id="cfg-llm-key" class="input" type="password" placeholder="留空保持不变;输入空格清除" autocomplete="new-password">
</div>
<div class="form-item">
<label class="form-label">模型</label>
<input id="cfg-llm-model" class="input" value="${esc(cfg.llm.model)}" placeholder="4.0Ultra">
</div>
</div>
<div class="cfg-sec">
<div class="cfg-sec-title">语义向量匹配(可选增强,保存即热生效)</div>
<div class="form-item">
<label class="form-label">状态</label>
<div id="cfg-embed-status">${embedStatusHtml(cfg.embed)}</div>
</div>
<div class="form-item">
<label class="form-label">向量来源</label>
<div class="seg" id="cfg-embed-provider">
<button class="seg-item ${cfg.embed.provider !== "api" ? "active" : ""}" data-provider="local">本地权重(BGE-M3)</button>
<button class="seg-item ${cfg.embed.provider === "api" ? "active" : ""}" data-provider="api">联网 API(通义千问)</button>
</div>
</div>
<div id="cfg-embed-local" class="${cfg.embed.provider === "api" ? "hidden" : ""}">
<div class="setup-hero">
<button class="btn btn-primary" id="cfg-embed-setup">⚡ 一键下载并配置</button>
<div class="setup-hero-desc">
自动完成全部配置,<b>推荐首次使用</b>:
<br>① 安装 sentence-transformers 依赖(PyPI 镜像站)
<br>② 从 ModelScope 镜像站下载 BGE-M3 权重(约 2.2GB)
<br>③ 自动写入配置并启用语义匹配。全程约 3–8 分钟,期间可继续使用其他功能。
</div>
<div class="setup-progress hidden" id="setup-progress">
<div class="setup-stage" id="setup-stage">准备中…</div>
<div class="setup-log" id="setup-log"></div>
</div>
</div>
<div class="form-item">
<label class="form-label">权重目录<span class="form-label-tip">一键配置后自动填入;手动方式见下方说明</span></label>
<input id="cfg-embed-path" class="input" value="${esc(cfg.embed.path || "")}" placeholder="如 F:/models/bge-m3">
</div>
<div class="cfg-guide">
<b>手动配置方式(不使用一键配置时)</b>
<ol>
<li>安装依赖:<code>pip install sentence-transformers</code></li>
<li>下载权重(约 2.2GB):ModelScope(国内推荐)<code>modelscope.cn/models/BAAI/bge-m3</code> 或 HuggingFace <code>huggingface.co/BAAI/bge-m3</code></li>
<li>目录内需包含 <code>config.json</code>、<code>model.safetensors</code> 与分词器文件,填入上方路径保存即可</li>
</ol>
</div>
</div>
<div id="cfg-embed-api" class="${cfg.embed.provider === "api" ? "" : "hidden"}">
<div class="form-item">
<label class="form-label">API Key<span class="form-label-tip">${cfg.embed.api_key_masked ? "当前:" + esc(cfg.embed.api_key_masked) : "未配置"}</span></label>
<input id="cfg-embed-key" class="input" type="password" placeholder="DashScope API Key(sk-...),留空保持不变" autocomplete="new-password">
</div>
<div class="form-item">
<label class="form-label">Base URL</label>
<input id="cfg-embed-base" class="input" value="${esc(cfg.embed.api_base_url)}" placeholder="https://dashscope.aliyuncs.com/compatible-mode/v1">
</div>
<div class="form-item">
<label class="form-label">模型</label>
<input id="cfg-embed-model" class="input" value="${esc(cfg.embed.api_model)}" placeholder="text-embedding-v4">
</div>
<div class="cfg-guide">
<b>联网 API 配置引导</b>
<ol>
<li>开通阿里云百炼(DashScope),在控制台获取 API Key</li>
<li>默认接口为 OpenAI 兼容模式,模型推荐 <code>text-embedding-v4</code></li>
<li>填入 Key 保存即生效;网络不可达时自动回退词表归一化</li>
</ol>
</div>
</div>
</div>
<div class="cfg-foot-note">如遇问题,请联系 support@rrrosmontis.icu</div>`,
`${firstRun ? `<button class="btn btn-secondary" id="cfg-skip">跳过,使用离线演示模式</button>` : ""}
<button class="btn btn-secondary" id="cfg-save-restart">保存并重启</button>
<button class="btn btn-primary" id="cfg-save">保存设置</button>`);
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) => `<option value="${esc(j.job_id)}">${esc(j.title)}</option>`).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");
}
})();