Files
CareerNexus/webui/app.js
T
RoyirinRosmontis 3329c55e06 竞赛演示模式:首屏降噪与预置导览
- demo_mode 配置项(默认开):不弹初始设置,LLM/语义警告横幅降级为顶栏状态点
- 顶栏新增'开始演示'导览(驾驶舱→新岗位发现→Java演化→匹配诊断四步预置路线)
- 图谱默认降噪:场景/行业图层默认关、关系标签默认关(悬停/缩放显示)
- 演化页默认锁定 Java 强样例;数据版本徽标标注'(模拟)';联系方式移入设置页
- start.bat/start.sh 启动脚本
2026-08-12 16:25:37 +08:00

1801 lines
84 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));
async function api(path, options) {
options = options || {};
options.headers = Object.assign(
{ "X-Access-Token": localStorage.getItem("cn_access_token") || "" },
options.headers);
const resp = await fetch(path, options);
if (resp.status === 401) {
openConfigModal(true);
throw new Error("需要访问令牌:请在系统设置中填写");
}
const body = await resp.json().catch(() => ({ code: -1, message: "响应解析失败" }));
if (!resp.ok || body.code !== 0) throw new Error(body.detail || body.message || `请求失败 (${resp.status})`);
return body.data;
}
function esc(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) =>
({ "&": "&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 证据弹窗(演化事件 / 图谱来源共用) */
async function showJdEvidence(sid) {
openModal(`证据来源 · ${esc(sid)}`, `<div class="text-weak">加载中…</div>`);
try {
const rec = await api(`/api/jd?sid=${encodeURIComponent(sid)}`);
$("#modal-body").innerHTML = `
<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>
<span>发布 ${esc(rec.publish_date)}</span><span>来源 ${esc(rec.source || "—")}</span>
</div>
<div class="jd-evidence">${esc(rec.text)}</div>`;
} catch (e) {
$("#modal-body").innerHTML = `<div class="empty small">原文读取失败:${esc(e.message)}</div>`;
}
}
CN.showJdEvidence = showJdEvidence;
/* ================= 视图切换 / 侧栏 ================= */
const PAGE_TITLES = {
dashboard: "数据驾驶舱", graph: "全景图谱", evolution: "岗位演化",
match: "人岗匹配", path: "学习路径",
};
const _loaded = {};
function switchView(name) {
$$(".nav-item").forEach((b) => b.classList.toggle("active", b.dataset.view === name));
$$(".view").forEach((v) => v.classList.remove("active"));
$("#view-" + name).classList.add("active");
$("#page-title").textContent = PAGE_TITLES[name] || "";
/* 视图记入 URL hash,刷新后回到同一页(node 参数在 graph.js 内维护) */
const hash = new URLSearchParams(location.hash.slice(1));
if (hash.get("view") !== name) {
hash.set("view", name);
if (name !== "graph") hash.delete("node");
history.replaceState(null, "", "#" + hash.toString());
}
if (name === "graph" && CN.graph && !_loaded.graph) { _loaded.graph = true; CN.graph.init(); }
if (name === "graph" && CN.graph && _loaded.graph) CN.graph.onShow();
if (name === "path" && !_loaded.path) { _loaded.path = true; loadPathJob($("#path-job-select").value); }
}
CN.switchView = switchView;
$$(".nav-item").forEach((btn) => { btn.onclick = () => switchView(btn.dataset.view); });
/* ================= 全局搜索 ================= */
(function initSearch() {
const input = $("#global-search-input"), drop = $("#global-search-drop");
let timer = null;
input.addEventListener("input", () => {
clearTimeout(timer);
timer = setTimeout(async () => {
const kw = input.value.trim();
if (!kw) { drop.classList.add("hidden"); return; }
try {
if (!CN.graphData) CN.graphData = await api("/api/graph");
const hits = CN.graphData.nodes
.filter((n) => (n.title || n.id).toLowerCase().includes(kw.toLowerCase()))
.slice(0, 8);
drop.innerHTML = hits.length ? hits.map((n, i) => {
const m = TYPE_META[n.type] || { name: n.type, tag: "tag-gray" };
return `<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");
});
})();
/* ================= 数据驾驶舱 ================= */
const METRIC_DEFS = [
{ key: "jobs", label: "岗位节点", view: "graph", icon: '<svg viewBox="0 0 24 24"><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V5a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>' },
{ key: "skills", label: "技能节点", view: "graph", icon: '<svg viewBox="0 0 24 24"><path d="M12 2l2.4 4.9 5.4.8-3.9 3.8.9 5.4-4.8-2.5-4.8 2.5.9-5.4L4.2 7.7l5.4-.8L12 2z"/></svg>' },
{ key: "edges", label: "图谱关系边", view: "graph", icon: '<svg viewBox="0 0 24 24"><circle cx="6" cy="6" r="2.5"/><circle cx="18" cy="18" r="2.5"/><path d="M8 8l8 8"/></svg>' },
{ key: "events", label: "演化事件", view: "evolution", icon: '<svg viewBox="0 0 24 24"><path d="M3 17l5.5-5.5 3.5 3.5L20 7"/><path d="M15 7h5v5"/></svg>' },
{ key: "pending", label: "待审核候选", view: "graph", icon: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>' },
];
function sparkline(canvas, values, color) {
const ctx = canvas.getContext("2d");
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
if (!values.length) return;
const max = Math.max(1, ...values), min = Math.min(...values);
ctx.beginPath();
values.forEach((v, i) => {
const x = (i / Math.max(1, values.length - 1)) * (W - 4) + 2;
const y = H - 3 - ((v - min) / Math.max(1e-9, max - min)) * (H - 8);
ctx[i ? "lineTo" : "moveTo"](x, y);
});
ctx.strokeStyle = color; ctx.lineWidth = 1.6; ctx.stroke();
}
function quarterlyCounts(events) {
const kv = {};
events.forEach((e) => { kv[e.window] = (kv[e.window] || 0) + 1; });
return Object.keys(kv).sort().map((k) => [k, kv[k]]);
}
async function loadDashboard() {
const [health, graph, pending, audit] = await Promise.all([
api("/api/health"), api("/api/graph"),
api("/api/review/pending").catch(() => []),
api("/api/audit?limit=8").catch(() => []),
]);
CN.graphData = graph;
const s = health.graph || {};
updateBanners(health);
$("#data-version").textContent = `数据更新至 ${(graph.events.at(-1) || {}).window || "2026Q3"}(模拟)`;
/* 指标卡 */
const series = quarterlyCounts(graph.events || []);
const last12 = series.slice(-12).map(([, c]) => c);
const values = { jobs: s.jobs, skills: s.skills, edges: s.edges, events: s.events, pending: pending.length };
$("#metric-cards").innerHTML = METRIC_DEFS.map((m, i) => {
const cur = last12.at(-1) ?? 0, prev = last12.at(-2) ?? cur;
const delta = m.key === "events" || m.key === "pending" ? cur - prev : 0;
const cls = delta > 0 ? "up" : delta < 0 ? "down" : "flat";
const arrow = delta > 0 ? "↑" : delta < 0 ? "↓" : "—";
const deltaTxt = m.key === "events"
? `<span class="mc-delta ${cls}">${arrow} 环比上季 ${Math.abs(delta)}</span>`
: `<span class="mc-delta flat">实时统计</span>`;
return `<div class="metric-card" data-view="${m.view}">
<div class="mc-label">${m.icon}${m.label}</div>
<div class="mc-num">${values[m.key] ?? "—"}</div>${deltaTxt}
<canvas width="90" height="30" data-spark="${i}"></canvas></div>`;
}).join("");
$$("#metric-cards .metric-card").forEach((el) => {
el.onclick = () => switchView(el.dataset.view);
sparkline(el.querySelector("canvas"), last12.length ? last12 : [0, 0], "#2f54eb");
});
$("#pending-count").textContent = `${pending.length} 待办`;
/* 趋势图(近 12 季度事件数,柱状) */
CN._trend = series.slice(-12);
drawTrend($("#trend-canvas"), CN._trend);
/* 任务与审核状态表 */
renderTaskTable(audit);
$("#btn-import-jd").onclick = () => openImportDialog();
/* 动态消息流 */
renderFeed(graph.events || [], audit);
/* 待审核候选 */
renderPending(pending);
}
/* JD 数据导入对话框:上传文件 / 粘贴文本 / 直接全量重建 */
async function openImportDialog() {
const choice = await sysDialog({
title: "JD 数据导入",
okText: "开始导入",
bodyHtml: `
<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, audit) {
const items = [];
events.slice(-8).reverse().forEach((e) => items.push({
icon: "#2f54eb", bg: "#f0f5ff", kind: "evo",
text: `<b>${esc(e.job_id.replace(/^job_/, ""))}</b> ${esc(e.change_type)}技能「${esc(e.skill)}」`,
time: e.window, job: e.job_id,
}));
(audit || []).slice(0, 4).forEach((r) => items.push({
icon: "#00b42a", bg: "#e8ffea", kind: "audit",
text: r.kind === "node_update"
? `<b>${esc(r.operator)}</b> 编辑了节点「${esc(r.node_id)}」`
: `<b>${esc(r.operator)}</b> ${{ accept: "通过", reject: "驳回", modify: "修改" }[r.action] || r.action}了候选「${esc((r.candidate_id || "").replace(/^cand_/, ""))}」`,
time: r.time || "",
}));
items.sort((a, b) => (b.time > a.time ? 1 : -1));
$("#activity-feed").innerHTML = items.slice(0, 10).map((it, i) => `
<div class="feed-item" data-i="${i}">
<span class="fi-icon" style="color:${it.icon};background:${it.bg}">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/></svg></span>
<div><div class="fi-text">${it.text}</div><div class="fi-time">${esc(it.time)}</div></div>
</div>`).join("") || `<div class="empty small">暂无动态</div>`;
$$("#activity-feed .feed-item").forEach((el) => {
el.onclick = () => {
const it = items[+el.dataset.i];
if (it.kind === "evo" && it.job) {
switchView("evolution");
$("#evo-job-select").value = it.job;
loadEvolution();
}
};
});
}
function renderPending(pending) {
const box = $("#pending-list");
if (!pending.length) {
box.innerHTML = `<div class="empty small">暂无待审核候选(可在图谱页触发新岗位发现)</div>`;
return;
}
box.innerHTML = pending.map((c) => `
<div class="review-item">
<div class="ri-head"><b>${esc(c.title)}</b><span class="tag tag-orange">待审核</span>
<span class="text-weak text-aux">${esc(c.category || "")}</span></div>
<div class="ri-meta">支撑 ${c.source_count} 条 JD · ${c.company_count} 家企业 · 期望年限 ${c.expected_years} 年</div>
<div class="ri-skills">必备:${c.required_skills.map((s) => `<span class="tag tag-blue">${esc(s)}</span>`).join(" ")}
${c.preferred_skills.length ? `<br>加分:${c.preferred_skills.map((s) => `<span class="tag tag-gray">${esc(s)}</span>`).join(" ")}` : ""}</div>
<div class="ri-actions">
<button class="btn btn-primary btn-sm" data-act="accept" data-id="${esc(c.job_id)}">通过入库</button>
<button class="btn btn-secondary btn-sm" data-act="reject" data-id="${esc(c.job_id)}">驳回</button>
<button class="btn btn-text btn-sm" data-locate="${esc(c.job_id)}">在图谱中查看</button>
</div>
</div>`).join("");
box.querySelectorAll("[data-act]").forEach((b) => {
b.onclick = () => doReview(b.dataset.id, b.dataset.act);
});
box.querySelectorAll("[data-locate]").forEach((b) => {
b.onclick = () => { switchView("graph"); if (CN.gotoNode) CN.gotoNode(b.dataset.locate); };
});
}
async function doReview(id, action) {
const operator = await CN.sysPrompt({ title: "审核确认", label: "请输入审核人姓名:", value: "评审专家" });
if (operator === null) return;
let reason = "webui 审核通过";
if (action === "reject") {
reason = await CN.sysPrompt({ title: "驳回候选", label: "请输入驳回原因:", value: "证据不足" });
if (reason === null) return;
}
try {
await post("/api/review", { candidate_id: id, action, operator: operator || "评审专家", reason });
toast(`审核完成:${action === "accept" ? "已通过入库" : "已驳回"}`, "success");
loadDashboard();
if (CN.graph && CN.graph.reload) CN.graph.reload();
} catch (e) { toast(e.message, "error"); }
}
CN.doReview = doReview;
/* 画布自适应:占满父容器(宽 100%、高 100%)+ dpr 高清,返回 CSS 像素坐标系下的 ctx/W/H */
function fitCanvas(canvas, cssH) {
const dpr = window.devicePixelRatio || 1;
canvas.style.width = "100%";
canvas.style.height = "100%";
const W = canvas.clientWidth || 600;
const H = (canvas.clientHeight >= 120 ? canvas.clientHeight : cssH);
canvas.width = W * dpr;
canvas.height = H * dpr;
const ctx = canvas.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return { ctx, W, H };
}
function drawTrend(canvas, kv) {
const { ctx, W, H } = fitCanvas(canvas, 220);
ctx.clearRect(0, 0, W, H);
if (!kv.length) return;
const max = Math.max(1, ...kv.map(([, c]) => c));
const bw = W / kv.length;
ctx.font = "11px sans-serif"; ctx.textAlign = "center";
kv.forEach(([k, c], i) => {
const h = (c / max) * (H - 46);
const x = i * bw + bw * 0.18, y = H - h - 24;
const grad = ctx.createLinearGradient(0, y, 0, H - 24);
grad.addColorStop(0, "#2f54eb"); grad.addColorStop(1, "#85a5ff");
ctx.fillStyle = grad;
ctx.beginPath();
ctx.roundRect(x, y, bw * 0.64, h, [3, 3, 0, 0]);
ctx.fill();
ctx.fillStyle = "#86909c";
ctx.fillText(k.slice(2), i * bw + bw / 2, H - 8);
ctx.fillStyle = "#4e5969";
ctx.fillText(c, i * bw + bw / 2, y - 5);
});
}
/* ================= 岗位演化 ================= */
const CHANGE_META = {
"新增": { tag: "tag-green", color: "#00b42a" },
"增强": { tag: "tag-blue", color: "#2f54eb" },
"减弱": { tag: "tag-orange", color: "#ff7d00" },
"退出": { tag: "tag-red", color: "#f53f3f" },
};
let evoEvents = [];
/* 全部岗位 × 季度 演化热力图 */
function renderEvoHeatmap() {
const box = $("#evo-heatmap");
const events = (CN.graphData && CN.graphData.events) || [];
const jobs = CN.jobs || [];
if (!events.length || !jobs.length) {
box.innerHTML = `<div class="empty small">暂无演化事件数据</div>`;
return;
}
const windows = [...new Set(events.map((e) => e.window))].sort();
// 统计每个岗位每季度事件数与类型分布
const cell = {};
events.forEach((e) => {
const k = `${e.job_id}|${e.window}`;
cell[k] = cell[k] || { n: 0, types: {} };
cell[k].n += 1;
cell[k].types[e.change_type] = (cell[k].types[e.change_type] || 0) + 1;
});
// 按总事件数降序排列岗位
const totals = {};
events.forEach((e) => { totals[e.job_id] = (totals[e.job_id] || 0) + 1; });
const rows = [...jobs].sort((a, b) => (totals[b.job_id] || 0) - (totals[a.job_id] || 0));
const labelEvery = Math.ceil(windows.length / 10);
// 色阶:开方缩放 + 12 封顶,避免个别高峰季度(如 57 件)把其余格子压到看不清
const alphaOf = (n) => 0.18 + 0.82 * Math.sqrt(Math.min(n, 12) / 12);
box.innerHTML = `
<div class="hm-grid" style="grid-template-columns: 132px repeat(${windows.length}, 1fr)">
<div class="hm-corner"></div>
${windows.map((w, i) => `<div class="hm-col-label">${i % labelEvery === 0 ? esc(w) : ""}</div>`).join("")}
${rows.map((j) => `
<div class="hm-row-label ${j.job_id === $("#evo-job-select").value ? "active" : ""}"
data-job="${esc(j.job_id)}" title="点击查看「${esc(j.title)}」演化详情">${esc(j.title)}</div>
${windows.map((w) => {
const c = cell[`${j.job_id}|${w}`];
const tip = c
? `${j.title} · ${w}:${c.n} 个事件(${Object.entries(c.types).map(([t, n]) => `${t} ${n}`).join(",")})`
: `${j.title} · ${w}:无事件`;
return `<div class="hm-cell" data-n="${c ? c.n : 0}" title="${esc(tip)}"
style="background:${c ? `rgba(47,84,235,${alphaOf(c.n).toFixed(3)})` : "var(--bg-page)"}"></div>`;
}).join("")}`).join("")}
</div>
<div class="hm-legend">
<span>事件数:</span><i class="hm-l" style="background:rgba(47,84,235,0.12)"></i>少
<i class="hm-l" style="background:rgba(47,84,235,0.5)"></i>
<i class="hm-l" style="background:rgba(47,84,235,1)"></i>多
<span class="text-weak">(悬停查看类型分布,点击行标签切换岗位)</span>
</div>`;
box.querySelectorAll(".hm-row-label").forEach((el) => {
el.onclick = () => {
$("#evo-job-select").value = el.dataset.job;
loadEvolution().then(renderEvoHeatmap);
};
});
}
function bucketWindow(w, gran) {
if (gran === "year") return w.slice(0, 4);
if (gran === "month") { /* 季度窗口展开为季中月 */ return `${w.slice(0, 4)}-${String((+w.slice(5) - 1) * 3 + 2).padStart(2, "0")}`; }
return w;
}
async function loadEvolution() {
const id = $("#evo-job-select").value;
if (!id) return;
const data = await api(`/api/jobs/${encodeURIComponent(id)}/evolution`);
evoEvents = data.events;
renderEvoStats(id);
renderEvolution();
runEvoCompare(); // 默认对比:最早事件前 → 今天
}
function renderEvoStats(jobId) {
const job = (CN.jobs || []).find((j) => j.job_id === jobId) || {};
const wins = [...new Set(evoEvents.map((e) => e.window))].sort();
const recent = new Set(wins.slice(-4));
const cnt = (t) => evoEvents.filter((e) => e.change_type === t && recent.has(e.window)).length;
const jds = new Set(evoEvents.flatMap((e) => e.source_ids || []));
const items = [
["当前必备技能", `${(job.required_skills || []).length} 项`],
["当前加分技能", `${(job.preferred_skills || []).length} 项`],
["近 4 季度新增", cnt("新增")],
["近 4 季度退出", cnt("退出")],
["证据 JD 总量", `${jds.size} 条`],
];
$("#evo-stats").innerHTML = items.map(([k, v]) =>
`<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">对比失败:${esc(e.message)}</div>`;
}
}
$("#evo-compare-btn").onclick = runEvoCompare;
$("#evo-date-a").addEventListener("change", runEvoCompare);
$("#evo-date-b").addEventListener("change", runEvoCompare);
$("#evo-job-select").onchange = loadEvolution;
$$("#evo-gran button").forEach((b) => {
b.onclick = () => {
$$("#evo-gran button").forEach((x) => x.classList.toggle("active", x === b));
renderEvolution();
};
});
function renderEvolution() {
const gran = $$("#evo-gran button").find((b) => b.classList.contains("active"))?.dataset.gran || "quarter";
drawStackBars($("#evo-stack-canvas"), evoEvents);
renderEvoGantt($("#evo-job-select").value);
/* 热度走势:每季度实际生效的技能关系数(按 REQUIRES 边的生效区间统计) */
const jobEdges = ((CN.graphData || {}).edges || [])
.filter((e) => e.source === $("#evo-job-select").value && e.rel === "REQUIRES" && (e.intervals || []).length);
const allWins = [...new Set(((CN.graphData || {}).events || []).map((e) => e.window))].sort();
const winKey = (w) => bucketWindow(w, gran);
const buckets = {};
allWins.forEach((w) => {
buckets[winKey(w)] = jobEdges.filter((e) =>
e.intervals.some(([s, en]) => s <= w && (en === null || w < en))).length;
});
CN._heat = Object.entries(buckets).sort((a, b) => (a[0] < b[0] ? -1 : 1));
drawHeatLine($("#evo-heat-canvas"), CN._heat);
/* 时间轴 */
const box = $("#evo-timeline");
box.innerHTML = evoEvents.length ? evoEvents.map((e, i) => {
const m = CHANGE_META[e.change_type] || { tag: "tag-gray" };
return `<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)}</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];
const sids = e.source_ids || [];
if (!sids.length) return toast("该事件无关联证据", "info");
if (sids.length === 1) return showJdEvidence(sids[0]);
openModal(`证据来源 · ${esc(e.skill)}(${sids.length} 条)`,
sids.map((s) => `<div class="src-item" data-sid="${esc(s)}">
<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>${esc(s)}</div>`).join(""));
$$("#modal-body .src-item").forEach((x) => { x.onclick = () => showJdEvidence(x.dataset.sid); });
};
});
}
function drawHeatLine(canvas, kv) {
const { ctx, W, H } = fitCanvas(canvas, 200);
ctx.clearRect(0, 0, W, H);
if (!kv.length) return;
const pad = { l: 40, r: 16, t: 16, b: 28 };
const max = Math.max(1, ...kv.map(([, v]) => v));
/* 网格与 y 轴 */
ctx.strokeStyle = "#f0f0f0"; ctx.fillStyle = "#86909c"; ctx.font = "11px sans-serif";
for (let g = 0; g <= 4; g++) {
const y = pad.t + ((H - pad.t - pad.b) * g) / 4;
ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(W - pad.r, y); ctx.stroke();
ctx.textAlign = "right";
ctx.fillText(Math.round(max * (1 - g / 4)), pad.l - 6, y + 4);
}
const px = (i) => pad.l + (i / Math.max(1, kv.length - 1)) * (W - pad.l - pad.r);
const py = (v) => pad.t + (1 - v / max) * (H - pad.t - pad.b);
/* 面积 + 折线 */
ctx.beginPath();
kv.forEach(([, v], i) => ctx[i ? "lineTo" : "moveTo"](px(i), py(v)));
ctx.strokeStyle = "#2f54eb"; ctx.lineWidth = 2; ctx.stroke();
ctx.lineTo(px(kv.length - 1), H - pad.b); ctx.lineTo(px(0), H - pad.b); ctx.closePath();
const grad = ctx.createLinearGradient(0, pad.t, 0, H - pad.b);
grad.addColorStop(0, "rgba(47,84,235,0.25)"); grad.addColorStop(1, "rgba(47,84,235,0.02)");
ctx.fillStyle = grad; ctx.fill();
/* 点与 x 轴 */
ctx.textAlign = "center";
kv.forEach(([k, v], i) => {
ctx.fillStyle = "#2f54eb";
ctx.beginPath(); ctx.arc(px(i), py(v), 3, 0, 7); ctx.fill();
if (kv.length <= 14 || i % Math.ceil(kv.length / 14) === 0) {
ctx.fillStyle = "#86909c"; ctx.fillText(k, px(i), H - 8);
}
});
}
/* 变更类型构成堆叠柱状图(按季度) */
function drawStackBars(canvas, events) {
const { ctx, W, H } = fitCanvas(canvas, 220);
ctx.clearRect(0, 0, W, H);
const windows = [...new Set(events.map((e) => e.window))].sort();
$("#stack-legend").innerHTML = Object.entries(CHANGE_META).map(([t, m]) =>
`<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);
});
}
/* 技能生命周期甘特图 */
function renderEvoGantt(jobId) {
const box = $("#evo-gantt");
const g = CN.graphData || {};
const allEvents = g.events || [];
const windows = [...new Set(allEvents.map((e) => e.window))].sort();
const edges = (g.edges || []).filter((e) => e.source === jobId && e.rel === "REQUIRES" && (e.intervals || []).length);
if (!windows.length || !edges.length) {
box.innerHTML = `<div class="empty small">暂无该岗位的技能生命周期数据</div>`;
return;
}
// 排序:当前仍生效的在前(按起始窗口升序),已退出的在后;限 14 行
const alive = (e) => e.intervals.some(([s, en]) => en === null);
edges.sort((a, b) => {
const d = (alive(b) ? 1 : 0) - (alive(a) ? 1 : 0);
if (d) return d;
return (a.intervals[0][0] < b.intervals[0][0] ? -1 : 1);
});
const rows = edges.slice(0, 14);
const labelEvery = Math.ceil(windows.length / 12);
// 事件 → 技能 × 窗口 的圆点
const dots = {};
evoEvents.forEach((e) => { dots[`${e.skill}|${e.window}`] = e.change_type; });
const cellHtml = (edge, w) => {
const active = edge.intervals.some(([s, en]) => s <= w && (en === null || w < en));
const dotType = dots[`${edge.target}|${w}`];
const barColor = edge.importance === "required" ? "var(--primary)" : "var(--cyan)";
const dot = dotType
? `<i class="gt-dot" style="background:${(CHANGE_META[dotType] || {}).color || "#999"}" title="${w} ${dotType}"></i>` : "";
return `<div class="gt-cell" title="${edge.target} · ${w}${active ? "(生效中)" : ""}">
${active ? `<div class="gt-bar" style="background:${barColor}"></div>` : ""}${dot}</div>`;
};
box.innerHTML = `
<div class="gt-grid" style="grid-template-columns: 110px repeat(${windows.length}, 1fr)">
<div class="hm-corner"></div>
${windows.map((w, i) => `<div class="hm-col-label">${i % labelEvery === 0 ? esc(w.slice(2)) : ""}</div>`).join("")}
${rows.map((e) => `
<div class="gt-label" data-skill="${esc(e.target)}" title="点击跳转图谱节点">${esc(e.target)}</div>
${windows.map((w) => cellHtml(e, w)).join("")}`).join("")}
</div>
<div class="hm-legend">
<span>生效区间:</span><i class="hm-l" style="background:var(--primary)"></i>必备
<i class="hm-l" style="background:var(--cyan)"></i>加分
<span style="margin-left:10px">圆点:</span>
${Object.entries(CHANGE_META).map(([t, m]) => `<i class="gt-dot" style="background:${m.color};position:static"></i>${t}`).join("")}
</div>`;
box.querySelectorAll(".gt-label").forEach((el) => {
el.onclick = () => { switchView("graph"); if (CN.gotoNode) CN.gotoNode(el.dataset.skill); };
});
}
/* ================= 人岗匹配(三步向导) ================= */
const matchState = { step: 1, skills: [], years: 0, result: null, profileText: "" };
let lastProfile = null; // 最近一次解析的完整简历画像(结构化模板与导出用)
function setMatchStep(n) {
matchState.step = n;
$$("#match-steps .step").forEach((s) => {
const i = +s.dataset.step;
s.classList.toggle("active", i === n);
s.classList.toggle("done", i < n);
if (i < n) s.querySelector("i").innerHTML = '<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");
};
$("#resume-file").addEventListener("change", async (e) => {
const f = e.target.files[0];
e.target.value = "";
if (!f) return;
const ext = (f.name.split(".").pop() || "").toLowerCase();
if (["txt", "md"].includes(ext)) { // 纯文本:本地读取,走文本解析
const reader = new FileReader();
reader.onload = () => { $("#resume-text").value = reader.result; toast(`已读取 ${f.name}`, "success"); };
reader.readAsText(f);
return;
}
if (["docx", "pdf"].includes(ext)) { // 二进制格式:上传后端解析
try {
toast(`正在解析 ${f.name}…`);
const fd = new FormData();
fd.append("file", f);
const resp = await fetch("/api/resume/parse/file", {
method: "POST", body: fd,
headers: { "X-Access-Token": localStorage.getItem("cn_access_token") || "" },
});
const body = await resp.json();
if (!resp.ok) throw new Error(body.detail || `HTTP ${resp.status}`);
const p = body.data;
applyParsedProfile(p, `${f.name}(已脱敏)`);
toast(`已解析 ${f.name}:识别技能 ${p.skills.length} 项`, "success");
} catch (err) { toast(`解析失败:${err.message}`, "error"); }
return;
}
toast("仅支持 .txt / .docx / .pdf 格式", "error");
});
function applyParsedProfile(p, sourceDesc) {
lastProfile = p;
matchState.profileText = p.raw_masked_text || "";
matchState.skills = [...p.skills];
matchState.years = p.years || 0;
$("#resume-text").value = p.raw_masked_text || "";
$("#parse-summary").innerHTML =
`解析完成(来源:${esc(sourceDesc || "文本")},已自动脱敏):识别技能 <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;
try {
const m = await post("/api/match", {
job_id: jobId, skills: matchState.skills, years: matchState.years,
scenarios: [],
});
matchState.result = m;
renderMatchResult(m);
setMatchStep(3);
} catch (e) { toast(e.message, "error"); }
};
$("#btn-back-2").onclick = () => setMatchStep(1);
$("#btn-goto-path").onclick = () => {
switchView("path");
$("#path-job-select").value = $("#match-job-select").value;
loadPathJob($("#match-job-select").value, matchState.skills).then(() => $("#btn-path").click());
};
function renderMatchResult(m) {
drawGauge($("#gauge-canvas"), m.total_score);
$("#match-verdict").innerHTML = m.is_match
? `<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>`;
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>`;
$("#missing-chips").innerHTML =
[...m.missing_required.map((s) => chip(s, "tag-red")), ...m.missing_preferred.map((s) => chip(s, "tag-orange"))].join("") ||
`<span class="text-weak text-aux">无</span>`;
$("#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 fetch("/api/resume/export", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ profile: lastProfile, match: matchState.result }),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const blob = await resp.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `简历_${lastProfile.name || "脱敏"}_结构化.docx`;
a.click();
URL.revokeObjectURL(a.href);
toast("规范简历 DOCX 已导出", "success");
} catch (e) { toast(`导出失败:${e.message}`, "error"); }
};
async function renderRecJobs(current) {
const box = $("#rec-job-cards");
box.innerHTML = `<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 };
/* 子页签切换 */
$$(".match-tab").forEach((b) => {
b.onclick = () => {
$$(".match-tab").forEach((x) => x.classList.toggle("active", x === b));
$("#match-single").classList.toggle("hidden", b.dataset.mtab !== "single");
$("#match-batch").classList.toggle("hidden", b.dataset.mtab !== "batch");
};
});
const _BATCH_EXTS = ["txt", "md", "docx", "pdf"];
function batchAddFiles(fileList) {
let added = 0;
for (const f of fileList) {
const ext = (f.name.split(".").pop() || "").toLowerCase();
if (!_BATCH_EXTS.includes(ext)) continue;
if (batchState.files.length >= 50) { toast("单批最多 50 份,超出部分已忽略", "error"); break; }
if (!batchState.files.some((x) => x.name === f.name && x.size === f.size)) {
batchState.files.push(f); added++;
}
}
if (added) renderBatchFileList();
if (!added && fileList.length) toast("没有可添加的简历文件(支持 .txt/.docx/.pdf)", "error");
}
function renderBatchFileList() {
const box = $("#batch-filelist");
box.innerHTML = batchState.files.length
? batchState.files.map((f, i) => `
<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 fetch("/api/match/batch", {
method: "POST", body: fd,
headers: { "X-Access-Token": localStorage.getItem("cn_access_token") || "" },
});
const body = await resp.json();
if (!resp.ok) throw new Error(body.detail || `HTTP ${resp.status}`);
batchState.result = body.data;
renderBatchResult(body.data);
} catch (e2) {
box.innerHTML = `<div class="empty small">批量匹配失败:${esc(e2.message)}</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">加载失败:${esc(e.message)}</div>`; }
}
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", () => loadPathJob($("#path-job-select").value));
$("#btn-path").onclick = async () => {
const jobId = $("#path-job-select").value;
if (jobId !== pathState.jobId) await loadPathJob(jobId);
try {
const data = await post("/api/learning-path", { job_id: jobId, known_skills: [...pathState.known] });
lastPathData = data;
renderPath(data);
} catch (e) { toast(e.message, "error"); }
};
function stageWeeks(n) {
const base = Math.max(1, Math.round(pathState.weeks / Math.max(1, n)));
return Array.from({ length: n }, () => base);
}
function updatePathOverview() {
if (!lastPathData || !lastPathData.learning_path.length) return;
const n = lastPathData.learning_path.length;
const gaps = lastPathData.learning_path.reduce((a, st) => a + st.skills.length, 0);
const done = pathState.done.size;
const pct = n ? Math.round((done / n) * 100) : 0;
$("#path-overview").innerHTML = `
<div class="po-item">目标岗位<b>${esc(lastPathData.title)}</b></div>
<div class="po-item">待补技能<b class="po-primary">${gaps} 项</b></div>
<div class="po-item">阶段<b>${n} 个</b></div>
<div class="po-item">预计周期<b>${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>`;
}
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);
box.innerHTML = 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} · ${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>${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);
if (pathState.done.has(st)) pathState.done.delete(st); else pathState.done.add(st);
localStorage.setItem(pathDoneKey(pathState.jobId), JSON.stringify([...pathState.done]));
renderPath(lastPathData);
};
});
updatePathOverview();
}
$("#btn-export-path").onclick = () => {
if (!lastPathData) return;
const lines = [`CareerNexus 学习路径 · 目标岗位:${lastPathData.title}`, ""];
lastPathData.learning_path.forEach((st) => {
lines.push(`阶段 ${st.stage}:${st.skills.join("、")}`);
if (st.acceptance) {
lines.push(` 产出物:${st.acceptance.deliverable}`);
lines.push(` 量化验收:`);
st.acceptance.criteria.forEach((c, k) => lines.push(` ${k + 1}. ${c}`));
lines.push(` 自测问答:`);
st.acceptance.self_check.forEach((q, k) => lines.push(` Q${k + 1}. ${q}`));
}
});
const blob = new Blob([lines.join("\n")], { type: "text/plain;charset=utf-8" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `学习路径_${lastPathData.title}.txt`;
a.click();
URL.revokeObjectURL(a.href);
toast("已导出文本", "success");
};
/* ================= 系统设置 / 状态横幅 / 演示模式 ================= */
let lastHealth = null; // 最近一次 health 响应(横幅与状态徽标的数据源)
function updateBanners(health) {
if (health) lastHealth = health;
const h = lastHealth || {};
const demo = h.demo_mode !== false;
const embed = h.embed || {};
const degraded = !!h.offline_fallback || !embed.ready;
// 演示模式:警告横幅降级为顶栏状态徽标(首屏不出现负面字眼)
const showOffline = !demo && !!h.offline_fallback
&& sessionStorage.getItem("cn_banner_dismissed") !== "1";
const showEmbed = !demo && !embed.ready
&& sessionStorage.getItem("cn_banner2_dismissed") !== "1";
$("#offline-banner").classList.toggle("hidden", !showOffline);
$("#embed-banner").classList.toggle("hidden", !showEmbed);
const n = (showOffline ? 1 : 0) + (showEmbed ? 1 : 0);
document.body.classList.toggle("has-banner", n === 1);
document.body.classList.toggle("has-banner2", n === 2);
// 顶栏状态徽标:绿=全就绪,黄=离线回退/语义未就绪
const badge = $("#demo-badge");
badge.classList.toggle("warn", degraded);
$("#demo-badge-text").textContent = degraded ? "离线演示" : (demo ? "演示模式" : "在线");
badge.title = degraded
? "离线回退中(LLM/语义模型未配置),点击查看设置"
: "全部通道就绪,点击查看设置";
}
function dismissBanner(id, key) {
sessionStorage.setItem(key, "1");
updateBanners();
}
$("#banner-close").onclick = () => dismissBanner("offline-banner", "cn_banner_dismissed");
$("#embed-banner-close").onclick = () => dismissBanner("embed-banner", "cn_banner2_dismissed");
$("#banner-config").onclick = () => openConfigModal(false);
$("#embed-banner-config").onclick = () => openConfigModal(false);
$("#demo-badge").onclick = () => openConfigModal(false);
/* ================= 演示导览(预置路线,一键串起完整闭环) ================= */
const DEMO_STEPS = [
{ title: "① 数据驾驶舱:多源数据总览", desc: "JD 规模、岗位/技能/事件总量、演化趋势、清洗与审核状态",
go: () => switchView("dashboard") },
{ title: "② 新岗位发现:候选→审核→入图", desc: "图谱定位「大模型应用工程师」:四指标证据、人工确认、证据 JD 回查",
go: () => { switchView("graph"); if (CN.gotoNode) CN.gotoNode("job_大模型应用工程师"); } },
{ title: "③ 既有岗位演化:Java 编年史", desc: "时点对比、变更堆叠图、生命周期甘特、事件证据链",
go: () => {
switchView("evolution");
$("#evo-job-select").value = "job_Java开发工程师";
$("#evo-job-select").dispatchEvent(new Event("change"));
} },
{ title: "④ 人岗匹配诊断 + 学习路径", desc: "载入示例简历 → 确认解析 → 总分/四维/差距清单 → 一键生成学习路径",
go: () => {
switchView("match");
$$(".match-tab").forEach((x) => x.classList.toggle("active", x.dataset.mtab === "single"));
$("#match-single").classList.remove("hidden");
$("#match-batch").classList.add("hidden");
$("#btn-load-sample").click();
} },
];
$("#btn-demo-tour").onclick = () => {
openModal("演示导览(预置路线)", `
<div class="tour-intro">按预置路线依次演示完整闭环,每步点击「前往」自动跳转到位:</div>
${DEMO_STEPS.map((s, i) => `
<div class="tour-step">
<div class="tour-step-body">
<div class="tour-step-title">${esc(s.title)}</div>
<div class="tour-step-desc">${esc(s.desc)}</div>
</div>
<button class="btn btn-primary btn-sm" data-step="${i}">前往</button>
</div>`).join("")}`);
$$("#modal-body [data-step]").forEach((b) => {
b.onclick = () => { closeModal(); DEMO_STEPS[+b.dataset.step].go(); };
});
};
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._trend) drawTrend($("#trend-canvas"), CN._trend);
if (CN._heat) drawHeatLine($("#evo-heat-canvas"), CN._heat);
});
(async function boot() {
try {
CN.jobs = await api("/api/jobs");
const opts = CN.jobs.map((j) => `<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开发工程师";
await loadDashboard(); // 先加载图谱缓存(演化页甘特图依赖)
await loadEvolution();
renderEvoHeatmap();
/* 分享链接:#view=graph&node=xxx */
const hash = new URLSearchParams(location.hash.slice(1));
if (hash.get("view")) switchView(hash.get("view"));
if (hash.get("node") && CN.gotoNode) CN.gotoNode(hash.get("node"));
/* 首次使用:演示模式下不弹初始设置(评审口径),非演示模式才引导 */
if ((!lastHealth || !lastHealth.guided) && lastHealth && lastHealth.demo_mode === false) {
openConfigModal(false, true);
}
} catch (e) {
toast("后端连接失败:" + e.message, "error");
}
})();