2337 lines
113 KiB
JavaScript
2337 lines
113 KiB
JavaScript
/* CareerNexus 职联 前端主脚本:外壳 + 驾驶舱 + 演化 + 匹配 + 学习路径。
|
||
* 全部数据走 /api,完全离线(无外部依赖)。图谱工作台见 graph.js。 */
|
||
"use strict";
|
||
|
||
/* ================= 公共工具 ================= */
|
||
const $ = (s) => document.querySelector(s);
|
||
const $$ = (s) => Array.from(document.querySelectorAll(s));
|
||
|
||
/* 网络层统一封装:网络错误一律翻译为中文业务文案,
|
||
* 演示环境禁止出现 "Failed to fetch"、接口路径、内部文件名。 */
|
||
async function cnFetch(path, options) {
|
||
try {
|
||
return await fetch(path, options);
|
||
} catch {
|
||
throw new Error("暂时无法连接服务,请检查后端是否已启动");
|
||
}
|
||
}
|
||
|
||
/* 把残余的技术型报错兜底翻译为业务文案(服务端中文 detail 原样保留) */
|
||
function friendlyErr(e, fallback = "操作失败,请稍后重试") {
|
||
const raw = (e && e.message) || "";
|
||
if (!raw) return fallback;
|
||
if (/failed to fetch|networkerror|load failed/i.test(raw)) return "暂时无法连接服务,请检查后端是否已启动";
|
||
if (/\.jsonl|\.py|traceback/i.test(raw)) return fallback; // 内部文件名不外显
|
||
return raw.replace(/HTTP (\d{3})/g, "服务响应异常($1)");
|
||
}
|
||
|
||
async function api(path, options) {
|
||
options = options || {};
|
||
options.headers = Object.assign(
|
||
{ "X-Access-Token": localStorage.getItem("cn_access_token") || "" },
|
||
options.headers);
|
||
const resp = await cnFetch(path, options);
|
||
if (resp.status === 401) {
|
||
openConfigModal(true);
|
||
throw new Error("需要访问令牌:请在系统设置中填写");
|
||
}
|
||
const body = await resp.json().catch(() => ({ code: -1, message: "服务响应异常,请稍后重试" }));
|
||
if (!resp.ok || body.code !== 0) {
|
||
throw new Error(friendlyErr({ message: body.detail || body.message || "" }, `服务暂时不可用(错误码 ${resp.status})`));
|
||
}
|
||
return body.data;
|
||
}
|
||
|
||
function esc(s) {
|
||
return String(s ?? "").replace(/[&<>"']/g, (c) =>
|
||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||
}
|
||
|
||
function toast(msg, type = "info") {
|
||
const el = document.createElement("div");
|
||
el.className = `toast ${type}`;
|
||
el.textContent = msg;
|
||
$("#toast-wrap").appendChild(el);
|
||
setTimeout(() => el.remove(), 2800);
|
||
}
|
||
|
||
/* ================= 统一系统弹窗(替代浏览器原生 alert/prompt/confirm) ================= */
|
||
function sysDialog({ title, bodyHtml, okText = "确定", cancelText = "取消", danger = false, onOk }) {
|
||
return new Promise((resolve) => {
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "sysdlg-mask";
|
||
wrap.innerHTML = `
|
||
<div class="sysdlg" role="dialog" aria-modal="true">
|
||
<div class="sysdlg-title">${esc(title)}</div>
|
||
<div class="sysdlg-body">${bodyHtml}</div>
|
||
<div class="sysdlg-foot">
|
||
${cancelText ? `<button class="btn btn-secondary btn-sm" data-act="cancel">${esc(cancelText)}</button>` : ""}
|
||
<button class="btn ${danger ? "btn-danger" : "btn-primary"} btn-sm" data-act="ok">${esc(okText)}</button>
|
||
</div>
|
||
</div>`;
|
||
document.body.appendChild(wrap);
|
||
const close = (val) => { wrap.remove(); resolve(val); };
|
||
wrap.querySelector('[data-act="ok"]').onclick = () => {
|
||
if (onOk) {
|
||
const v = onOk(wrap);
|
||
if (v !== undefined) close(v); // onOk 返回 undefined 表示校验不通过,不关闭
|
||
} else close(true);
|
||
};
|
||
wrap.querySelector('[data-act="cancel"]')?.addEventListener("click", () => close(null));
|
||
wrap.addEventListener("click", (e) => { if (e.target === wrap) close(null); });
|
||
const input = wrap.querySelector("input,textarea");
|
||
if (input) {
|
||
input.focus(); input.select();
|
||
input.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter" && input.tagName === "INPUT") wrap.querySelector('[data-act="ok"]').click();
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
function sysPrompt({ title, label = "", value = "", placeholder = "" }) {
|
||
return sysDialog({
|
||
title,
|
||
bodyHtml: `${label ? `<div class="sysdlg-label">${esc(label)}</div>` : ""}
|
||
<input class="input sysdlg-input" value="${esc(value)}" placeholder="${esc(placeholder)}">`,
|
||
onOk: (w) => w.querySelector("input").value.trim() || null,
|
||
});
|
||
}
|
||
|
||
function sysConfirm({ title, text, okText = "确定", danger = false }) {
|
||
return sysDialog({ title, bodyHtml: `<div class="sysdlg-text">${esc(text)}</div>`, okText, danger });
|
||
}
|
||
|
||
function sysAlert({ title, text, okText = "知道了" }) {
|
||
return sysDialog({ title, bodyHtml: `<div class="sysdlg-text">${esc(text)}</div>`, okText, cancelText: "" });
|
||
}
|
||
|
||
/* 复制展示框:readonly 输入 + 一键复制(clipboard 失败时退化为手动全选复制) */
|
||
function sysCopy({ title, text }) {
|
||
return sysDialog({
|
||
title,
|
||
okText: "复制", cancelText: "关闭",
|
||
bodyHtml: `<input class="input sysdlg-input" value="${esc(text)}" readonly>`,
|
||
onOk: async (w) => {
|
||
const input = w.querySelector("input");
|
||
input.focus(); input.select();
|
||
try { await navigator.clipboard.writeText(text); toast("已复制到剪贴板", "success"); }
|
||
catch { document.execCommand("copy"); toast("已复制到剪贴板", "success"); }
|
||
return true;
|
||
},
|
||
});
|
||
}
|
||
|
||
function openModal(title, bodyHtml, footHtml) {
|
||
$("#modal-title").innerHTML = title;
|
||
$("#modal-body").innerHTML = bodyHtml;
|
||
const foot = $("#modal-foot");
|
||
if (footHtml) { foot.innerHTML = footHtml; foot.classList.remove("hidden"); }
|
||
else foot.classList.add("hidden");
|
||
$("#modal-mask").classList.remove("hidden");
|
||
}
|
||
function closeModal() { $("#modal-mask").classList.add("hidden"); $("#modal-box").classList.remove("wide"); }
|
||
$("#modal-close").onclick = closeModal;
|
||
$("#modal-mask").addEventListener("click", (e) => { if (e.target.id === "modal-mask") closeModal(); });
|
||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeModal(); });
|
||
|
||
const post = (path, payload) => api(path, {
|
||
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload),
|
||
});
|
||
|
||
/* 全局共享状态与命名空间(graph.js 复用) */
|
||
const CN = window.CN = {
|
||
api, post, $, $$, esc, toast, openModal, closeModal,
|
||
sysPrompt, sysConfirm, sysAlert, sysCopy,
|
||
jobs: [], // /api/jobs 缓存
|
||
graphData: null, // /api/graph 缓存
|
||
switchView: null, // 下方赋值
|
||
gotoNode: null, // graph.js 注册:跳转图谱并选中节点
|
||
};
|
||
|
||
const TYPE_META = {
|
||
Job: { name: "岗位", color: "#2f54eb", tag: "tag-blue" },
|
||
Skill: { name: "技能", color: "#00b42a", tag: "tag-green" },
|
||
Industry: { name: "行业", color: "#13c2c2", tag: "tag-cyan" },
|
||
Scenario: { name: "场景", color: "#722ed1", tag: "tag-purple" },
|
||
};
|
||
CN.TYPE_META = TYPE_META;
|
||
|
||
/* JD 原文缓存(证据弹窗与来源列表共用,失败不缓存以允许重试) */
|
||
const _jdCache = new Map();
|
||
async function fetchJd(sid) {
|
||
if (_jdCache.has(sid)) return _jdCache.get(sid);
|
||
const p = api(`/api/jd?sid=${encodeURIComponent(sid)}`);
|
||
_jdCache.set(sid, p);
|
||
try { return await p; } catch (e) { _jdCache.delete(sid); throw e; }
|
||
}
|
||
|
||
/* 来源列表行内联元信息:填充为「企业 · 平台 · 日期」可读文案(不显示内部 source_id) */
|
||
CN.jdMetaLine = async function (el, sid) {
|
||
try {
|
||
const r = await fetchJd(sid);
|
||
el.textContent = `${r.company || "企业未披露"} · ${r.source || "公开招聘平台"} · ${r.publish_date || "时间未知"}`;
|
||
} catch {
|
||
el.textContent = "证据详情暂时读取失败,点击可重试查看";
|
||
}
|
||
};
|
||
|
||
/* JD 证据弹窗(演化事件 / 图谱来源共用)
|
||
* sids:单条或数组(数组时支持上一条/下一条翻页)
|
||
* ctx:{ skill, jobTitle, changeType, window } 用于生成可读结论与命中依据 */
|
||
async function showJdEvidence(sids, ctx = {}) {
|
||
const list = (Array.isArray(sids) ? sids : [sids]).filter(Boolean);
|
||
if (!list.length) return toast("该条目暂无关联的公开 JD 证据", "info");
|
||
let idx = 0;
|
||
const conclusion = ctx.skill
|
||
? `${ctx.skill}${ctx.changeType ? ` · ${ctx.changeType}` : ""}${ctx.jobTitle ? ` — ${ctx.jobTitle}` : ""}`
|
||
: "";
|
||
async function render() {
|
||
const sid = list[idx];
|
||
const foot = `
|
||
${list.length > 1 ? `
|
||
<button class="btn btn-secondary btn-sm" id="ev-prev" ${idx === 0 ? "disabled" : ""}>上一条</button>
|
||
<span class="text-weak text-aux" style="align-self:center">${idx + 1} / ${list.length}</span>
|
||
<button class="btn btn-secondary btn-sm" id="ev-next" ${idx === list.length - 1 ? "disabled" : ""}>下一条</button>` : ""}
|
||
<button class="btn btn-primary btn-sm" id="ev-close">关闭</button>`;
|
||
openModal("JD 原文证据", `<div class="text-weak">证据加载中…</div>`, foot);
|
||
$("#ev-close").onclick = closeModal;
|
||
if (list.length > 1) {
|
||
$("#ev-prev").onclick = () => { idx -= 1; render(); };
|
||
$("#ev-next").onclick = () => { idx += 1; render(); };
|
||
}
|
||
try {
|
||
const rec = await fetchJd(sid);
|
||
$("#modal-body").innerHTML = `
|
||
${conclusion ? `<div class="ev-conclusion">${esc(conclusion)}${ctx.window ? `<span class="text-weak">(${esc(ctx.window)})</span>` : ""}</div>` : ""}
|
||
<div class="jd-meta">
|
||
<span><b>${esc(rec.job_title)}</b></span><span>${esc(rec.company || "企业未披露")}</span>
|
||
<span>${esc(rec.city || "—")}</span><span>${esc(rec.salary || "—")}</span>
|
||
</div>
|
||
<div class="ev-source">来源:${esc(rec.source || "公开招聘平台")} · ${esc(rec.company || "企业未披露")} · 发布时间:${esc(rec.publish_date || "未知")}</div>
|
||
<div class="jd-evidence">${esc(rec.text)}</div>
|
||
${ctx.skill ? `<div class="ev-basis">命中依据:该 JD 对「${esc(ctx.skill)}」提出明确能力要求${ctx.changeType ? `(${esc(ctx.changeType)})` : ""}</div>` : ""}
|
||
<div class="ev-sid">${esc(sid)}</div>`;
|
||
} catch (e) {
|
||
const gone = /不存在|非法|格式/.test((e && e.message) || "");
|
||
$("#modal-body").innerHTML = `
|
||
<div class="empty small">
|
||
<p>${gone ? "该能力当前没有可展示的公开原文,已进入人工核验队列"
|
||
: "暂时无法读取该条证据,请检查服务后重试"}</p>
|
||
<button class="btn btn-secondary btn-sm" id="ev-retry">重试</button>
|
||
</div>`;
|
||
$("#ev-retry").onclick = render;
|
||
}
|
||
}
|
||
await render();
|
||
}
|
||
CN.showJdEvidence = showJdEvidence;
|
||
CN.fetchJd = fetchJd;
|
||
|
||
/* ================= 视图切换 / 侧栏 ================= */
|
||
const PAGE_TITLES = {
|
||
dashboard: "人才能力变化指挥台", graph: "岗位能力证据地图", evolution: "岗位演化",
|
||
match: "人岗匹配", path: "学习路径",
|
||
};
|
||
const _loaded = {};
|
||
function switchView(name) {
|
||
$$(".nav-item").forEach((b) => b.classList.toggle("active", b.dataset.view === name));
|
||
$$(".view").forEach((v) => v.classList.remove("active"));
|
||
$("#view-" + name).classList.add("active");
|
||
$("#page-title").textContent = PAGE_TITLES[name] || "";
|
||
/* 视图记入 URL hash,刷新后回到同一页(node 参数在 graph.js 内维护) */
|
||
const hash = new URLSearchParams(location.hash.slice(1));
|
||
if (hash.get("view") !== name) {
|
||
hash.set("view", name);
|
||
if (name !== "graph") hash.delete("node");
|
||
history.replaceState(null, "", "#" + hash.toString());
|
||
}
|
||
if (name === "graph" && CN.graph && !_loaded.graph) { _loaded.graph = true; CN.graph.init(); }
|
||
if (name === "graph" && CN.graph && _loaded.graph) CN.graph.onShow();
|
||
if (name === "path" && !_loaded.path) {
|
||
_loaded.path = true;
|
||
loadPathJob($("#path-job-select").value).then(() => renderDemoPath()); // 未诊断时展示示范路线
|
||
}
|
||
}
|
||
CN.switchView = switchView;
|
||
|
||
$$(".nav-item").forEach((btn) => { btn.onclick = () => switchView(btn.dataset.view); });
|
||
|
||
/* ================= 全局搜索 ================= */
|
||
(function initSearch() {
|
||
const input = $("#global-search-input"), drop = $("#global-search-drop");
|
||
let timer = null;
|
||
input.addEventListener("input", () => {
|
||
clearTimeout(timer);
|
||
timer = setTimeout(async () => {
|
||
const kw = input.value.trim();
|
||
if (!kw) { drop.classList.add("hidden"); return; }
|
||
try {
|
||
if (!CN.graphData) CN.graphData = await api("/api/graph");
|
||
const hits = CN.graphData.nodes
|
||
.filter((n) => (n.title || n.id).toLowerCase().includes(kw.toLowerCase()))
|
||
.slice(0, 8);
|
||
drop.innerHTML = hits.length ? hits.map((n, i) => {
|
||
const m = TYPE_META[n.type] || { name: n.type, tag: "tag-gray" };
|
||
return `<div class="sd-item" data-id="${esc(n.id)}">
|
||
<span class="tag ${m.tag}">${m.name}</span><span>${esc(n.title || n.id)}</span>
|
||
<span class="text-weak text-aux">${esc(n.category || "")}</span></div>`;
|
||
}).join("") : `<div class="sd-item text-weak">无匹配结果</div>`;
|
||
drop.classList.remove("hidden");
|
||
$$("#global-search-drop .sd-item[data-id]").forEach((el) => {
|
||
el.onclick = () => {
|
||
drop.classList.add("hidden"); input.value = "";
|
||
switchView("graph");
|
||
if (CN.gotoNode) CN.gotoNode(el.dataset.id);
|
||
};
|
||
});
|
||
} catch { /* 静默 */ }
|
||
}, 200);
|
||
});
|
||
document.addEventListener("click", (e) => {
|
||
if (!e.target.closest(".global-search")) drop.classList.add("hidden");
|
||
});
|
||
})();
|
||
|
||
/* ================= 人才能力变化指挥台(数据驾驶舱) ================= */
|
||
/* 首屏 KPI:新岗位候选 / 关键技能增强 / 待人工复核 / 影响岗位(图谱规模降级为一行小字) */
|
||
const METRIC_DEFS = [
|
||
{ key: "candidates", label: "新岗位候选", act: "desk", sub: "聚类发现,待人工决策", icon: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>' },
|
||
{ key: "boosted", label: "关键技能增强", act: "evolution", sub: "近 2 季度新增+增强事件", icon: '<svg viewBox="0 0 24 24"><path d="M3 17l5.5-5.5 3.5 3.5L20 7"/><path d="M15 7h5v5"/></svg>' },
|
||
{ key: "review", label: "待人工复核", act: "desk", sub: "候选岗位 + 候选技能关系", icon: '<svg viewBox="0 0 24 24"><path d="M9 11l3 3 8-8"/><path d="M21 12v6a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2h11"/></svg>' },
|
||
{ key: "affected", label: "影响岗位", act: "evolution", sub: "近 2 季度发生变化的岗位", icon: '<svg viewBox="0 0 24 24"><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V5a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>' },
|
||
];
|
||
|
||
async function loadDashboard() {
|
||
const [health, graph, pending, audit] = await Promise.all([
|
||
api("/api/health"), api("/api/graph"),
|
||
api("/api/review/pending").catch(() => []),
|
||
api("/api/audit?limit=8").catch(() => []),
|
||
]);
|
||
CN.graphData = graph;
|
||
CN._pendingCache = pending;
|
||
const s = health.graph || {};
|
||
updateBanners(health);
|
||
const events = graph.events || [];
|
||
const lastWin = (events.at(-1) || {}).window || "2026Q3";
|
||
$("#data-version").textContent = `数据截止:${lastWin} · 模拟数据`;
|
||
|
||
/* 本期口径:最近 2 个季度的变化事件 */
|
||
const wins2 = [...new Set(events.map((e) => e.window))].sort().slice(-2);
|
||
const recent = events.filter((e) => wins2.includes(e.window));
|
||
const cnt = (t) => recent.filter((e) => e.change_type === t).length;
|
||
const readyCount = pending.filter((c) => ["高", "较高"].includes(candidateTrust(c, CN.jobs).level)).length;
|
||
const pendingRels = pending.reduce((a, c) =>
|
||
a + (c.required_skills || []).length + (c.preferred_skills || []).length, 0);
|
||
|
||
/* 指挥台首屏:结论 + 图谱规模(降级为小字) */
|
||
$("#cmd-title").textContent = `${lastWin} 人才能力变化概览`;
|
||
$("#cmd-sub").textContent = pending.length
|
||
? `本期确认 ${recent.length} 项关键变化(新增 ${cnt("新增")} · 增强 ${cnt("增强")} · 减弱 ${cnt("减弱")} · 退出 ${cnt("退出")});${readyCount} 个岗位候选已具备入库条件`
|
||
: `本期确认 ${recent.length} 项关键变化(新增 ${cnt("新增")} · 增强 ${cnt("增强")} · 减弱 ${cnt("减弱")} · 退出 ${cnt("退出")});当前无待审核候选`;
|
||
$("#cmd-scale").textContent =
|
||
`图谱规模:岗位 ${s.jobs ?? "—"} · 技能 ${s.skills ?? "—"} · 关系边 ${s.edges ?? "—"} · 演化事件 ${s.events ?? "—"}`;
|
||
|
||
/* KPI 卡(点击直达对应处理界面) */
|
||
const values = {
|
||
candidates: pending.length,
|
||
boosted: cnt("增强") + cnt("新增"),
|
||
review: pending.length + pendingRels,
|
||
affected: new Set(recent.map((e) => e.job_id)).size,
|
||
};
|
||
$("#metric-cards").innerHTML = METRIC_DEFS.map((m) => `
|
||
<div class="metric-card" data-act="${m.act}">
|
||
<div class="mc-label">${m.icon}${m.label}</div>
|
||
<div class="mc-num">${values[m.key] ?? "—"}</div>
|
||
<span class="mc-delta flat">${m.sub}</span>
|
||
</div>`).join("");
|
||
$$("#metric-cards .metric-card").forEach((el) => {
|
||
el.onclick = () => (el.dataset.act === "desk" ? openReviewDesk() : switchView("evolution"));
|
||
});
|
||
$("#pending-count").textContent = `${pending.length} 待办`;
|
||
|
||
/* 主图:近 8 季度变化事件流(全岗位口径堆叠柱,点击柱段跳演化页) */
|
||
CN._flowEvents = events;
|
||
drawEventFlow($("#trend-canvas"), events, 8);
|
||
|
||
renderTrust();
|
||
renderTaskTable(audit);
|
||
$("#btn-import-jd").onclick = () => openImportDialog();
|
||
$("#btn-cmd-report").onclick = () => switchView("evolution");
|
||
$("#btn-open-review-desk").onclick = () => openReviewDesk();
|
||
$("#btn-open-review-desk-2").onclick = () => openReviewDesk();
|
||
$("#btn-audit-toggle").onclick = () => {
|
||
const wrap = $("#task-table-wrap");
|
||
wrap.classList.toggle("hidden");
|
||
$("#btn-audit-toggle").textContent = wrap.classList.contains("hidden") ? "展开台账" : "收起台账";
|
||
};
|
||
renderFeed(events);
|
||
renderPending(pending);
|
||
}
|
||
|
||
/* 数据可信度卡:有效 JD / 独立企业 / 来源类型 / 人工复核率 / 异常剔除率 */
|
||
async function renderTrust() {
|
||
const box = $("#trust-card");
|
||
try {
|
||
const t = await api("/api/stats/credibility");
|
||
const rows = [
|
||
["有效 JD", `${t.jd_valid} 条`, `原始 ${t.jd_total} 条,去重降噪后入图`],
|
||
["独立企业", `${t.companies} 家`, "跨企业交叉验证,防单一企业造词"],
|
||
["来源类型", `${t.sources} 类`, (t.source_names || []).join("、") || "公开招聘平台"],
|
||
["人工复核率", t.review_rate == null ? "—" : `${Math.round(t.review_rate * 100)}%`,
|
||
`审核留痕 ${t.reviewed} 条 · 待审 ${t.pending} 项`],
|
||
["异常剔除率", `${Math.round((t.excluded_rate || 0) * 100)}%`, `剔除噪声/重复 ${t.jd_total - t.jd_valid} 条`],
|
||
];
|
||
box.innerHTML = rows.map(([k, v, sub]) => `
|
||
<div class="trust-row"><span class="trust-k">${k}</span><b class="trust-v">${v}</b>
|
||
<span class="trust-sub">${esc(sub)}</span></div>`).join("");
|
||
} catch (e) {
|
||
box.innerHTML = `<div class="empty small">
|
||
<p>可信度统计暂时加载失败:${esc(friendlyErr(e))}</p>
|
||
<button class="btn btn-secondary btn-sm" id="trust-retry">重试</button></div>`;
|
||
$("#trust-retry").onclick = renderTrust;
|
||
}
|
||
}
|
||
|
||
/* JD 数据导入对话框:上传文件 / 粘贴文本 / 直接全量重建 */
|
||
async function openImportDialog() {
|
||
const choice = await sysDialog({
|
||
title: "JD 数据导入",
|
||
okText: "开始导入",
|
||
bodyHtml: `
|
||
<div class="sysdlg-text">支持 <b>.jsonl / .csv / .txt</b> 文件上传(UTF-8,≤10MB),或直接粘贴 JSONL 文本(每行一条记录);两者都留空则仅触发全量重建。</div>
|
||
<div class="sysdlg-label">上传文件</div>
|
||
<input type="file" id="imp-file" class="input sysdlg-input" accept=".jsonl,.csv,.txt">
|
||
<div class="sysdlg-label">或粘贴文本</div>
|
||
<textarea id="imp-text" class="input sysdlg-input" rows="5" placeholder='{"jid":"j1","job_title":"Java开发工程师","company":"示例科技","publish_date":"2025-03-01","text":"负责后端开发,熟悉 Java、Spring Boot、MySQL"}'></textarea>`,
|
||
onOk: (w) => {
|
||
const f = w.querySelector("#imp-file").files[0];
|
||
const t = w.querySelector("#imp-text").value;
|
||
if (!f && !t.trim()) return "rebuild";
|
||
return { file: f, text: t };
|
||
},
|
||
});
|
||
if (choice === null) return;
|
||
try {
|
||
let t;
|
||
if (choice === "rebuild") {
|
||
t = await post("/api/jd/import", {});
|
||
} else {
|
||
const fd = new FormData();
|
||
if (choice.file) fd.append("file", choice.file);
|
||
else fd.append("text", choice.text);
|
||
t = await api("/api/jd/import", { method: "POST", body: fd });
|
||
const bad = (t.bad_lines || []).length;
|
||
toast(`已接收:导入 ${t.imported} 条 / 跳过 ${t.skipped} 条 / 坏行 ${bad} 条`
|
||
+ (bad ? `(详见 logs/${t.bad_log})` : ""), bad ? "error" : "success");
|
||
}
|
||
toast(`导入任务已启动:${t.task_id}`, "success");
|
||
pollTask(t.task_id);
|
||
} catch (e) { toast(e.message, "error"); }
|
||
}
|
||
|
||
async function pollTask(taskId) { const timer = setInterval(async () => {
|
||
try {
|
||
const t = await api(`/api/tasks/${taskId}`);
|
||
if (t.status === "done") {
|
||
clearInterval(timer);
|
||
toast("JD 导入完成,图谱已更新", "success");
|
||
loadDashboard();
|
||
} else if (t.status === "failed") {
|
||
clearInterval(timer);
|
||
toast("导入失败:" + (t.error || "未知错误"), "error");
|
||
}
|
||
} catch { clearInterval(timer); }
|
||
}, 1500);
|
||
}
|
||
|
||
function renderTaskTable(audit) {
|
||
const tbody = $("#task-table tbody");
|
||
const rows = (audit || []).slice(0, 8);
|
||
if (!rows.length) {
|
||
tbody.innerHTML = "";
|
||
$("#task-empty").classList.remove("hidden");
|
||
return;
|
||
}
|
||
$("#task-empty").classList.add("hidden");
|
||
tbody.innerHTML = rows.map((r) => {
|
||
const isNode = r.kind === "node_update";
|
||
const obj = isNode ? r.node_id : (r.candidate_id || "").replace(/^cand_/, "");
|
||
const act = isNode ? "节点编辑" : { accept: "审核通过", reject: "退回观察", modify: "审核修改" }[r.action] || r.action;
|
||
const badge = isNode ? "badge-version" : (r.action === "reject" ? "badge-warning" : "badge-success");
|
||
return `<tr><td><span class="badge ${badge}">${act}</span></td>
|
||
<td>${esc(obj)}</td><td>${esc(r.operator || "—")}</td>
|
||
<td>${esc(r.time || "—")}</td><td>${esc(r.reason || "—")}</td></tr>`;
|
||
}).join("");
|
||
}
|
||
|
||
/* 变化播报卡:技能图标 + 变化类型 + 影响岗位 + 证据数 + 跳转演化页(审核动作归入台账) */
|
||
function renderFeed(events) {
|
||
const verb = { "新增": "成为新要求", "增强": "需求显著增强", "减弱": "需求明显减弱", "退出": "退出能力画像" };
|
||
const items = (events || []).slice(-6).reverse();
|
||
const jobTitle = (id) => ((CN.jobs || []).find((j) => j.job_id === id) || {}).title || id.replace(/^job_/, "");
|
||
$("#activity-feed").innerHTML = items.length ? items.map((e, i) => {
|
||
const m = CHANGE_META[e.change_type] || { tag: "tag-gray", color: "#86909c" };
|
||
return `<div class="feed-item bcast" data-i="${i}">
|
||
<span class="fi-icon" style="color:${m.color};background:${m.color}1a">
|
||
<svg viewBox="0 0 24 24"><path d="M12 2l2.4 4.9 5.4.8-3.9 3.8.9 5.4-4.8-2.5-4.8 2.5.9-5.4L4.2 7.7l5.4-.8L12 2z"/></svg></span>
|
||
<div class="bcast-body">
|
||
<div class="fi-text"><b>${esc(e.skill)}</b> ${verb[e.change_type] || esc(e.change_type)}</div>
|
||
<div class="fi-time">影响岗位:${esc(jobTitle(e.job_id))} · 证据 ${(e.source_ids || []).length} 条 JD · ${esc(e.window)}</div>
|
||
</div>
|
||
<span class="tag ${m.tag}">${esc(e.change_type)}</span>
|
||
</div>`;
|
||
}).join("") : `<div class="empty small">暂无变化播报</div>`;
|
||
$$("#activity-feed .feed-item").forEach((el) => {
|
||
el.onclick = () => {
|
||
const e = items[+el.dataset.i];
|
||
switchView("evolution");
|
||
$("#evo-job-select").value = e.job_id;
|
||
loadEvolution();
|
||
};
|
||
});
|
||
}
|
||
|
||
/* 待办行动卡(最多 3 张:结论 + 证据强度 + 处理按钮;完整台账见审核台/下方台账) */
|
||
function renderPending(pending) {
|
||
const box = $("#pending-list");
|
||
if (!pending.length) {
|
||
box.innerHTML = `<div class="empty small">暂无待审核候选(可在图谱页触发新岗位发现)</div>`;
|
||
return;
|
||
}
|
||
box.innerHTML = pending.slice(0, 3).map((c) => {
|
||
const t = candidateTrust(c, CN.jobs);
|
||
const ready = ["高", "较高"].includes(t.level);
|
||
return `<div class="review-item action-card">
|
||
<div class="ri-head"><b>${esc(c.title)}</b>
|
||
<span class="tag ${ready ? "tag-green" : "tag-orange"}">${ready ? "建议批准入图" : "建议继续观察"}</span></div>
|
||
<div class="ri-meta">结论:四指标达标 ${t.okCount}/4(可信等级 ${t.level}) · 处理人:待分配</div>
|
||
<div class="ri-meta">证据强度:${c.source_count} 条 JD · ${c.company_count} 家企业 · ${(c.support_windows || []).length} 个季度跨度</div>
|
||
<div class="ri-actions">
|
||
<button class="btn btn-primary btn-sm" data-act="accept" data-id="${esc(c.job_id)}">批准入图</button>
|
||
<button class="btn btn-secondary btn-sm" data-act="reject" data-id="${esc(c.job_id)}">退回观察</button>
|
||
<button class="btn btn-text btn-sm" data-desk="${esc(c.job_id)}">审核台查看</button>
|
||
</div>
|
||
</div>`;
|
||
}).join("") + (pending.length > 3
|
||
? `<button class="btn btn-text btn-sm" id="pending-more">查看全部 ${pending.length} 个候选 →</button>` : "");
|
||
box.querySelectorAll("[data-act]").forEach((b) => {
|
||
b.onclick = () => doReview(b.dataset.id, b.dataset.act);
|
||
});
|
||
box.querySelectorAll("[data-desk]").forEach((b) => {
|
||
b.onclick = () => openReviewDesk(b.dataset.desk);
|
||
});
|
||
const more = $("#pending-more");
|
||
if (more) more.onclick = () => openReviewDesk();
|
||
}
|
||
|
||
async function doReview(id, action) {
|
||
const operator = await CN.sysPrompt({ title: "审核确认", label: "请输入审核人姓名:", value: "评审专家" });
|
||
if (operator === null) return;
|
||
let reason = "webui 审核通过";
|
||
if (action === "reject") {
|
||
reason = await CN.sysPrompt({ title: "退回继续观察", label: "请输入退回原因(候选保留观察,暂不入库):", value: "证据不足,继续观察" });
|
||
if (reason === null) return;
|
||
}
|
||
try {
|
||
await post("/api/review", { candidate_id: id, action, operator: operator || "评审专家", reason });
|
||
toast(action === "accept" ? "已批准入图:图谱已实时更新" : "已退回继续观察", "success");
|
||
loadDashboard();
|
||
if (!$("#review-desk-mask").classList.contains("hidden")) renderReviewDesk();
|
||
if (CN.graph && CN.graph.reload) CN.graph.reload();
|
||
} catch (e) { toast(e.message, "error"); }
|
||
}
|
||
CN.doReview = doReview;
|
||
|
||
/* ================= 候选岗位审核台(显性二级界面:发现 → 证据 → 人工决策 → 自动入图) ================= */
|
||
/* 候选可信等级四指标:支撑 JD 数 ≥15 / 独立企业数 ≥5 / 增长趋势(支撑窗口后半段不弱于前半段)/ 与既有岗位差异度 ≥0.40 */
|
||
function candidateTrust(c, jobs) {
|
||
const jdOk = (c.source_count || 0) >= 15;
|
||
const compOk = (c.company_count || 0) >= 5;
|
||
const wins = (c.support_windows || []).slice().sort();
|
||
const half = Math.max(1, Math.floor(wins.length / 2));
|
||
const before = wins.slice(0, wins.length - half).length;
|
||
const recent = wins.slice(-half).length;
|
||
const growthOk = before === 0 ? wins.length >= 2 : recent / before >= 1.0;
|
||
const skills = new Set([...(c.required_skills || []), ...(c.preferred_skills || [])].map((s) => s.toLowerCase()));
|
||
let best = { sim: 0, title: "" };
|
||
(jobs || []).forEach((j) => {
|
||
const js = new Set([...(j.required_skills || []), ...(j.preferred_skills || [])].map((s) => s.toLowerCase()));
|
||
if (!skills.size || !js.size) return;
|
||
const inter = [...skills].filter((s) => js.has(s)).length;
|
||
const sim = inter / (skills.size + js.size - inter);
|
||
if (sim > best.sim) best = { sim, title: j.title };
|
||
});
|
||
const distinct = 1 - best.sim;
|
||
const dots = [
|
||
{ label: "支撑 JD", ok: jdOk, value: `${c.source_count || 0} 条(≥15 达标)` },
|
||
{ label: "独立企业", ok: compOk, value: `${c.company_count || 0} 家(≥5 达标)` },
|
||
{ label: "增长趋势", ok: growthOk, value: `支撑窗口覆盖 ${wins.length} 个季度` },
|
||
{ label: "差异度", ok: distinct >= 0.4, value: `${distinct.toFixed(2)}(≥0.40 达标)` },
|
||
];
|
||
const okCount = dots.filter((d) => d.ok).length;
|
||
const level = okCount >= 4 ? "高" : okCount === 3 ? "较高" : okCount === 2 ? "中" : "低";
|
||
return { dots, level, okCount, distinctness: distinct, nearest: best };
|
||
}
|
||
|
||
function openReviewDesk(focusId) {
|
||
renderReviewDesk(focusId);
|
||
$("#review-desk-mask").classList.remove("hidden");
|
||
}
|
||
CN.openReviewDesk = openReviewDesk;
|
||
|
||
$("#review-desk-close").onclick = () => $("#review-desk-mask").classList.add("hidden");
|
||
$("#review-desk-mask").addEventListener("click", (e) => {
|
||
if (e.target.id === "review-desk-mask") $("#review-desk-mask").classList.add("hidden");
|
||
});
|
||
|
||
async function renderReviewDesk(focusId) {
|
||
const box = $("#review-desk-body");
|
||
if (!box) return;
|
||
let pending = CN._pendingCache;
|
||
try {
|
||
pending = await api("/api/review/pending");
|
||
CN._pendingCache = pending;
|
||
} catch (e) {
|
||
box.innerHTML = `<div class="empty small">
|
||
<p>候选数据暂时加载失败:${esc(friendlyErr(e))}</p>
|
||
<button class="btn btn-secondary btn-sm" id="desk-retry">重试</button></div>`;
|
||
$("#desk-retry").onclick = () => renderReviewDesk();
|
||
return;
|
||
}
|
||
if (!pending.length) {
|
||
box.innerHTML = `<div class="empty small">暂无待审核候选(可在图谱页触发新岗位发现)</div>`;
|
||
return;
|
||
}
|
||
const levelTag = { "高": "tag-green", "较高": "tag-cyan", "中": "tag-orange", "低": "tag-red" };
|
||
box.innerHTML = pending.map((c) => {
|
||
const t = candidateTrust(c, CN.jobs);
|
||
const wins = (c.support_windows || []).slice().sort();
|
||
const span = wins.length ? `${wins[0]} ~ ${wins[wins.length - 1]}` : "—";
|
||
const resp = c.responsibilities || [];
|
||
const summary = resp[0]
|
||
|| `面向${(c.industries || []).join("/") || "新兴"}领域的「${c.title}」岗位(职责描述待人工补充)`;
|
||
return `<div class="cand-card" data-cand="${esc(c.job_id)}">
|
||
<div class="cc-head"><b>候选:${esc(c.title)}</b>
|
||
<span class="tag ${levelTag[t.level] || "tag-gray"}">可信等级:${t.level}</span></div>
|
||
<div class="cc-def">职责摘要:${esc(summary)}</div>
|
||
${resp.length > 1 ? `<div class="cc-duties">核心职责:${resp.slice(0, 5).map((d) => `<span class="tag tag-cyan">${esc(d.replace(/;\s*$/, ""))}</span>`).join(" ")}</div>` : ""}
|
||
<div class="cc-scens">应用场景:${(c.scenarios || []).length
|
||
? c.scenarios.map((s) => `<span class="tag tag-purple">${esc(s)}</span>`).join(" ")
|
||
: "—"}</div>
|
||
<div class="cc-dots">${t.dots.map((d) =>
|
||
`<span class="cred-dot ${d.ok ? "on" : ""}" title="${esc(d.label)}:${esc(d.value)}${d.ok ? "(达标)" : "(未达标)"}"></span>`).join("")}
|
||
<span class="cc-dots-label">${t.okCount}/4 指标达标(JD / 企业 / 增长 / 差异)</span></div>
|
||
<div class="cc-meta">多源证据:${c.source_count} JD · ${c.company_count} 企业 · 时间跨度 ${esc(span)}</div>
|
||
<div class="cc-diff">与相近岗位:${t.nearest.title
|
||
? `「${esc(t.nearest.title)}」技能重合 ${(t.nearest.sim * 100).toFixed(0)}%,职责差异度 ${t.distinctness.toFixed(2)}`
|
||
: "暂无可比较岗位"}</div>
|
||
<div class="cc-skills">必备:${(c.required_skills || []).slice(0, 6).map((s) => `<span class="tag tag-blue">${esc(s)}</span>`).join(" ") || "—"}
|
||
${(c.preferred_skills || []).length ? `<br>加分:${c.preferred_skills.slice(0, 4).map((s) => `<span class="tag tag-gray">${esc(s)}</span>`).join(" ")}` : ""}</div>
|
||
<div class="cc-actions">
|
||
<button class="btn btn-secondary btn-sm" data-ev="${esc(c.job_id)}">查看证据原文</button>
|
||
<button class="btn btn-secondary btn-sm" data-cmp="${esc(c.job_id)}">相近岗位比较</button>
|
||
<button class="btn btn-primary btn-sm" data-act="accept" data-id="${esc(c.job_id)}">人工批准入图</button>
|
||
<button class="btn btn-danger btn-sm" data-act="reject" data-id="${esc(c.job_id)}">退回继续观察</button>
|
||
</div>
|
||
</div>`;
|
||
}).join("");
|
||
box.querySelectorAll("[data-act]").forEach((b) => {
|
||
b.onclick = () => doReview(b.dataset.id, b.dataset.act);
|
||
});
|
||
box.querySelectorAll("[data-ev]").forEach((b) => {
|
||
b.onclick = () => {
|
||
const c = pending.find((x) => x.job_id === b.dataset.ev);
|
||
showJdEvidence((c && c.support_source_ids) || [], { jobTitle: (c || {}).title || b.dataset.ev });
|
||
};
|
||
});
|
||
box.querySelectorAll("[data-cmp]").forEach((b) => {
|
||
b.onclick = () => {
|
||
const c = pending.find((x) => x.job_id === b.dataset.cmp);
|
||
const t = candidateTrust(c, CN.jobs);
|
||
const near = (CN.jobs || []).find((j) => j.title === t.nearest.title);
|
||
if (!near) return toast("暂无可比较的相近岗位", "info");
|
||
const chips = (list, cls) => (list || []).map((s) => `<span class="tag ${cls}">${esc(s)}</span>`).join(" ") || "—";
|
||
openModal(`相近岗位比较:${esc(c.title)} vs ${esc(near.title)}`, `
|
||
<div class="cmp-meta">技能重合 ${(t.nearest.sim * 100).toFixed(0)}% · 职责差异度 ${t.distinctness.toFixed(2)}(≥0.40 视为新岗位)</div>
|
||
<div class="diff-grid">
|
||
<div class="diff-col"><div class="diff-title">候选「${esc(c.title)}」必备</div>
|
||
<div class="diff-chips">${chips(c.required_skills, "tag-blue")}</div></div>
|
||
<div class="diff-col"><div class="diff-title">「${esc(near.title)}」必备</div>
|
||
<div class="diff-chips">${chips(near.required_skills, "tag-green")}</div></div>
|
||
<div class="diff-col"><div class="diff-title">候选独有技能</div>
|
||
<div class="diff-chips">${chips((c.required_skills || []).filter((s) => !(near.required_skills || []).includes(s)), "tag-orange")}</div></div>
|
||
</div>`);
|
||
};
|
||
});
|
||
if (focusId) {
|
||
const el = box.querySelector(`[data-cand="${focusId}"]`);
|
||
if (el) { el.scrollIntoView({ block: "center" }); el.classList.add("flash"); }
|
||
}
|
||
}
|
||
|
||
/* 画布自适应:占满父容器(宽 100%、高 100%)+ dpr 高清,返回 CSS 像素坐标系下的 ctx/W/H */
|
||
function fitCanvas(canvas, cssH) {
|
||
const dpr = window.devicePixelRatio || 1;
|
||
canvas.style.width = "100%";
|
||
canvas.style.height = "100%";
|
||
const W = canvas.clientWidth || 600;
|
||
const H = (canvas.clientHeight >= 120 ? canvas.clientHeight : cssH);
|
||
canvas.width = W * dpr;
|
||
canvas.height = H * dpr;
|
||
const ctx = canvas.getContext("2d");
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
return { ctx, W, H };
|
||
}
|
||
|
||
/* 变化事件流:近 N 季度全岗位口径 新增/增强/减弱/退出 堆叠柱,点击柱段跳演化页对应岗位 */
|
||
function drawEventFlow(canvas, events, n) {
|
||
const { ctx, W, H } = fitCanvas(canvas, 220);
|
||
ctx.clearRect(0, 0, W, H);
|
||
$("#cmd-stack-legend").innerHTML = Object.entries(CHANGE_META).map(([t, m]) =>
|
||
`<span><i style="background:${m.color}"></i>${t}</span>`).join("");
|
||
const windows = [...new Set(events.map((e) => e.window))].sort().slice(-n);
|
||
if (!windows.length) return;
|
||
const types = Object.keys(CHANGE_META);
|
||
const counts = windows.map((w) => {
|
||
const row = {};
|
||
types.forEach((t) => { row[t] = events.filter((e) => e.window === w && e.change_type === t).length; });
|
||
return row;
|
||
});
|
||
const maxTotal = Math.max(1, ...counts.map((r) => types.reduce((a, t) => a + r[t], 0)));
|
||
const bw = W / windows.length;
|
||
const baseY = H - 22;
|
||
ctx.font = "10px sans-serif"; ctx.textAlign = "center";
|
||
windows.forEach((w, i) => {
|
||
let y = baseY;
|
||
types.forEach((t) => {
|
||
const v = counts[i][t];
|
||
if (!v) return;
|
||
const h = (v / maxTotal) * (H - 52);
|
||
ctx.fillStyle = CHANGE_META[t].color;
|
||
ctx.fillRect(i * bw + bw * 0.2, y - h, bw * 0.6, h);
|
||
y -= h;
|
||
});
|
||
const total = types.reduce((a, t) => a + counts[i][t], 0);
|
||
if (total) { ctx.fillStyle = "#4e5969"; ctx.fillText(total, i * bw + bw / 2, y - 4); }
|
||
ctx.fillStyle = "#86909c";
|
||
ctx.fillText(w.slice(2), i * bw + bw / 2, H - 6);
|
||
});
|
||
canvas._bars = windows.map((w, i) => ({ w, x0: i * bw, x1: (i + 1) * bw }));
|
||
canvas.onclick = (ev) => {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const x = ev.clientX - rect.left;
|
||
const bar = (canvas._bars || []).find((b) => x >= b.x0 && x < b.x1);
|
||
if (!bar) return;
|
||
/* 跳到该季度事件最多的岗位演化报告 */
|
||
const byJob = {};
|
||
events.filter((e) => e.window === bar.w).forEach((e) => { byJob[e.job_id] = (byJob[e.job_id] || 0) + 1; });
|
||
const top = Object.entries(byJob).sort((a, b) => b[1] - a[1])[0];
|
||
switchView("evolution");
|
||
if (top && $("#evo-job-select").querySelector(`option[value="${top[0]}"]`)) {
|
||
$("#evo-job-select").value = top[0];
|
||
loadEvolution();
|
||
}
|
||
toast(`已定位 ${bar.w} 季度变化最多的岗位`, "info");
|
||
};
|
||
}
|
||
|
||
/* ================= 岗位演化 ================= */
|
||
/* 变化类型视觉语义:新增=青绿 / 增强=蓝紫 / 减弱=琥珀 / 退出=砖红(持续=中性蓝灰) */
|
||
const CHANGE_META = {
|
||
// 低饱和语义色:图表填充用柔和色,文字标签仍用 tag 类的标准色
|
||
"新增": { tag: "tag-cyan", color: "#7bc8a4" },
|
||
"增强": { tag: "tag-purple", color: "#8f9ff0" },
|
||
"减弱": { tag: "tag-orange", color: "#e8c07d" },
|
||
"退出": { tag: "tag-red", color: "#d99a91" },
|
||
};
|
||
const STEADY_COLOR = "#86909c"; // 持续:中性蓝灰
|
||
let evoEvents = [];
|
||
|
||
/* 全部岗位 × 季度 演化热力图 */
|
||
function 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().slice(-12); // 全岗位扫描仅近 12 季度
|
||
// 统计每个岗位每季度事件数与类型分布
|
||
const cell = {};
|
||
events.forEach((e) => {
|
||
const k = `${e.job_id}|${e.window}`;
|
||
cell[k] = cell[k] || { n: 0, types: {} };
|
||
cell[k].n += 1;
|
||
cell[k].types[e.change_type] = (cell[k].types[e.change_type] || 0) + 1;
|
||
});
|
||
// 按总事件数降序排列岗位
|
||
const totals = {};
|
||
events.forEach((e) => { totals[e.job_id] = (totals[e.job_id] || 0) + 1; });
|
||
const rows = [...jobs].sort((a, b) => (totals[b.job_id] || 0) - (totals[a.job_id] || 0));
|
||
const labelEvery = Math.ceil(windows.length / 10);
|
||
// 色阶:开方缩放 + 12 封顶,避免个别高峰季度(如 57 件)把其余格子压到看不清
|
||
const alphaOf = (n) => 0.18 + 0.82 * Math.sqrt(Math.min(n, 12) / 12);
|
||
box.innerHTML = `
|
||
<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;
|
||
/* 点击行 → 转入该岗位报告(默认 Tab) */
|
||
$$("#evo-tabs .match-tab").forEach((x) => x.classList.toggle("active", x.dataset.evotab === "report"));
|
||
$("#evo-tab-report").classList.remove("hidden");
|
||
$("#evo-tab-scan").classList.add("hidden");
|
||
loadEvolution();
|
||
};
|
||
});
|
||
}
|
||
|
||
/* 演化页二级 Tab:岗位报告(默认) / 全岗位扫描(热力图) */
|
||
$$("#evo-tabs .match-tab").forEach((b) => {
|
||
b.onclick = () => {
|
||
$$("#evo-tabs .match-tab").forEach((x) => x.classList.toggle("active", x === b));
|
||
const tab = b.dataset.evotab;
|
||
$("#evo-tab-report").classList.toggle("hidden", tab !== "report");
|
||
$("#evo-tab-scan").classList.toggle("hidden", tab !== "scan");
|
||
if (tab === "scan") renderEvoHeatmap();
|
||
};
|
||
});
|
||
|
||
function bucketWindow(w, gran) {
|
||
if (gran === "year") return w.slice(0, 4);
|
||
if (gran === "month") { /* 季度窗口展开为季中月 */ return `${w.slice(0, 4)}-${String((+w.slice(5) - 1) * 3 + 2).padStart(2, "0")}`; }
|
||
return w;
|
||
}
|
||
|
||
async function loadEvolution() {
|
||
const id = $("#evo-job-select").value;
|
||
if (!id) return;
|
||
try {
|
||
const data = await api(`/api/jobs/${encodeURIComponent(id)}/evolution`);
|
||
evoEvents = data.events;
|
||
} catch (e) {
|
||
evoEvents = [];
|
||
$("#evo-timeline").innerHTML = `<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);
|
||
renderEvoReplace($("#evo-job-select").value);
|
||
/* 热度走势:每季度实际生效的技能关系数(按 REQUIRES 边的生效区间统计) */
|
||
const jobEdges = ((CN.graphData || {}).edges || [])
|
||
.filter((e) => e.source === $("#evo-job-select").value && e.rel === "REQUIRES" && (e.intervals || []).length);
|
||
const allWins = [...new Set(((CN.graphData || {}).events || []).map((e) => e.window))].sort();
|
||
const winKey = (w) => bucketWindow(w, gran);
|
||
const buckets = {};
|
||
allWins.forEach((w) => {
|
||
buckets[winKey(w)] = jobEdges.filter((e) =>
|
||
e.intervals.some(([s, en]) => s <= w && (en === null || w < en))).length;
|
||
});
|
||
CN._heat = Object.entries(buckets).sort((a, b) => (a[0] < b[0] ? -1 : 1));
|
||
drawHeat();
|
||
|
||
/* 时间轴 */
|
||
const box = $("#evo-timeline");
|
||
const jobTitle = (CN.jobs.find((j) => j.job_id === $("#evo-job-select").value) || {}).title || "";
|
||
box.innerHTML = evoEvents.length ? evoEvents.map((e, i) => {
|
||
const m = CHANGE_META[e.change_type] || { tag: "tag-gray" };
|
||
return `<div class="evo-event t-${e.change_type}" data-i="${i}">
|
||
<div class="ee-head"><span class="ee-window">${esc(e.window)}</span>
|
||
<span class="tag ${m.tag}">${esc(e.change_type)}</span>
|
||
<span class="ee-skill">${esc(e.skill)}</span>
|
||
<span class="text-weak text-aux">需求指数 ${e.prev_weight.toFixed(2)} → ${e.curr_weight.toFixed(2)}</span></div>
|
||
<div class="ee-desc">${esc((e.description || "").replace(/权重/g, "需求指数"))}</div>
|
||
<div class="ee-src">证据 JD ${e.source_ids.length} 条 · 点击查看原文</div>
|
||
</div>`;
|
||
}).join("") : `<div class="empty small">该岗位暂无演化事件</div>`;
|
||
box.querySelectorAll(".evo-event").forEach((el) => {
|
||
el.onclick = () => {
|
||
const e = evoEvents[+el.dataset.i];
|
||
showJdEvidence(e.source_ids || [], {
|
||
skill: e.skill, jobTitle, changeType: e.change_type, window: e.window,
|
||
});
|
||
};
|
||
});
|
||
}
|
||
|
||
function drawHeatLine(canvas, kv) {
|
||
const { ctx, W, H } = fitCanvas(canvas, 200);
|
||
ctx.clearRect(0, 0, W, H);
|
||
if (!kv.length) return;
|
||
const pad = { l: 40, r: 16, t: 16, b: 28 };
|
||
const max = Math.max(1, ...kv.map(([, v]) => v));
|
||
/* 网格与 y 轴 */
|
||
ctx.strokeStyle = "#f0f0f0"; ctx.fillStyle = "#86909c"; ctx.font = "11px sans-serif";
|
||
for (let g = 0; g <= 4; g++) {
|
||
const y = pad.t + ((H - pad.t - pad.b) * g) / 4;
|
||
ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(W - pad.r, y); ctx.stroke();
|
||
ctx.textAlign = "right";
|
||
ctx.fillText(Math.round(max * (1 - g / 4)), pad.l - 6, y + 4);
|
||
}
|
||
const px = (i) => pad.l + (i / Math.max(1, kv.length - 1)) * (W - pad.l - pad.r);
|
||
const py = (v) => pad.t + (1 - v / max) * (H - pad.t - pad.b);
|
||
/* 面积 + 折线 */
|
||
ctx.beginPath();
|
||
kv.forEach(([, v], i) => ctx[i ? "lineTo" : "moveTo"](px(i), py(v)));
|
||
ctx.strokeStyle = "#2f54eb"; ctx.lineWidth = 2; ctx.stroke();
|
||
ctx.lineTo(px(kv.length - 1), H - pad.b); ctx.lineTo(px(0), H - pad.b); ctx.closePath();
|
||
const grad = ctx.createLinearGradient(0, pad.t, 0, H - pad.b);
|
||
grad.addColorStop(0, "rgba(47,84,235,0.25)"); grad.addColorStop(1, "rgba(47,84,235,0.02)");
|
||
ctx.fillStyle = grad; ctx.fill();
|
||
/* 点与 x 轴 */
|
||
ctx.textAlign = "center";
|
||
kv.forEach(([k, v], i) => {
|
||
ctx.fillStyle = "#2f54eb";
|
||
ctx.beginPath(); ctx.arc(px(i), py(v), 3, 0, 7); ctx.fill();
|
||
if (kv.length <= 14 || i % Math.ceil(kv.length / 14) === 0) {
|
||
ctx.fillStyle = "#86909c"; ctx.fillText(k, px(i), H - 8);
|
||
}
|
||
});
|
||
}
|
||
|
||
/* 变更类型构成堆叠柱状图(按季度) */
|
||
function drawStackBars(canvas, events) {
|
||
const { ctx, W, H } = fitCanvas(canvas, 220);
|
||
ctx.clearRect(0, 0, W, H);
|
||
const windows = [...new Set(events.map((e) => e.window))].sort();
|
||
$("#stack-legend").innerHTML = Object.entries(CHANGE_META).map(([t, m]) =>
|
||
`<span><i style="background:${m.color}"></i>${t}</span>`).join("");
|
||
if (!windows.length) return;
|
||
const types = Object.keys(CHANGE_META);
|
||
const counts = windows.map((w) => {
|
||
const row = {};
|
||
types.forEach((t) => { row[t] = events.filter((e) => e.window === w && e.change_type === t).length; });
|
||
return row;
|
||
});
|
||
const maxTotal = Math.max(1, ...counts.map((r) => types.reduce((a, t) => a + r[t], 0)));
|
||
const bw = W / windows.length;
|
||
const baseY = H - 22;
|
||
ctx.font = "10px sans-serif"; ctx.textAlign = "center";
|
||
windows.forEach((w, i) => {
|
||
let y = baseY;
|
||
types.forEach((t) => {
|
||
const v = counts[i][t];
|
||
if (!v) return;
|
||
const h = (v / maxTotal) * (H - 52);
|
||
ctx.fillStyle = CHANGE_META[t].color;
|
||
ctx.fillRect(i * bw + bw * 0.2, y - h, bw * 0.6, h);
|
||
y -= h;
|
||
});
|
||
const total = types.reduce((a, t) => a + counts[i][t], 0);
|
||
if (total) {
|
||
ctx.fillStyle = "#4e5969";
|
||
ctx.fillText(total, i * bw + bw / 2, y - 4);
|
||
}
|
||
ctx.fillStyle = "#86909c";
|
||
ctx.fillText(w.slice(2), i * bw + bw / 2, H - 6);
|
||
});
|
||
}
|
||
|
||
/* 能力生命线(Swimlane):每个核心技能一行(近 8 季度),线段=生效区间,圆点=变更事件。
|
||
* 行状态取该技能最近一次事件:新增=青绿 / 增强=蓝紫 / 减弱=琥珀 / 退出=砖红 / 持续=蓝灰 */
|
||
function renderEvoGantt(jobId) {
|
||
const box = $("#evo-gantt");
|
||
const g = CN.graphData || {};
|
||
const allEvents = g.events || [];
|
||
const windows = [...new Set(allEvents.map((e) => e.window))].sort().slice(-8);
|
||
const edges = (g.edges || []).filter((e) => e.source === jobId && e.rel === "REQUIRES" && (e.intervals || []).length);
|
||
if (!windows.length || !edges.length) {
|
||
box.innerHTML = `<div class="empty small">暂无该岗位的能力生命线数据</div>`;
|
||
return;
|
||
}
|
||
/* Top 12:当前仍生效优先,按当前需求指数降序 */
|
||
const alive = (e) => e.intervals.some(([s, en]) => en === null);
|
||
const rows = edges.slice().sort((a, b) =>
|
||
((alive(b) ? 1 : 0) - (alive(a) ? 1 : 0)) || ((b.weight || 0) - (a.weight || 0))).slice(0, 12);
|
||
const dots = {};
|
||
evoEvents.forEach((e) => { dots[`${e.skill}|${e.window}`] = e.change_type; });
|
||
const lastWin = windows[windows.length - 1];
|
||
const recentWins = windows.slice(-4);
|
||
const rowMeta = (edge) => {
|
||
const activeNow = edge.intervals.some(([s, en]) => s <= lastWin && (en === null || lastWin < en));
|
||
if (!activeNow) return { label: "退出", color: CHANGE_META["退出"].color };
|
||
const lastEv = evoEvents.filter((e) => e.skill === edge.target && recentWins.includes(e.window)).at(-1);
|
||
if (lastEv) return { label: lastEv.change_type, color: (CHANGE_META[lastEv.change_type] || {}).color || STEADY_COLOR };
|
||
return { label: edge.importance === "required" ? "核心" : "加分", color: STEADY_COLOR };
|
||
};
|
||
const cellHtml = (edge, w, color) => {
|
||
const active = edge.intervals.some(([s, en]) => s <= w && (en === null || w < en));
|
||
const dotType = dots[`${edge.target}|${w}`];
|
||
const dot = dotType
|
||
? `<i class="sl-dot" style="background:${(CHANGE_META[dotType] || {}).color || "#999"}" title="${w} ${dotType}"></i>` : "";
|
||
return `<div class="sl-cell" title="${esc(edge.target)} · ${esc(w)}${active ? "(生效中)" : ""}">
|
||
${active ? `<div class="sl-seg" style="background:${color}"></div>` : ""}${dot}</div>`;
|
||
};
|
||
box.innerHTML = `
|
||
<div class="sl-grid" style="grid-template-columns: 128px repeat(${windows.length}, 1fr) 56px">
|
||
<div class="hm-corner"></div>
|
||
${windows.map((w) => `<div class="hm-col-label">${esc(w.slice(2))}</div>`).join("")}
|
||
<div class="hm-col-label">状态</div>
|
||
${rows.map((e) => {
|
||
const m = rowMeta(e);
|
||
return `<div class="gt-label" data-skill="${esc(e.target)}" title="点击跳转图谱节点">${esc(e.target)}</div>
|
||
${windows.map((w) => cellHtml(e, w, m.color)).join("")}
|
||
<div class="sl-status" style="color:${m.color}">${m.label}</div>`;
|
||
}).join("")}
|
||
</div>
|
||
<div class="hm-legend">
|
||
<span>行状态:</span>
|
||
${Object.entries(CHANGE_META).map(([t, m]) => `<i class="hm-l" style="background:${m.color}"></i>${t}`).join("")}
|
||
<i class="hm-l" style="background:${STEADY_COLOR}"></i>持续
|
||
<span class="text-weak">(圆点 = 该季度变更事件,点击技能名跳转图谱)</span>
|
||
</div>`;
|
||
box.querySelectorAll(".gt-label").forEach((el) => {
|
||
el.onclick = () => { switchView("graph"); if (CN.gotoNode) CN.gotoNode(el.dataset.skill); };
|
||
});
|
||
}
|
||
|
||
/* 能力更替流:旧核心(已退出必备) → 持续能力 → 新核心(近 4 季度新进入) */
|
||
function renderEvoReplace(jobId) {
|
||
const box = $("#evo-replace");
|
||
const g = CN.graphData || {};
|
||
const wins = [...new Set((g.events || []).map((e) => e.window))].sort();
|
||
const recent4 = new Set(wins.slice(-4));
|
||
const edges = (g.edges || []).filter((e) => e.source === jobId && e.rel === "REQUIRES" && (e.intervals || []).length);
|
||
if (!edges.length) {
|
||
box.innerHTML = `<div class="empty small">暂无能力更替数据</div>`;
|
||
return;
|
||
}
|
||
const alive = (e) => e.intervals.some(([s, en]) => en === null);
|
||
const start = (e) => e.intervals.map((i) => i[0]).sort()[0];
|
||
const oldCore = edges.filter((e) => !alive(e));
|
||
const cont = edges.filter((e) => alive(e) && !recent4.has(start(e)));
|
||
const newCore = edges.filter((e) => alive(e) && recent4.has(start(e)));
|
||
const chip = (e) =>
|
||
`<span class="tag ${alive(e) ? (e.importance === "required" ? "tag-blue" : "tag-cyan") : "tag-red"} rp-chip" data-skill="${esc(e.target)}">${esc(e.target)}</span>`;
|
||
const col = (title, list, cls, note) => `
|
||
<div class="rp-col ${cls}">
|
||
<div class="rp-title">${title}(${list.length})</div>
|
||
<div class="rp-note">${note}</div>
|
||
<div class="rp-chips">${list.slice(0, 10).map(chip).join("") || '<span class="text-weak text-aux">无</span>'}</div>
|
||
</div>`;
|
||
box.innerHTML = `
|
||
<div class="rp-grid">
|
||
${col("旧核心(已退出)", oldCore, "old", "曾为核心/加分,当前已退出画像")}
|
||
<div class="rp-arrow">→</div>
|
||
${col("持续能力", cont, "keep", "跨周期稳定生效,构成岗位基本盘")}
|
||
<div class="rp-arrow">→</div>
|
||
${col("新核心(近 4 季度进入)", newCore, "new", "本期新增或升级的必备/加分能力")}
|
||
</div>`;
|
||
box.querySelectorAll(".rp-chip").forEach((el) => {
|
||
el.onclick = () => { switchView("graph"); if (CN.gotoNode) CN.gotoNode(el.dataset.skill); };
|
||
});
|
||
}
|
||
|
||
/* ================= 人岗匹配(三步向导) ================= */
|
||
const matchState = { step: 1, skills: [], years: 0, result: null, profileText: "" };
|
||
let lastProfile = null; // 最近一次解析的完整简历画像(结构化模板与导出用)
|
||
|
||
function setMatchStep(n) {
|
||
matchState.step = n;
|
||
$$("#match-steps .step").forEach((s) => {
|
||
const i = +s.dataset.step;
|
||
s.classList.toggle("active", i === n);
|
||
s.classList.toggle("done", i < n);
|
||
if (i < n) s.querySelector("i").innerHTML = '<svg viewBox="0 0 24 24" style="width:13px;height:13px"><path d="M20 6L9 17l-5-5"/></svg>';
|
||
else s.querySelector("i").textContent = i;
|
||
});
|
||
[1, 2, 3].forEach((i) => $(`#match-pane-${i}`).classList.toggle("hidden", i !== n));
|
||
}
|
||
|
||
$("#btn-load-sample").onclick = async () => {
|
||
$("#resume-text").value =
|
||
"姓名:陈晓\n手机:13900001111\n邮箱:chenx@example.com\n\n5年 Java 后端开发经验," +
|
||
"熟悉 Java、Spring Boot、Spring Cloud、MySQL、Redis,了解 Docker 与 Kubernetes," +
|
||
"参与过电商与金融场景的交易系统建设,具备分布式系统调优经验。";
|
||
toast("已载入示例简历", "success");
|
||
};
|
||
|
||
/* 目标岗位信息卡:上传前让用户理解岗位要求(右栏 40%) */
|
||
async function loadMatchJobInfo() {
|
||
const box = $("#match-job-info");
|
||
if (!box) return;
|
||
const jobId = $("#match-job-select").value;
|
||
if (!jobId) { box.innerHTML = `<div class="empty small">请先选择目标岗位</div>`; return; }
|
||
box.innerHTML = `<div class="text-weak text-aux">岗位信息加载中…</div>`;
|
||
try {
|
||
const d = await api(`/api/jobs/${encodeURIComponent(jobId)}`);
|
||
const def = d.definition || d;
|
||
const level = def.expected_years == null ? "—" : def.expected_years <= 2 ? "初级" : def.expected_years <= 5 ? "中级" : "高级";
|
||
const chips = (list, cls, n) => (list || []).slice(0, n).map((s) => `<span class="tag ${cls}">${esc(s)}</span>`).join(" ") || `<span class="text-weak text-aux">无</span>`;
|
||
box.innerHTML = `
|
||
<div class="jic-title">${esc(def.title || jobId)}</div>
|
||
<div class="jic-meta">${level} · 支撑 ${def.source_count ?? "—"} 条 JD · ${def.company_count ?? "—"} 家企业 · 数据截止 2026Q3</div>
|
||
${(def.responsibilities || [])[0] ? `<div class="jic-def">${esc(def.responsibilities[0])}</div>` : ""}
|
||
<div class="jic-sec"><span class="jic-label">必备技能</span><div class="jic-chips">${chips(def.required_skills, "tag-green", 6)}</div></div>
|
||
<div class="jic-sec"><span class="jic-label">加分技能</span><div class="jic-chips">${chips(def.preferred_skills, "tag-cyan", 4)}</div></div>`;
|
||
} catch (e) {
|
||
box.innerHTML = `<div class="empty small">
|
||
<p>岗位信息暂时加载失败:${esc(friendlyErr(e))}</p>
|
||
<button class="btn btn-secondary btn-sm" id="jic-retry">重试</button></div>`;
|
||
$("#jic-retry").onclick = loadMatchJobInfo;
|
||
}
|
||
}
|
||
$("#match-job-select").addEventListener("change", loadMatchJobInfo);
|
||
|
||
$("#resume-file").addEventListener("change", async (e) => {
|
||
const f = e.target.files[0];
|
||
e.target.value = "";
|
||
if (!f) return;
|
||
const ext = (f.name.split(".").pop() || "").toLowerCase();
|
||
if (["txt", "md"].includes(ext)) { // 纯文本:本地读取,走文本解析
|
||
const reader = new FileReader();
|
||
reader.onload = () => { $("#resume-text").value = reader.result; toast(`已读取 ${f.name}`, "success"); };
|
||
reader.readAsText(f);
|
||
return;
|
||
}
|
||
if (["docx", "pdf"].includes(ext)) { // 二进制格式:上传后端解析
|
||
try {
|
||
toast(`正在解析 ${f.name}…`);
|
||
const fd = new FormData();
|
||
fd.append("file", f);
|
||
const resp = await cnFetch("/api/resume/parse/file", {
|
||
method: "POST", body: fd,
|
||
headers: { "X-Access-Token": localStorage.getItem("cn_access_token") || "" },
|
||
});
|
||
const body = await resp.json();
|
||
if (!resp.ok) throw new Error(friendlyErr({ message: body.detail || "" }, `服务响应异常(${resp.status})`));
|
||
const p = body.data;
|
||
applyParsedProfile(p, `${f.name}(已脱敏)`);
|
||
toast(`已解析 ${f.name}:识别技能 ${p.skills.length} 项`, "success");
|
||
} catch (err) { toast(friendlyErr(err, "简历解析失败,请重试"), "error"); }
|
||
return;
|
||
}
|
||
toast("仅支持 .txt / .docx / .pdf 格式", "error");
|
||
});
|
||
|
||
function applyParsedProfile(p, sourceDesc) {
|
||
lastProfile = p;
|
||
matchState.profileText = p.raw_masked_text || "";
|
||
matchState.skills = [...p.skills];
|
||
matchState.years = p.years || 0;
|
||
$("#resume-text").value = p.raw_masked_text || "";
|
||
$("#parse-summary").innerHTML =
|
||
`解析完成(来源:${esc(sourceDesc || "文本")},已自动脱敏):识别技能 <b>${p.skills.length}</b> 项 · ` +
|
||
`工作年限约 <b>${p.years || 0}</b> 年 · 场景经验 <b>${(p.scenarios || []).join("、") || "未识别"}</b>`;
|
||
$("#parse-years").value = matchState.years;
|
||
renderSkillChips();
|
||
setMatchStep(2);
|
||
}
|
||
|
||
$("#btn-parse-resume").onclick = async () => {
|
||
const text = $("#resume-text").value.trim();
|
||
if (!text) return toast("请先粘贴或上传简历", "error");
|
||
try {
|
||
const p = await post("/api/resume/parse", { text });
|
||
applyParsedProfile(p, "粘贴文本");
|
||
} catch (e) { toast(e.message, "error"); }
|
||
};
|
||
|
||
function renderSkillChips() {
|
||
const box = $("#skill-chip-editor");
|
||
box.innerHTML = matchState.skills.length
|
||
? matchState.skills.map((s, i) => `<span class="skill-chip">${esc(s)}<button data-i="${i}" title="删除">×</button></span>`).join("")
|
||
: `<span class="text-weak text-aux">暂无技能,请在下方输入添加</span>`;
|
||
box.querySelectorAll("button").forEach((b) => {
|
||
b.onclick = () => { matchState.skills.splice(+b.dataset.i, 1); renderSkillChips(); };
|
||
});
|
||
}
|
||
function flushSkillInput() {
|
||
// 支持逗号/顿号/分号/空白分隔的批量添加,去重去空
|
||
const input = $("#skill-add-input");
|
||
const parts = input.value.split(/[,,、;;\s]+/).map((s) => s.trim()).filter(Boolean);
|
||
let added = 0;
|
||
parts.forEach((v) => { if (!matchState.skills.includes(v)) { matchState.skills.push(v); added++; } });
|
||
input.value = "";
|
||
if (added) renderSkillChips();
|
||
return added;
|
||
}
|
||
$("#skill-add-input").addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") { e.preventDefault(); flushSkillInput(); }
|
||
});
|
||
$("#skill-add-input").addEventListener("blur", flushSkillInput);
|
||
$("#btn-skill-add").onclick = flushSkillInput;
|
||
$("#btn-back-1").onclick = () => setMatchStep(1);
|
||
|
||
$("#btn-run-match").onclick = async () => {
|
||
flushSkillInput(); // 输入框里未回车的技能先入库,避免丢失
|
||
const jobId = $("#match-job-select").value;
|
||
matchState.years = +$("#parse-years").value || 0;
|
||
const btn = $("#btn-run-match");
|
||
btn.disabled = true;
|
||
const embedReady = lastHealth && lastHealth.embed && lastHealth.embed.ready;
|
||
btn.textContent = embedReady
|
||
? "诊断中…(首次语义召回需加载模型,约 10 秒)"
|
||
: "诊断中…";
|
||
try {
|
||
const m = await post("/api/match", {
|
||
job_id: jobId, skills: matchState.skills, years: matchState.years,
|
||
scenarios: [],
|
||
});
|
||
matchState.result = m;
|
||
renderMatchResult(m);
|
||
setMatchStep(3);
|
||
} catch (e) { toast(e.message, "error"); }
|
||
finally { btn.disabled = false; btn.textContent = "开始诊断"; }
|
||
};
|
||
$("#btn-back-2").onclick = () => setMatchStep(1);
|
||
$("#btn-goto-path").onclick = () => {
|
||
switchView("path");
|
||
$("#path-job-select").value = $("#match-job-select").value;
|
||
loadPathJob($("#match-job-select").value, matchState.skills).then(() => $("#btn-path").click());
|
||
};
|
||
|
||
/* 总分 → 自然语言结论(答辩口径:分数只是结论的辅助) */
|
||
function matchAdvice(score) {
|
||
if (score >= 80) return "强烈推荐投递:能力画像与目标岗位高度匹配,可优先安排面试。";
|
||
if (score >= 60) return "建议补齐核心缺口后投递:已具备岗位基础,优先补足缺失的必备技能。";
|
||
return "建议先系统学习再投递:当前与岗位要求差距较大,可按学习路径分阶段提升。";
|
||
}
|
||
|
||
function renderMatchResult(m) {
|
||
drawGauge($("#gauge-canvas"), m.total_score);
|
||
$("#match-verdict").innerHTML = m.is_match
|
||
? `<span class="tag tag-green">✓ 达到匹配阈值</span> <span class="text-secondary">目标岗位:${esc(m.job_title)}</span>`
|
||
: `<span class="tag tag-orange">未达匹配阈值(60 分)</span> <span class="text-secondary">目标岗位:${esc(m.job_title)}</span>`;
|
||
$("#match-advice").innerHTML = `<b>诊断结论:</b>${matchAdvice(m.total_score)}`;
|
||
drawRadar($("#radar-canvas"), m.dimension_scores);
|
||
const chip = (s, cls) => `<span class="tag ${cls}">${esc(s)}</span>`;
|
||
$("#matched-chips").innerHTML =
|
||
[...m.matched_required.map((s) => chip(s, "tag-green")), ...m.matched_preferred.map((s) => chip(s, "tag-cyan"))].join("") ||
|
||
`<span class="text-weak text-aux">无命中(简历中未识别到本岗位所需技能)</span>`;
|
||
/* 缺口技能:超过 5 项默认折叠,避免清单压垮用户 */
|
||
const missing = [
|
||
...m.missing_required.map((s) => chip(s, "tag-red")),
|
||
...m.missing_preferred.map((s) => chip(s, "tag-orange")),
|
||
];
|
||
const missBox = $("#missing-chips");
|
||
if (!missing.length) {
|
||
missBox.innerHTML = `<span class="text-weak text-aux">无</span>`;
|
||
} else if (missing.length <= 5) {
|
||
missBox.innerHTML = missing.join("");
|
||
} else {
|
||
const renderMiss = (expanded) => {
|
||
missBox.innerHTML = (expanded ? missing : missing.slice(0, 5)).join("") +
|
||
`<button class="link-btn" id="miss-toggle">${expanded ? "收起" : `展开全部(共 ${missing.length} 项)`}</button>`;
|
||
$("#miss-toggle").onclick = () => renderMiss(!expanded);
|
||
};
|
||
renderMiss(false);
|
||
}
|
||
$("#transfer-chips").innerHTML = m.transferable_skills.map((s) => chip(s, "tag-purple")).join("") ||
|
||
`<span class="text-weak text-aux">暂无可迁移技能</span>`;
|
||
// 声明掌握但与本岗位无关的技能:显式告知"不计入评分",避免用户疑惑
|
||
// (后端已做大小写/别名归一化,这里比较时统一小写,避免 redis/Redis 误判)
|
||
const counted = new Set([...(m.matched_required || []), ...(m.matched_preferred || []),
|
||
...(m.transferable_skills || [])].map((s) => s.toLowerCase()));
|
||
const irrelevant = matchState.skills.filter((s) => !counted.has(s.toLowerCase()));
|
||
$("#irrelevant-block").classList.toggle("hidden", !irrelevant.length);
|
||
$("#irrelevant-chips").innerHTML = irrelevant.map((s) => chip(s, "tag-gray")).join("");
|
||
renderResumeHighlight(m);
|
||
renderRecJobs(m);
|
||
}
|
||
|
||
function skillClsMap(m) {
|
||
// 技能 → 高亮类别:命中必备=绿,命中加分=蓝,其他已识别=灰
|
||
const clsOf = new Map();
|
||
(m.matched_required || []).forEach((s) => clsOf.set(s.toLowerCase(), "hl-req"));
|
||
(m.matched_preferred || []).forEach((s) => clsOf.set(s.toLowerCase(), "hl-pref"));
|
||
matchState.skills.forEach((s) => { if (!clsOf.has(s.toLowerCase())) clsOf.set(s.toLowerCase(), "hl-other"); });
|
||
return clsOf;
|
||
}
|
||
|
||
function highlightHtml(text, clsOf) {
|
||
if (!clsOf.size) return esc(text);
|
||
// 单次扫描替换:按长度降序构造联合正则,避免嵌套匹配(如 Spring / Spring Boot)
|
||
const names = [...clsOf.keys()].sort((a, b) => b.length - a.length)
|
||
.map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
||
const re = new RegExp(names.join("|"), "gi");
|
||
return esc(text).replace(re, (hit) =>
|
||
`<span class="hl ${clsOf.get(hit.toLowerCase())}">${hit}</span>`);
|
||
}
|
||
|
||
function renderResumeHighlight(m) {
|
||
const box = $("#resume-highlight");
|
||
const p = lastProfile;
|
||
if (!p || !(p.raw_masked_text || "").trim()) {
|
||
box.innerHTML = `<div class="empty"><p>无简历文本(本次以技能清单直接匹配)</p></div>`;
|
||
return;
|
||
}
|
||
const clsOf = skillClsMap(m);
|
||
const photo = p.photo_b64
|
||
? `<img class="rd-photo" src="${p.photo_b64}" alt="证件照">`
|
||
: `<div class="rd-photo rd-photo-empty"><svg viewBox="0 0 24 24"><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3.6-7 8-7s8 3 8 7"/></svg><span>无照片</span></div>`;
|
||
const kv = (k, v) => `<td class="rd-k">${k}</td><td class="rd-v">${esc(v || "—")}</td>`;
|
||
const secs = (p.sections && p.sections.length)
|
||
? p.sections : [{ title: "简历全文", text: p.raw_masked_text }];
|
||
box.innerHTML = `
|
||
<table class="rd-table">
|
||
<tr>
|
||
<td class="rd-photo-cell" rowspan="3">${photo}</td>
|
||
${kv("姓名", p.name)}${kv("性别", p.gender)}
|
||
</tr>
|
||
<tr>${kv("学历", p.education)}${kv("工作年限", p.years ? p.years + " 年" : "")}</tr>
|
||
<tr>${kv("手机", p.phone)}${kv("邮箱", p.email)}</tr>
|
||
<tr><td class="rd-k">家庭住址</td><td class="rd-v" colspan="4">${esc(p.address || "—")}</td></tr>
|
||
${secs.map((sec) => `
|
||
<tr>
|
||
<td class="rd-sec-k">${esc(sec.title)}</td>
|
||
<td class="rd-sec-v" colspan="5">${highlightHtml(sec.text, clsOf)}</td>
|
||
</tr>`).join("")}
|
||
</table>`;
|
||
}
|
||
|
||
$("#btn-export-resume").onclick = async () => {
|
||
if (!lastProfile) return toast("请先解析简历", "error");
|
||
try {
|
||
const resp = await cnFetch("/api/resume/export", {
|
||
method: "POST", headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ profile: lastProfile, match: matchState.result }),
|
||
});
|
||
if (!resp.ok) throw new Error(`服务响应异常(${resp.status})`);
|
||
const blob = await resp.blob();
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `简历_${lastProfile.name || "脱敏"}_结构化.docx`;
|
||
a.click();
|
||
URL.revokeObjectURL(a.href);
|
||
toast("规范简历 DOCX 已导出", "success");
|
||
} catch (e) { toast(`导出失败:${friendlyErr(e)}`, "error"); }
|
||
};
|
||
|
||
async function renderRecJobs(current) {
|
||
const box = $("#rec-job-cards");
|
||
box.innerHTML = `<div class="text-weak text-aux">计算中…</div>`;
|
||
const others = CN.jobs.filter((j) => j.job_id !== current.job_id).slice(0, 12);
|
||
const results = await Promise.all(others.map((j) =>
|
||
post("/api/match", { job_id: j.job_id, skills: matchState.skills, years: matchState.years, scenarios: [] })
|
||
.then((m) => ({ job: j, m })).catch(() => null)));
|
||
const top = results.filter(Boolean).sort((a, b) => b.m.total_score - a.m.total_score).slice(0, 3);
|
||
box.innerHTML = top.map(({ job, m }) => `
|
||
<div class="rec-job-card" data-id="${esc(job.job_id)}">
|
||
<div class="rj-title">${esc(job.title)} <span class="tag tag-gray">${esc(job.category || "")}</span></div>
|
||
<div class="rj-score">${m.total_score}<span class="text-aux text-weak"> 分</span></div>
|
||
<div class="rj-miss">缺口:${m.missing_required.slice(0, 3).map(esc).join("、") || "无"}</div>
|
||
</div>`).join("");
|
||
box.querySelectorAll(".rec-job-card").forEach((el) => {
|
||
el.onclick = () => {
|
||
$("#match-job-select").value = el.dataset.id;
|
||
$("#btn-run-match").click();
|
||
};
|
||
});
|
||
}
|
||
|
||
function drawGauge(canvas, score) {
|
||
const ctx = canvas.getContext("2d");
|
||
ctx.clearRect(0, 0, 220, 150);
|
||
const cx = 110, cy = 120, R = 88;
|
||
ctx.lineWidth = 14; ctx.lineCap = "round";
|
||
ctx.strokeStyle = "#f0f1f5";
|
||
ctx.beginPath(); ctx.arc(cx, cy, R, Math.PI, 2 * Math.PI); ctx.stroke();
|
||
const color = score >= 80 ? "#00b42a" : score >= 60 ? "#2f54eb" : "#ff7d00";
|
||
ctx.strokeStyle = color;
|
||
ctx.beginPath(); ctx.arc(cx, cy, R, Math.PI, Math.PI * (1 + Math.min(100, score) / 100)); ctx.stroke();
|
||
$("#gauge-score").textContent = Math.round(score);
|
||
$("#gauge-score").style.color = color;
|
||
}
|
||
|
||
function drawRadar(canvas, dims) {
|
||
const ctx = canvas.getContext("2d");
|
||
ctx.clearRect(0, 0, 340, 280);
|
||
const cx = 170, cy = 145, R = 100;
|
||
const labels = { required: "必备技能", preferred: "加分技能", experience: "经验年限", scenario: "场景经验" };
|
||
const keys = Object.keys(labels);
|
||
const angle = (i) => -Math.PI / 2 + (i * 2 * Math.PI) / keys.length;
|
||
for (let ring = 1; ring <= 4; ring++) {
|
||
ctx.beginPath();
|
||
keys.forEach((_, i) => {
|
||
const r = (R * ring) / 4;
|
||
ctx[i ? "lineTo" : "moveTo"](cx + r * Math.cos(angle(i)), cy + r * Math.sin(angle(i)));
|
||
});
|
||
ctx.closePath(); ctx.strokeStyle = "#e5e6eb"; ctx.stroke();
|
||
}
|
||
keys.forEach((_, i) => {
|
||
ctx.beginPath(); ctx.moveTo(cx, cy);
|
||
ctx.lineTo(cx + R * Math.cos(angle(i)), cy + R * Math.sin(angle(i)));
|
||
ctx.strokeStyle = "#f0f0f0"; ctx.stroke();
|
||
});
|
||
ctx.beginPath();
|
||
keys.forEach((k, i) => {
|
||
const r = (R * (dims[k] || 0)) / 100;
|
||
ctx[i ? "lineTo" : "moveTo"](cx + r * Math.cos(angle(i)), cy + r * Math.sin(angle(i)));
|
||
});
|
||
ctx.closePath();
|
||
ctx.fillStyle = "rgba(47,84,235,0.18)"; ctx.fill();
|
||
ctx.strokeStyle = "#2f54eb"; ctx.lineWidth = 2; ctx.stroke();
|
||
keys.forEach((k, i) => {
|
||
const r = (R * (dims[k] || 0)) / 100;
|
||
ctx.fillStyle = "#2f54eb";
|
||
ctx.beginPath(); ctx.arc(cx + r * Math.cos(angle(i)), cy + r * Math.sin(angle(i)), 3.5, 0, 7); ctx.fill();
|
||
ctx.fillStyle = "#4e5969"; ctx.font = "12px sans-serif"; ctx.textAlign = "center";
|
||
ctx.fillText(`${labels[k]} ${Math.round(dims[k] || 0)}`,
|
||
cx + (R + 36) * Math.cos(angle(i)), cy + (R + 22) * Math.sin(angle(i)) + 4);
|
||
});
|
||
}
|
||
|
||
/* ================= 批量匹配 ================= */
|
||
const batchState = { files: [], result: null };
|
||
|
||
/* 子页签切换 */
|
||
$$(".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", () => {
|
||
const id = $("#path-job-select").value;
|
||
loadPathJob(id).then(() => {
|
||
if (lastPathData && lastPathData._demo) { lastPathData = null; renderDemoPath(); } // 示范路线跟随岗位切换
|
||
});
|
||
});
|
||
|
||
$("#btn-path").onclick = async () => {
|
||
const jobId = $("#path-job-select").value;
|
||
if (jobId !== pathState.jobId) await loadPathJob(jobId);
|
||
const btn = $("#btn-path");
|
||
btn.disabled = true;
|
||
$("#path-result").innerHTML = `<div class="empty small">正在生成学习路径:对齐岗位图谱 → 计算技能缺口 → 拆分阶段…</div>`;
|
||
try {
|
||
const data = await post("/api/learning-path", { job_id: jobId, known_skills: [...pathState.known] });
|
||
lastPathData = data;
|
||
renderPath(data);
|
||
} catch (e) {
|
||
$("#path-result").innerHTML = `<div class="empty">
|
||
<p>学习路径生成失败:${esc(friendlyErr(e))}</p>
|
||
<button class="btn btn-secondary btn-sm" id="path-retry">重试</button></div>`;
|
||
$("#path-retry").onclick = () => $("#btn-path").click();
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
};
|
||
|
||
function stageWeeks(n) {
|
||
const base = Math.max(1, Math.round(pathState.weeks / Math.max(1, n)));
|
||
return Array.from({ length: n }, () => base);
|
||
}
|
||
|
||
/* 阶段预计投入文案:按每周约 8 小时有效学习估算 */
|
||
function stageEffortText(w) {
|
||
return `${w} 周 / 约 ${w * 8} 小时`;
|
||
}
|
||
|
||
function updatePathOverview() {
|
||
if (!lastPathData || !lastPathData.learning_path.length) return;
|
||
const n = lastPathData.learning_path.length;
|
||
const done = pathState.done.size;
|
||
const pct = n ? Math.round((done / n) * 100) : 0;
|
||
/* 当前匹配分来自匹配页诊断结果(同岗位才展示);目标分 = 匹配阈值 60 */
|
||
const cur = (matchState.result && matchState.result.job_id === pathState.jobId)
|
||
? `${Math.round(matchState.result.total_score)} 分` : "未诊断";
|
||
$("#path-overview").innerHTML = `
|
||
${lastPathData._demo ? `<div class="po-demo">示范路线 · 完成人岗诊断后将替换为你的个人结果</div>` : ""}
|
||
<div class="po-item">目标岗位<b>${esc(lastPathData.title)}</b></div>
|
||
<div class="po-item">当前匹配分<b class="po-primary">${cur}</b></div>
|
||
<div class="po-item">目标分<b>60 分</b></div>
|
||
<div class="po-item">预计周期<b>${pathState.weeks} 周</b></div>
|
||
<div class="po-progress">
|
||
<div class="progress"><div class="progress-bar" style="width:${pct}%"></div></div>
|
||
<div class="path-summary-tip">学习记录:已完成 ${done}/${n} 阶段(${pct}%)· 完成阶段后可重新匹配验证</div>
|
||
</div>`;
|
||
}
|
||
|
||
/* 未诊断时展示示范路线(当前选中岗,零已掌握),完成诊断后由个人结果替换 */
|
||
async function renderDemoPath() {
|
||
if (lastPathData && lastPathData.learning_path && lastPathData.learning_path.length) return;
|
||
const jobId = $("#path-job-select").value || "job_Java开发工程师";
|
||
try {
|
||
const data = await post("/api/learning-path", { job_id: jobId, known_skills: [] });
|
||
data._demo = true;
|
||
lastPathData = data;
|
||
renderPath(data);
|
||
} catch { /* 静默:保留空状态引导 */ }
|
||
}
|
||
|
||
function renderPath(data) {
|
||
const box = $("#path-result");
|
||
$("#btn-export-path").classList.toggle("hidden", !data.learning_path.length);
|
||
$("#path-overview").classList.toggle("hidden", !data.learning_path.length);
|
||
if (!data.learning_path.length) {
|
||
box.innerHTML = `<div class="path-done">✓ 已覆盖「${esc(data.title)}」全部必备技能,无需补课</div>`;
|
||
return;
|
||
}
|
||
const weeks = stageWeeks(data.learning_path.length);
|
||
/* 阶段语义化命名:基础补齐 → 岗位核心(可多个)→ 项目验证 */
|
||
const stageName = (i, n) =>
|
||
i === 0 ? "基础补齐" : i === n - 1 ? "项目验证" : "岗位核心";
|
||
box.innerHTML = (data._demo ? `
|
||
<div class="path-demo-banner">
|
||
<b>示范路线</b>(以零已掌握技能生成):完成一次人岗诊断后,此处将自动替换为你的个人成长路线图
|
||
<button class="btn btn-secondary btn-sm" id="path-goto-match-2">去完成诊断</button>
|
||
</div>` : "") + data.learning_path.map((st, i) => {
|
||
const done = pathState.done.has(st.stage);
|
||
return `
|
||
<div class="stage-card${done ? " done" : ""}" data-stage="${st.stage}">
|
||
<span class="stage-no">${done ? "✓" : st.stage}</span>
|
||
<div class="stage-body">
|
||
<div class="stage-head">
|
||
<span class="stage-title">阶段 ${st.stage} · ${stageName(i, data.learning_path.length)}(${st.skills.length} 项技能)</span>
|
||
<span class="stage-weeks">建议 ${weeks[i]} 周</span>
|
||
</div>
|
||
${i > 0 ? `<div class="stage-prereq">前置:建议完成阶段 ${st.stage - 1} 后开始(按技能前置关系拓扑排序)</div>` : ""}
|
||
<div class="stage-skills">${st.skills.map((s) =>
|
||
`<span class="tag tag-blue" data-skill="${esc(s)}" title="点击跳转图谱节点">${esc(s)}</span>`).join("")}</div>
|
||
<div class="stage-meta">
|
||
<span>预计投入:<b>${stageEffortText(weeks[i])}</b></span>
|
||
<span>资源类型:<b>${RESOURCE_TYPES[i % RESOURCE_TYPES.length]}</b></span>
|
||
<span>技能性质:<b>必备补齐</b></span>
|
||
${st.acceptance ? `<span>能力类别:<b>${esc(st.acceptance.category)}</b></span>` : ""}
|
||
</div>
|
||
${st.acceptance ? `
|
||
<details class="stage-acc" ${i === 0 ? "open" : ""}>
|
||
<summary>验收标准(产出物 1 项 · 量化验收 ${st.acceptance.criteria.length} 项 · 自测 ${st.acceptance.self_check.length} 问)</summary>
|
||
<div class="acc-block">
|
||
<div class="acc-title">产出物</div>
|
||
<div class="acc-deliverable">${esc(st.acceptance.deliverable)}</div>
|
||
<div class="acc-title">量化验收项</div>
|
||
<ul class="acc-list">${st.acceptance.criteria.map((c, k) =>
|
||
`<li><span class="acc-no">${k + 1}</span>${esc(c)}</li>`).join("")}</ul>
|
||
<div class="acc-title">自测问答(能口头讲清才算通过)</div>
|
||
<ul class="acc-list quiz">${st.acceptance.self_check.map((q, k) =>
|
||
`<li><span class="acc-no">Q${k + 1}</span>${esc(q)}</li>`).join("")}</ul>
|
||
</div>
|
||
</details>` : ""}
|
||
<div class="stage-foot">
|
||
<button class="btn ${done ? "btn-secondary" : "btn-primary"} btn-sm stage-check" data-stage="${st.stage}">
|
||
${done ? "已完成 ✓(点击取消)" : "标记完成"}</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}).join("");
|
||
box.querySelectorAll("[data-skill]").forEach((el) => {
|
||
el.onclick = () => { switchView("graph"); if (CN.gotoNode) CN.gotoNode(el.dataset.skill); };
|
||
});
|
||
box.querySelectorAll(".stage-check").forEach((el) => {
|
||
el.onclick = () => {
|
||
const st = parseInt(el.dataset.stage, 10);
|
||
const stage = (lastPathData.learning_path || []).find((x) => x.stage === st);
|
||
if (pathState.done.has(st)) {
|
||
pathState.done.delete(st);
|
||
} else {
|
||
pathState.done.add(st);
|
||
/* 完成阶段 → 左栏「已掌握」区同步点亮该阶段技能,并提示重新匹配验证 */
|
||
if (stage && !lastPathData._demo) {
|
||
stage.skills.forEach((s) => pathState.known.add(s));
|
||
renderSkillPool();
|
||
updatePathSummary();
|
||
toast("该阶段技能已同步为「已掌握」,可回到人岗匹配重新诊断验证", "success");
|
||
} else if (stage) {
|
||
toast("示范路线:完成诊断后,阶段完成状态将同步到个人技能画像", "info");
|
||
}
|
||
}
|
||
localStorage.setItem(pathDoneKey(pathState.jobId), JSON.stringify([...pathState.done]));
|
||
renderPath(lastPathData);
|
||
};
|
||
});
|
||
const gotoMatch2 = $("#path-goto-match-2");
|
||
if (gotoMatch2) gotoMatch2.onclick = () => switchView("match");
|
||
updatePathOverview();
|
||
}
|
||
|
||
$("#btn-export-path").onclick = () => {
|
||
if (!lastPathData) return;
|
||
const lines = [`CareerNexus 学习路径 · 目标岗位:${lastPathData.title}`, ""];
|
||
lastPathData.learning_path.forEach((st) => {
|
||
lines.push(`阶段 ${st.stage}:${st.skills.join("、")}`);
|
||
if (st.acceptance) {
|
||
lines.push(` 产出物:${st.acceptance.deliverable}`);
|
||
lines.push(` 量化验收:`);
|
||
st.acceptance.criteria.forEach((c, k) => lines.push(` ${k + 1}. ${c}`));
|
||
lines.push(` 自测问答:`);
|
||
st.acceptance.self_check.forEach((q, k) => lines.push(` Q${k + 1}. ${q}`));
|
||
}
|
||
});
|
||
const blob = new Blob([lines.join("\n")], { type: "text/plain;charset=utf-8" });
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `学习路径_${lastPathData.title}.txt`;
|
||
a.click();
|
||
URL.revokeObjectURL(a.href);
|
||
toast("已导出文本", "success");
|
||
};
|
||
|
||
/* ================= 系统设置 / 状态横幅 / 演示模式 ================= */
|
||
let lastHealth = null; // 最近一次 health 响应(横幅与状态徽标的数据源)
|
||
function updateBanners(health) {
|
||
if (health) lastHealth = health;
|
||
const h = lastHealth || {};
|
||
const demo = h.demo_mode !== false;
|
||
const embed = h.embed || {};
|
||
const degraded = !!h.offline_fallback || !embed.ready;
|
||
// 演示模式:警告横幅降级为顶栏状态徽标(首屏不出现负面字眼)
|
||
const showOffline = !demo && !!h.offline_fallback
|
||
&& sessionStorage.getItem("cn_banner_dismissed") !== "1";
|
||
const showEmbed = !demo && !embed.ready
|
||
&& sessionStorage.getItem("cn_banner2_dismissed") !== "1";
|
||
$("#offline-banner").classList.toggle("hidden", !showOffline);
|
||
$("#embed-banner").classList.toggle("hidden", !showEmbed);
|
||
const n = (showOffline ? 1 : 0) + (showEmbed ? 1 : 0);
|
||
document.body.classList.toggle("has-banner", n === 1);
|
||
document.body.classList.toggle("has-banner2", n === 2);
|
||
// 顶栏状态徽标:绿=全就绪,黄=离线回退/语义未就绪
|
||
const badge = $("#demo-badge");
|
||
badge.classList.toggle("warn", degraded);
|
||
$("#demo-badge-text").textContent = degraded ? "离线演示" : (demo ? "演示模式" : "在线");
|
||
badge.title = degraded
|
||
? "离线回退中(LLM/语义模型未配置),点击查看设置"
|
||
: "全部通道就绪,点击查看设置";
|
||
}
|
||
function dismissBanner(id, key) {
|
||
sessionStorage.setItem(key, "1");
|
||
updateBanners();
|
||
}
|
||
$("#banner-close").onclick = () => dismissBanner("offline-banner", "cn_banner_dismissed");
|
||
$("#embed-banner-close").onclick = () => dismissBanner("embed-banner", "cn_banner2_dismissed");
|
||
$("#banner-config").onclick = () => openConfigModal(false);
|
||
$("#embed-banner-config").onclick = () => openConfigModal(false);
|
||
$("#demo-badge").onclick = () => openConfigModal(false);
|
||
|
||
/* ================= 演示导览(6 步评委导览,右上角浮动步进条) ================= */
|
||
const DEMO_JOB = "job_Java开发工程师";
|
||
const DEMO_CANDIDATE = "job_大模型应用工程师";
|
||
const DEMO_STEPS = [
|
||
{ title: "数据驾驶舱:多源数据总览",
|
||
desc: "JD 规模、岗位/技能/事件总量、演化趋势、清洗与审核状态",
|
||
go: () => switchView("dashboard") },
|
||
{ title: "新岗位候选:发现 → 人工审核 → 入图",
|
||
desc: "候选岗位不直接入图:四指标证据 + 人工确认,可回查证据 JD",
|
||
go: () => {
|
||
switchView("graph");
|
||
const locate = () => {
|
||
const has = CN.graph && CN.graph.has && CN.graph.has(DEMO_CANDIDATE);
|
||
if (CN.gotoNode) CN.gotoNode(has ? DEMO_CANDIDATE : DEMO_JOB);
|
||
};
|
||
if (CN.graph && CN.graph.ready) CN.graph.ready().then(locate);
|
||
else if (CN.gotoNode) CN.gotoNode(DEMO_CANDIDATE);
|
||
} },
|
||
{ title: "岗位局部图谱:Java 开发工程师",
|
||
desc: "技能点级局部图谱:必备/加分技能、支撑 JD 数与可读证据",
|
||
go: () => {
|
||
switchView("graph");
|
||
const enter = () => { if (CN.graph && CN.graph.solo) CN.graph.solo(DEMO_JOB); };
|
||
if (CN.graph && CN.graph.ready) CN.graph.ready().then(enter);
|
||
} },
|
||
{ title: "岗位演化报告:能力动态更新",
|
||
desc: "本期结论、新增/增强/减弱/退出、时点对比,每条可回查证据",
|
||
go: () => {
|
||
switchView("evolution");
|
||
$("#evo-job-select").value = DEMO_JOB;
|
||
$("#evo-job-select").dispatchEvent(new Event("change"));
|
||
} },
|
||
{ title: "人岗诊断:示例简历自动跑通",
|
||
desc: "载入示例简历 → 解析确认 → 匹配结论 / 核心缺口 / 可迁移能力",
|
||
go: async () => {
|
||
switchView("match");
|
||
$$(".match-tab").forEach((x) => x.classList.toggle("active", x.dataset.mtab === "single"));
|
||
$("#match-single").classList.remove("hidden");
|
||
$("#match-batch").classList.add("hidden");
|
||
$("#match-job-select").value = DEMO_JOB;
|
||
loadMatchJobInfo();
|
||
$("#btn-load-sample").click();
|
||
try {
|
||
const p = await post("/api/resume/parse", { text: $("#resume-text").value });
|
||
applyParsedProfile(p, "演示示例简历");
|
||
$("#btn-run-match").click();
|
||
} catch (e) { toast(friendlyErr(e), "error"); }
|
||
} },
|
||
{ title: "学习路径:可执行的三阶段路线",
|
||
desc: "自动带入岗位与技能缺口:基础补齐 → 岗位核心 → 项目验证",
|
||
go: () => {
|
||
switchView("path");
|
||
$("#path-job-select").value = DEMO_JOB;
|
||
loadPathJob(DEMO_JOB, matchState.skills).then(() => $("#btn-path").click());
|
||
} },
|
||
];
|
||
|
||
let tourIdx = -1;
|
||
function tourGo(i) {
|
||
tourIdx = Math.max(0, Math.min(DEMO_STEPS.length - 1, i));
|
||
const step = DEMO_STEPS[tourIdx];
|
||
$("#tour-bar").classList.remove("hidden");
|
||
$("#tour-prog").textContent = `演示 ${tourIdx + 1}/${DEMO_STEPS.length}`;
|
||
$("#tour-title").textContent = step.title;
|
||
$("#tour-desc").textContent = step.desc;
|
||
$("#tour-prev").disabled = tourIdx === 0;
|
||
$("#tour-next").textContent = tourIdx === DEMO_STEPS.length - 1 ? "完成" : "下一步";
|
||
Promise.resolve(step.go()).catch((e) => toast(friendlyErr(e), "error"));
|
||
}
|
||
function endTour() {
|
||
tourIdx = -1;
|
||
$("#tour-bar").classList.add("hidden");
|
||
}
|
||
$("#btn-demo-tour").onclick = () => tourGo(0);
|
||
$("#tour-prev").onclick = () => tourGo(tourIdx - 1);
|
||
$("#tour-next").onclick = () => (tourIdx === DEMO_STEPS.length - 1 ? endTour() : tourGo(tourIdx + 1));
|
||
$("#tour-exit").onclick = endTour;
|
||
|
||
function embedStatusHtml(emb) {
|
||
if (emb.ready) {
|
||
return `<span class="tag tag-green">已就绪(${emb.provider === "api" ? "联网 API" : "本地权重"},语义召回已启用)</span>`;
|
||
}
|
||
if (emb.provider === "api") {
|
||
return emb.api_key_masked
|
||
? `<span class="tag tag-orange">已配置 Key,保存后生效</span>`
|
||
: `<span class="tag tag-gray">未配置 API Key(当前使用词表归一化匹配)</span>`;
|
||
}
|
||
if (emb.configured) {
|
||
const reasons = [
|
||
!emb.weights_present ? "权重文件缺失" : "",
|
||
!emb.deps_installed ? "未安装 sentence-transformers" : "",
|
||
].filter(Boolean).join(";");
|
||
return `<span class="tag tag-orange">已配置未就绪:${esc(reasons)}</span>`;
|
||
}
|
||
return `<span class="tag tag-gray">未配置(当前使用词表归一化匹配)</span>`;
|
||
}
|
||
|
||
async function openConfigModal(needToken, firstRun) {
|
||
let cfg;
|
||
try { cfg = await api("/api/config"); }
|
||
catch (e) { toast(e.message, "error"); return; }
|
||
$("#modal-box").classList.add("wide");
|
||
const modeBadge = cfg.offline_fallback
|
||
? `<span class="tag tag-orange">离线回退</span>` : `<span class="tag tag-green">联网就绪</span>`;
|
||
openModal(firstRun ? "欢迎使用 CareerNexus 职联 · 初始设置" : "系统设置", `
|
||
${firstRun ? `<div class="cfg-welcome">
|
||
<b>首次使用,建议先完成基础配置</b>(均可稍后通过右上角 ⚙ 修改):
|
||
<br>· 配置 <b>LLM API 密钥</b>可启用大模型抽取(不配则离线确定性方案兜底)
|
||
<br>· 配置<b>语义向量模型</b>(本地 BGE-M3 或联网 API)可启用语义相似匹配
|
||
<br>· 需要对外提供服务时,再调整<b>监听地址与访问令牌</b>
|
||
</div>` : ""}
|
||
<div class="cfg-status">
|
||
<span>当前监听 <b>${esc(cfg.running.host)}:${cfg.running.port}</b></span>
|
||
<span>LLM 状态 ${modeBadge}</span>
|
||
${cfg.restart_required ? `<span class="tag tag-orange">监听变更待重启</span>` : ""}
|
||
</div>
|
||
${needToken ? `<div class="cfg-warn">该服务已启用访问令牌,请填写后保存。</div>` : ""}
|
||
<div class="cfg-sec">
|
||
<div class="cfg-sec-title">服务监听(修改后需重启生效)</div>
|
||
<div class="form-item">
|
||
<label class="form-label">监听地址</label>
|
||
<div class="seg" id="cfg-host">
|
||
<button class="seg-item ${cfg.saved.host === "127.0.0.1" ? "active" : ""}" data-host="127.0.0.1">127.0.0.1(仅本机)</button>
|
||
<button class="seg-item ${cfg.saved.host === "0.0.0.0" ? "active" : ""}" data-host="0.0.0.0">0.0.0.0(对外开放)</button>
|
||
</div>
|
||
</div>
|
||
<div class="form-item">
|
||
<label class="form-label">端口</label>
|
||
<input id="cfg-port" type="number" class="input" min="1024" max="65535" value="${cfg.saved.port}">
|
||
</div>
|
||
<div class="form-item">
|
||
<label class="form-label">访问令牌<span class="form-label-tip">对外开放时建议设置;${cfg.has_access_token ? "当前:" + esc(cfg.access_token_masked) : "当前未设置"}</span></label>
|
||
<div class="cfg-token-row">
|
||
<input id="cfg-token" class="input" type="password" placeholder="留空则清除令牌" autocomplete="new-password">
|
||
<button class="btn btn-secondary btn-sm" id="cfg-token-gen">随机生成</button>
|
||
<button class="btn btn-secondary btn-sm" id="cfg-token-show">显示</button>
|
||
</div>
|
||
</div>
|
||
<div class="form-item">
|
||
<label class="form-label">演示模式<span class="form-label-tip">开启后不弹初始设置、警告降级为顶栏状态点</span></label>
|
||
<div class="seg" id="cfg-demo">
|
||
<button class="seg-item ${cfg.demo_mode !== false ? "active" : ""}" data-demo="1">开启(评审演示)</button>
|
||
<button class="seg-item ${cfg.demo_mode === false ? "active" : ""}" data-demo="0">关闭(开发调试)</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="cfg-sec">
|
||
<div class="cfg-sec-title">LLM API(星火 / OpenAI 兼容,保存即热生效)</div>
|
||
<div class="form-item">
|
||
<label class="form-label">Base URL</label>
|
||
<input id="cfg-llm-url" class="input" value="${esc(cfg.llm.base_url)}" placeholder="https://spark-api-open.xf-yun.com/v1">
|
||
</div>
|
||
<div class="form-item">
|
||
<label class="form-label">API Key<span class="form-label-tip">${cfg.llm.configured ? "当前:" + esc(cfg.llm.api_key_masked) : "未配置(离线回退中)"}</span></label>
|
||
<input id="cfg-llm-key" class="input" type="password" placeholder="留空保持不变;输入空格清除" autocomplete="new-password">
|
||
</div>
|
||
<div class="form-item">
|
||
<label class="form-label">模型</label>
|
||
<input id="cfg-llm-model" class="input" value="${esc(cfg.llm.model)}" placeholder="4.0Ultra">
|
||
</div>
|
||
</div>
|
||
<div class="cfg-sec">
|
||
<div class="cfg-sec-title">语义向量匹配(可选增强,保存即热生效)</div>
|
||
<div class="form-item">
|
||
<label class="form-label">状态</label>
|
||
<div id="cfg-embed-status">${embedStatusHtml(cfg.embed)}</div>
|
||
</div>
|
||
<div class="form-item">
|
||
<label class="form-label">向量来源</label>
|
||
<div class="seg" id="cfg-embed-provider">
|
||
<button class="seg-item ${cfg.embed.provider !== "api" ? "active" : ""}" data-provider="local">本地权重(BGE-M3)</button>
|
||
<button class="seg-item ${cfg.embed.provider === "api" ? "active" : ""}" data-provider="api">联网 API(通义千问)</button>
|
||
</div>
|
||
</div>
|
||
<div id="cfg-embed-local" class="${cfg.embed.provider === "api" ? "hidden" : ""}">
|
||
<div class="setup-hero">
|
||
<button class="btn btn-primary" id="cfg-embed-setup">⚡ 一键下载并配置</button>
|
||
<div class="setup-hero-desc">
|
||
自动完成全部配置,<b>推荐首次使用</b>:
|
||
<br>① 安装 sentence-transformers 依赖(PyPI 镜像站)
|
||
<br>② 从 ModelScope 镜像站下载 BGE-M3 权重(约 2.2GB)
|
||
<br>③ 自动写入配置并启用语义匹配。全程约 3–8 分钟,期间可继续使用其他功能。
|
||
</div>
|
||
<div class="setup-progress hidden" id="setup-progress">
|
||
<div class="setup-stage" id="setup-stage">准备中…</div>
|
||
<div class="setup-log" id="setup-log"></div>
|
||
</div>
|
||
</div>
|
||
<div class="form-item">
|
||
<label class="form-label">权重目录<span class="form-label-tip">一键配置后自动填入;手动方式见下方说明</span></label>
|
||
<input id="cfg-embed-path" class="input" value="${esc(cfg.embed.path || "")}" placeholder="如 F:/models/bge-m3">
|
||
</div>
|
||
<div class="cfg-guide">
|
||
<b>手动配置方式(不使用一键配置时)</b>
|
||
<ol>
|
||
<li>安装依赖:<code>pip install sentence-transformers</code></li>
|
||
<li>下载权重(约 2.2GB):ModelScope(国内推荐)<code>modelscope.cn/models/BAAI/bge-m3</code> 或 HuggingFace <code>huggingface.co/BAAI/bge-m3</code></li>
|
||
<li>目录内需包含 <code>config.json</code>、<code>model.safetensors</code> 与分词器文件,填入上方路径保存即可</li>
|
||
</ol>
|
||
</div>
|
||
</div>
|
||
<div id="cfg-embed-api" class="${cfg.embed.provider === "api" ? "" : "hidden"}">
|
||
<div class="form-item">
|
||
<label class="form-label">API Key<span class="form-label-tip">${cfg.embed.api_key_masked ? "当前:" + esc(cfg.embed.api_key_masked) : "未配置"}</span></label>
|
||
<input id="cfg-embed-key" class="input" type="password" placeholder="DashScope API Key(sk-...),留空保持不变" autocomplete="new-password">
|
||
</div>
|
||
<div class="form-item">
|
||
<label class="form-label">Base URL</label>
|
||
<input id="cfg-embed-base" class="input" value="${esc(cfg.embed.api_base_url)}" placeholder="https://dashscope.aliyuncs.com/compatible-mode/v1">
|
||
</div>
|
||
<div class="form-item">
|
||
<label class="form-label">模型</label>
|
||
<input id="cfg-embed-model" class="input" value="${esc(cfg.embed.api_model)}" placeholder="text-embedding-v4">
|
||
</div>
|
||
<div class="cfg-guide">
|
||
<b>联网 API 配置引导</b>
|
||
<ol>
|
||
<li>开通阿里云百炼(DashScope),在控制台获取 API Key</li>
|
||
<li>默认接口为 OpenAI 兼容模式,模型推荐 <code>text-embedding-v4</code></li>
|
||
<li>填入 Key 保存即生效;网络不可达时自动回退词表归一化</li>
|
||
</ol>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="cfg-foot-note">如遇问题,请联系 support@rrrosmontis.icu</div>`,
|
||
`${firstRun ? `<button class="btn btn-secondary" id="cfg-skip">跳过,使用离线演示模式</button>` : ""}
|
||
<button class="btn btn-secondary" id="cfg-save-restart">保存并重启</button>
|
||
<button class="btn btn-primary" id="cfg-save">保存设置</button>`);
|
||
|
||
let host = cfg.saved.host;
|
||
$$("#cfg-host .seg-item").forEach((b) => {
|
||
b.onclick = () => {
|
||
$$("#cfg-host .seg-item").forEach((x) => x.classList.remove("active"));
|
||
b.classList.add("active");
|
||
host = b.dataset.host;
|
||
};
|
||
});
|
||
let embedProvider = cfg.embed.provider === "api" ? "api" : "local";
|
||
let demoMode = cfg.demo_mode !== false;
|
||
$$("#cfg-demo .seg-item").forEach((b) => {
|
||
b.onclick = () => {
|
||
$$("#cfg-demo .seg-item").forEach((x) => x.classList.remove("active"));
|
||
b.classList.add("active");
|
||
demoMode = b.dataset.demo === "1";
|
||
};
|
||
});
|
||
$$("#cfg-embed-provider .seg-item").forEach((b) => {
|
||
b.onclick = () => {
|
||
$$("#cfg-embed-provider .seg-item").forEach((x) => x.classList.remove("active"));
|
||
b.classList.add("active");
|
||
embedProvider = b.dataset.provider;
|
||
$("#cfg-embed-local").classList.toggle("hidden", embedProvider !== "local");
|
||
$("#cfg-embed-api").classList.toggle("hidden", embedProvider !== "api");
|
||
};
|
||
});
|
||
let tokenDirty = false; // 只有用户真正改过令牌框才提交(空串也提交 = 清除)
|
||
$("#cfg-token").addEventListener("input", () => { tokenDirty = true; });
|
||
$("#cfg-token-gen").onclick = () => {
|
||
const t = Array.from(crypto.getRandomValues(new Uint8Array(16))).map((x) => x.toString(16).padStart(2, "0")).join("");
|
||
$("#cfg-token").value = t;
|
||
$("#cfg-token").type = "text";
|
||
tokenDirty = true;
|
||
};
|
||
$("#cfg-token-show").onclick = () => {
|
||
const i = $("#cfg-token");
|
||
i.type = i.type === "password" ? "text" : "password";
|
||
};
|
||
|
||
async function doSave() {
|
||
const payload = {
|
||
host, port: parseInt($("#cfg-port").value, 10),
|
||
llm_base_url: $("#cfg-llm-url").value.trim(),
|
||
llm_model: $("#cfg-llm-model").value.trim(),
|
||
embed_provider: embedProvider,
|
||
embed_model_path: $("#cfg-embed-path").value.trim(),
|
||
embed_api_base_url: $("#cfg-embed-base").value.trim(),
|
||
embed_api_model: $("#cfg-embed-model").value.trim(),
|
||
demo_mode: demoMode,
|
||
guided: true, // 保存即视为完成初始设置
|
||
};
|
||
const tok = $("#cfg-token").value;
|
||
if (tokenDirty) payload.access_token = tok.trim(); // dirty 才提交;空串 = 清除令牌
|
||
const key = $("#cfg-llm-key").value;
|
||
if (key) payload.llm_api_key = key.trim();
|
||
const ekey = $("#cfg-embed-key").value;
|
||
if (ekey) payload.embed_api_key = ekey.trim();
|
||
const saved = await post("/api/config", payload);
|
||
if (tokenDirty) { // 保存成功后同步 localStorage,未动过令牌框则不触碰
|
||
if (payload.access_token) localStorage.setItem("cn_access_token", payload.access_token);
|
||
else localStorage.removeItem("cn_access_token");
|
||
}
|
||
lastHealth = Object.assign({}, lastHealth, {
|
||
offline_fallback: saved.offline_fallback, embed: saved.embed, demo_mode: saved.demo_mode,
|
||
});
|
||
updateBanners();
|
||
return saved;
|
||
}
|
||
$("#cfg-save").onclick = async () => {
|
||
try {
|
||
const saved = await doSave();
|
||
closeModal();
|
||
toast(saved.restart_required ? "已保存;监听地址/端口需重启后生效" : "设置已保存并热生效", "success");
|
||
} catch (e) { toast(e.message, "error"); }
|
||
};
|
||
$("#cfg-save-restart").onclick = async () => {
|
||
try {
|
||
await doSave();
|
||
await post("/api/config/restart", {});
|
||
closeModal();
|
||
toast("服务重启中,5 秒后自动刷新…", "info");
|
||
setTimeout(() => location.reload(), 5000);
|
||
} catch (e) { toast(e.message, "error"); }
|
||
};
|
||
if (firstRun) {
|
||
$("#cfg-skip").onclick = async () => {
|
||
try { await post("/api/config", { guided: true }); } catch { /* 跳过标记失败不阻塞 */ }
|
||
closeModal();
|
||
toast("已跳过初始设置,当前为离线演示模式", "info");
|
||
};
|
||
}
|
||
|
||
/* 一键下载并配置语义环境(异步任务 + 轮询进度) */
|
||
const STAGE_NAMES = { deps: "① 安装 Python 依赖", download: "② 下载 BGE-M3 权重",
|
||
config: "③ 写入配置", done: "✓ 配置完成", start: "准备中…", error: "✗ 失败" };
|
||
$("#cfg-embed-setup").onclick = async () => {
|
||
const btn = $("#cfg-embed-setup");
|
||
btn.disabled = true;
|
||
$("#setup-progress").classList.remove("hidden");
|
||
try { await post("/api/semantic/setup", {}); }
|
||
catch (e) { toast(e.message, "error"); btn.disabled = false; return; }
|
||
const timer = setInterval(async () => {
|
||
try {
|
||
const st = await api("/api/semantic/setup/status");
|
||
$("#setup-stage").textContent = STAGE_NAMES[st.stage] || st.stage || "进行中…";
|
||
$("#setup-log").textContent = (st.log || []).slice(-3).join("\n");
|
||
if (!st.running) {
|
||
clearInterval(timer);
|
||
btn.disabled = false;
|
||
if (st.done || (st.embed && st.embed.ready)) {
|
||
toast("语义向量通道已就绪", "success");
|
||
closeModal();
|
||
openConfigModal(false); // 重新打开刷新状态
|
||
lastHealth = Object.assign({}, lastHealth, { embed: st.embed });
|
||
updateBanners();
|
||
} else {
|
||
$("#setup-stage").textContent = "✗ 配置失败:" + (st.error || "未知原因");
|
||
toast("语义环境配置失败:" + (st.error || ""), "error");
|
||
}
|
||
}
|
||
} catch { /* 轮询失败继续等 */ }
|
||
}, 2000);
|
||
};
|
||
}
|
||
$("#btn-config").onclick = () => openConfigModal(false);
|
||
|
||
/* ================= 启动 ================= */
|
||
window.addEventListener("resize", () => {
|
||
if (CN._flowEvents) drawEventFlow($("#trend-canvas"), CN._flowEvents, 8);
|
||
drawHeat();
|
||
});
|
||
(async function boot() {
|
||
try {
|
||
CN.jobs = await api("/api/jobs");
|
||
const opts = CN.jobs.map((j) => `<option value="${esc(j.job_id)}">${esc(j.title)}</option>`).join("");
|
||
["#evo-job-select", "#match-job-select", "#path-job-select", "#batch-job-select"].forEach((s) => { $(s).innerHTML = opts; });
|
||
$("#evo-job-select").value = "job_Java开发工程师"; // 演化页默认锁定最强样例
|
||
$("#match-job-select").value = "job_Java开发工程师";
|
||
loadMatchJobInfo(); // 匹配页右侧目标岗位信息卡
|
||
/* 学习路径空状态:引导先做人岗诊断 */
|
||
const pathGoto = $("#path-goto-match");
|
||
if (pathGoto) pathGoto.onclick = () => switchView("match");
|
||
await loadDashboard(); // 先加载图谱缓存(演化页甘特图依赖)
|
||
await loadEvolution();
|
||
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");
|
||
}
|
||
})();
|