Files
CareerNexus/webui/app.js
T
RoyirinRosmontis ac6d08cdf2 前端答辩级改造:错误中文化、证据弹窗可读化、图谱默认岗位子图、演化报告化、匹配60/40、六步导览
- cnFetch 统一网络层,业务化错误文案+原位重试,屏蔽内部文件名
- 证据弹窗:结论/来源平台·企业·日期/原文/命中依据+翻页
- 图谱默认 Java 岗位子图;详情面板四区块(结论/能力清单/近期变化/证据操作)
- 演化页本期结论块(需求指数口径)+近8季默认视图
- 匹配页输入区 60/40 + 岗位信息卡 + 结论分档文案 + 缺口折叠
- 学习路径空状态引导+阶段语义化命名;六步导览浮动条
- tests/test_frontend_logic.js 21 用例
2026-08-13 12:33:13 +08:00

2040 lines
95 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); }
}
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;
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);
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));
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);
});
}
/* 技能生命周期甘特图 */
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");
};
/* 目标岗位信息卡:上传前让用户理解岗位要求(右栏 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;
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 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 };
/* 子页签切换 */
$$(".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 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", () => loadPathJob($("#path-job-select").value));
$("#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);
}
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);
/* 阶段语义化命名:基础补齐 → 岗位核心 → 项目验证(超出 3 阶段顺延为综合提升) */
const STAGE_NAMES = ["基础补齐", "岗位核心", "项目验证"];
const stageName = (i) => STAGE_NAMES[i] || "综合提升";
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} · ${stageName(i)}(${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);
/* ================= 演示导览(6 步评委导览,右上角浮动步进条) ================= */
const DEMO_JOB = "job_Java开发工程师";
const DEMO_CANDIDATE = "job_大模型应用工程师";
const DEMO_STEPS = [
{ title: "数据驾驶舱:多源数据总览",
desc: "JD 规模、岗位/技能/事件总量、演化趋势、清洗与审核状态",
go: () => switchView("dashboard") },
{ title: "新岗位候选:发现 → 人工审核 → 入图",
desc: "候选岗位不直接入图:四指标证据 + 人工确认,可回查证据 JD",
go: () => {
switchView("graph");
const locate = () => {
const has = CN.graph && CN.graph.has && CN.graph.has(DEMO_CANDIDATE);
if (CN.gotoNode) CN.gotoNode(has ? DEMO_CANDIDATE : DEMO_JOB);
};
if (CN.graph && CN.graph.ready) CN.graph.ready().then(locate);
else if (CN.gotoNode) CN.gotoNode(DEMO_CANDIDATE);
} },
{ title: "岗位局部图谱:Java 开发工程师",
desc: "技能点级局部图谱:必备/加分技能、支撑 JD 数与可读证据",
go: () => {
switchView("graph");
const enter = () => { if (CN.graph && CN.graph.solo) CN.graph.solo(DEMO_JOB); };
if (CN.graph && CN.graph.ready) CN.graph.ready().then(enter);
} },
{ title: "岗位演化报告:能力动态更新",
desc: "本期结论、新增/增强/减弱/退出、时点对比,每条可回查证据",
go: () => {
switchView("evolution");
$("#evo-job-select").value = DEMO_JOB;
$("#evo-job-select").dispatchEvent(new Event("change"));
} },
{ title: "人岗诊断:示例简历自动跑通",
desc: "载入示例简历 → 解析确认 → 匹配结论 / 核心缺口 / 可迁移能力",
go: async () => {
switchView("match");
$$(".match-tab").forEach((x) => x.classList.toggle("active", x.dataset.mtab === "single"));
$("#match-single").classList.remove("hidden");
$("#match-batch").classList.add("hidden");
$("#match-job-select").value = DEMO_JOB;
loadMatchJobInfo();
$("#btn-load-sample").click();
try {
const p = await post("/api/resume/parse", { text: $("#resume-text").value });
applyParsedProfile(p, "演示示例简历");
$("#btn-run-match").click();
} catch (e) { toast(friendlyErr(e), "error"); }
} },
{ title: "学习路径:可执行的三阶段路线",
desc: "自动带入岗位与技能缺口:基础补齐 → 岗位核心 → 项目验证",
go: () => {
switchView("path");
$("#path-job-select").value = DEMO_JOB;
loadPathJob(DEMO_JOB, matchState.skills).then(() => $("#btn-path").click());
} },
];
let tourIdx = -1;
function tourGo(i) {
tourIdx = Math.max(0, Math.min(DEMO_STEPS.length - 1, i));
const step = DEMO_STEPS[tourIdx];
$("#tour-bar").classList.remove("hidden");
$("#tour-prog").textContent = `演示 ${tourIdx + 1}/${DEMO_STEPS.length}`;
$("#tour-title").textContent = step.title;
$("#tour-desc").textContent = step.desc;
$("#tour-prev").disabled = tourIdx === 0;
$("#tour-next").textContent = tourIdx === DEMO_STEPS.length - 1 ? "完成" : "下一步";
Promise.resolve(step.go()).catch((e) => toast(friendlyErr(e), "error"));
}
function endTour() {
tourIdx = -1;
$("#tour-bar").classList.add("hidden");
}
$("#btn-demo-tour").onclick = () => tourGo(0);
$("#tour-prev").onclick = () => tourGo(tourIdx - 1);
$("#tour-next").onclick = () => (tourIdx === DEMO_STEPS.length - 1 ? endTour() : tourGo(tourIdx + 1));
$("#tour-exit").onclick = endTour;
function embedStatusHtml(emb) {
if (emb.ready) {
return `<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);
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();
renderEvoHeatmap();
/* 分享链接:#view=graph&node=xxx */
const hash = new URLSearchParams(location.hash.slice(1));
if (hash.get("view")) switchView(hash.get("view"));
if (hash.get("node") && CN.gotoNode) CN.gotoNode(hash.get("node"));
/* 首次使用:演示模式下不弹初始设置(评审口径),非演示模式才引导 */
if ((!lastHealth || !lastHealth.guided) && lastHealth && lastHealth.demo_mode === false) {
openConfigModal(false, true);
}
} catch (e) {
toast(friendlyErr(e, "后端连接失败,请检查服务是否已启动"), "error");
}
})();