前端答辩级改造:错误中文化、证据弹窗可读化、图谱默认岗位子图、演化报告化、匹配60/40、六步导览

- cnFetch 统一网络层,业务化错误文案+原位重试,屏蔽内部文件名
- 证据弹窗:结论/来源平台·企业·日期/原文/命中依据+翻页
- 图谱默认 Java 岗位子图;详情面板四区块(结论/能力清单/近期变化/证据操作)
- 演化页本期结论块(需求指数口径)+近8季默认视图
- 匹配页输入区 60/40 + 岗位信息卡 + 结论分档文案 + 缺口折叠
- 学习路径空状态引导+阶段语义化命名;六步导览浮动条
- tests/test_frontend_logic.js 21 用例
This commit is contained in:
RoyirinRosmontis
2026-08-13 12:33:13 +08:00
parent 3329c55e06
commit ac6d08cdf2
6 changed files with 642 additions and 120 deletions
+1 -1
View File
@@ -14,6 +14,6 @@ if not exist ".venv\Scripts\python.exe" (
)
echo 启动 CareerNexus 职联 ...
echo 服务地址 http://127.0.0.1:9008 (关闭本窗口即停止服务)
echo 服务地址 http://127.0.0.1:9008
.venv\Scripts\python.exe main.py
pause
+74
View File
@@ -0,0 +1,74 @@
/* 关键前端逻辑单测(node 直跑):错误翻译 / 结论分档 / 阶段命名 / 本期结论分组 */
"use strict";
const fs = require("fs");
const path = require("path");
const src = fs.readFileSync(path.join(__dirname, "..", "webui", "app.js"), "utf-8");
function extractFn(name) {
const m = src.match(new RegExp(`function ${name}\\([\\s\\S]*?\\n}`));
if (!m) throw new Error(`未找到函数 ${name}`);
return m[0];
}
function loadFn(name) {
// 在独立函数作用域内定义并返回目标函数(规避 eval 严格模式作用域问题)
return new Function(`${extractFn(name)}; return ${name};`)();
}
const friendlyErr = loadFn("friendlyErr");
const matchAdvice = loadFn("matchAdvice");
let pass = 0, fail = 0;
function eq(actual, expected, label) {
const ok = actual === expected;
if (ok) pass++; else { fail++; console.error(`FAIL ${label}: 期望 ${JSON.stringify(expected)} 实际 ${JSON.stringify(actual)}`); }
}
function includes(hay, needle, label) {
const ok = String(hay).includes(needle);
if (ok) pass++; else { fail++; console.error(`FAIL ${label}: ${JSON.stringify(hay)} 不含 ${needle}`); }
}
/* friendlyErr:禁止出现 Failed to fetch / 内部文件名 / 接口路径 */
eq(friendlyErr(new TypeError("Failed to fetch")), "暂时无法连接服务,请检查后端是否已启动", "fetch 失败翻译");
eq(friendlyErr(new Error("NetworkError when attempting to fetch resource.")), "暂时无法连接服务,请检查后端是否已启动", "NetworkError 翻译");
eq(friendlyErr(new Error("记录不存在")), "记录不存在", "服务端中文 detail 原样保留");
eq(friendlyErr(new Error("HTTP 500")), "服务响应异常(500)", "HTTP 状态码中文化");
eq(friendlyErr(new Error("batch_2019.jsonl#92 读取失败")), "操作失败,请稍后重试", "内部文件名不外显");
eq(friendlyErr(new Error("")), "操作失败,请稍后重试", "空消息兜底");
/* matchAdvice:三档结论 */
includes(matchAdvice(85), "强烈推荐", ">=80 档");
includes(matchAdvice(72), "补齐核心缺口", "60-80 档");
includes(matchAdvice(40), "先系统学习", "<60 档");
includes(matchAdvice(80), "强烈推荐", "边界 80");
includes(matchAdvice(60), "补齐核心缺口", "边界 60");
includes(matchAdvice(59.9), "先系统学习", "边界 59.9");
/* 阶段语义化命名(与 renderPath 内逻辑一致) */
const STAGE_NAMES = ["基础补齐", "岗位核心", "项目验证"];
const stageName = (i) => STAGE_NAMES[i] || "综合提升";
eq(stageName(0), "基础补齐", "阶段1命名");
eq(stageName(1), "岗位核心", "阶段2命名");
eq(stageName(2), "项目验证", "阶段3命名");
eq(stageName(3), "综合提升", "阶段4顺延");
/* 本期结论分组逻辑(与 renderEvoSummary 一致) */
const evoEvents = [
{ window: "2026Q2", change_type: "新增", skill: "Kubernetes", prev_weight: 0, curr_weight: 2.52 },
{ window: "2026Q2", change_type: "增强", skill: "微服务", prev_weight: 1.3, curr_weight: 2.1 },
{ window: "2026Q3", change_type: "减弱", skill: "JSP", prev_weight: 0.8, curr_weight: 0.2 },
{ window: "2026Q3", change_type: "退出", skill: "Struts", prev_weight: 0.1, curr_weight: 0 },
];
const wins = [...new Set(evoEvents.map((e) => e.window))].sort().slice(-2);
eq(wins.join(","), "2026Q2,2026Q3", "最近两个季度窗口");
const verb = { "新增": "成为新要求", "增强": "需求显著增强", "减弱": "需求明显减弱", "退出": "退出能力画像" };
eq(verb[evoEvents[1].change_type], "需求显著增强", "增强动词");
const line = `${evoEvents[1].skill}(需求指数 ${evoEvents[1].prev_weight.toFixed(2)}→${evoEvents[1].curr_weight.toFixed(2)})`;
eq(line, "微服务(需求指数 1.30→2.10)", "需求指数文案");
/* 热度走势默认近 8 季切片 */
const heat = Array.from({ length: 30 }, (_, i) => [`20${10 + i}Q1`, i]);
eq(heat.slice(-8).length, 8, "近8季切片");
eq(heat.slice(-Infinity).length, 30, "全部范围");
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
+309 -70
View File
@@ -6,18 +6,39 @@
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 fetch(path, options);
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(body.detail || body.message || `请求失败 (${resp.status})`);
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;
}
@@ -136,23 +157,76 @@ const TYPE_META = {
};
CN.TYPE_META = TYPE_META;
/* JD 证据弹窗(演化事件 / 图谱来源共用) */
async function showJdEvidence(sid) {
openModal(`证据来源 · ${esc(sid)}`, `<div class="text-weak">加载中…</div>`);
/* 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 rec = await api(`/api/jd?sid=${encodeURIComponent(sid)}`);
$("#modal-body").innerHTML = `
<div class="jd-meta">
<span><b>${esc(rec.job_title)}</b></span><span>${esc(rec.company)}</span>
<span>${esc(rec.city || "—")}</span><span>${esc(rec.salary || "—")}</span>
<span>发布 ${esc(rec.publish_date)}</span><span>来源 ${esc(rec.source || "—")}</span>
</div>
<div class="jd-evidence">${esc(rec.text)}</div>`;
} catch (e) {
$("#modal-body").innerHTML = `<div class="empty small">原文读取失败:${esc(e.message)}</div>`;
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 = {
@@ -255,7 +329,7 @@ async function loadDashboard() {
CN.graphData = graph;
const s = health.graph || {};
updateBanners(health);
$("#data-version").textContent = `数据更新至 ${(graph.events.at(-1) || {}).window || "2026Q3"}(模拟)`;
$("#data-version").textContent = `数据截止:${(graph.events.at(-1) || {}).window || "2026Q3"} · 模拟数据`;
/* 指标卡 */
const series = quarterlyCounts(graph.events || []);
@@ -554,13 +628,47 @@ function bucketWindow(w, gran) {
async function loadEvolution() {
const id = $("#evo-job-select").value;
if (!id) return;
const data = await api(`/api/jobs/${encodeURIComponent(id)}/evolution`);
evoEvents = data.events;
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();
@@ -622,7 +730,10 @@ async function runEvoCompare() {
el.onclick = () => { switchView("graph"); if (CN.gotoNode) CN.gotoNode(el.dataset.skill); };
});
} catch (e) {
box.innerHTML = `<div class="empty small">对比失败:${esc(e.message)}</div>`;
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;
@@ -636,6 +747,21 @@ $$("#evo-gran button").forEach((b) => {
};
});
/* 热度走势范围:默认最近 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);
@@ -651,31 +777,28 @@ function renderEvolution() {
e.intervals.some(([s, en]) => s <= w && (en === null || w < en))).length;
});
CN._heat = Object.entries(buckets).sort((a, b) => (a[0] < b[0] ? -1 : 1));
drawHeatLine($("#evo-heat-canvas"), CN._heat);
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)}</div>
<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];
const sids = e.source_ids || [];
if (!sids.length) return toast("该事件无关联证据", "info");
if (sids.length === 1) return showJdEvidence(sids[0]);
openModal(`证据来源 · ${esc(e.skill)}(${sids.length} 条)`,
sids.map((s) => `<div class="src-item" data-sid="${esc(s)}">
<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>${esc(s)}</div>`).join(""));
$$("#modal-body .src-item").forEach((x) => { x.onclick = () => showJdEvidence(x.dataset.sid); });
showJdEvidence(e.source_ids || [], {
skill: e.skill, jobTitle, changeType: e.change_type, window: e.window,
});
};
});
}
@@ -827,6 +950,34 @@ $("#btn-load-sample").onclick = async () => {
"参与过电商与金融场景的交易系统建设,具备分布式系统调优经验。";
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 = "";
@@ -843,16 +994,16 @@ $("#resume-file").addEventListener("change", async (e) => {
toast(`正在解析 ${f.name}…`);
const fd = new FormData();
fd.append("file", f);
const resp = await fetch("/api/resume/parse/file", {
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(body.detail || `HTTP ${resp.status}`);
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(`解析失败:${err.message}`, "error"); }
} catch (err) { toast(friendlyErr(err, "简历解析失败,请重试"), "error"); }
return;
}
toast("仅支持 .txt / .docx / .pdf 格式", "error");
@@ -928,19 +1079,42 @@ $("#btn-goto-path").onclick = () => {
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>`;
$("#missing-chips").innerHTML =
[...m.missing_required.map((s) => chip(s, "tag-red")), ...m.missing_preferred.map((s) => chip(s, "tag-orange"))].join("") ||
`<span class="text-weak text-aux">无</span>`;
/* 缺口技能:超过 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>`;
// 声明掌握但与本岗位无关的技能:显式告知"不计入评分",避免用户疑惑
@@ -1007,11 +1181,11 @@ function renderResumeHighlight(m) {
$("#btn-export-resume").onclick = async () => {
if (!lastProfile) return toast("请先解析简历", "error");
try {
const resp = await fetch("/api/resume/export", {
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(`HTTP ${resp.status}`);
if (!resp.ok) throw new Error(`服务响应异常(${resp.status})`);
const blob = await resp.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
@@ -1019,7 +1193,7 @@ $("#btn-export-resume").onclick = async () => {
a.click();
URL.revokeObjectURL(a.href);
toast("规范简历 DOCX 已导出", "success");
} catch (e) { toast(`导出失败:${e.message}`, "error"); }
} catch (e) { toast(`导出失败:${friendlyErr(e)}`, "error"); }
};
async function renderRecJobs(current) {
@@ -1175,16 +1349,18 @@ $("#btn-batch-run").onclick = async () => {
const fd = new FormData();
fd.append("job_id", $("#batch-job-select").value);
batchState.files.forEach((f) => fd.append("files", f, f.name));
const resp = await fetch("/api/match/batch", {
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(body.detail || `HTTP ${resp.status}`);
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">批量匹配失败:${esc(e2.message)}</div>`;
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>`;
}
};
@@ -1263,7 +1439,12 @@ async function loadPathJob(jobId, presetKnown) {
pathState.preferred = def.preferred_skills || [];
renderSkillPool();
updatePathSummary();
} catch (e) { pool.innerHTML = `<div class="pool-loading">加载失败:${esc(e.message)}</div>`; }
} 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() {
@@ -1308,11 +1489,21 @@ $("#path-job-select").addEventListener("change", () => loadPathJob($("#path-job-
$("#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) { toast(e.message, "error"); }
} 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) {
@@ -1346,6 +1537,9 @@ function renderPath(data) {
return;
}
const weeks = stageWeeks(data.learning_path.length);
/* 阶段语义化命名:基础补齐 → 岗位核心 → 项目验证(超出 3 阶段顺延为综合提升) */
const STAGE_NAMES = ["基础补齐", "岗位核心", "项目验证"];
const stageName = (i) => STAGE_NAMES[i] || "综合提升";
box.innerHTML = data.learning_path.map((st, i) => {
const done = pathState.done.has(st.stage);
return `
@@ -1353,7 +1547,7 @@ function renderPath(data) {
<span class="stage-no">${done ? "✓" : st.stage}</span>
<div class="stage-body">
<div class="stage-head">
<span class="stage-title">阶段 ${st.stage} · ${st.skills.length} 项技能</span>
<span class="stage-title">阶段 ${st.stage} · ${stageName(i)}(${st.skills.length} 项技能)</span>
<span class="stage-weeks">建议 ${weeks[i]} 周</span>
</div>
${i > 0 ? `<div class="stage-prereq">前置:建议完成阶段 ${st.stage - 1} 后开始(按技能前置关系拓扑排序)</div>` : ""}
@@ -1457,42 +1651,83 @@ $("#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 规模、岗位/技能/事件总量、演化趋势、清洗与审核状态",
{ title: "数据驾驶舱:多源数据总览",
desc: "JD 规模、岗位/技能/事件总量、演化趋势、清洗与审核状态",
go: () => switchView("dashboard") },
{ title: "② 新岗位发现:候选→审核→入图", desc: "图谱定位「大模型应用工程师」:四指标证据、人工确认、证据 JD 回查",
go: () => { switchView("graph"); if (CN.gotoNode) CN.gotoNode("job_大模型应用工程师"); } },
{ title: "③ 既有岗位演化:Java 编年史", desc: "时点对比、变更堆叠图、生命周期甘特、事件证据链",
{ 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 = "job_Java开发工程师";
$("#evo-job-select").value = DEMO_JOB;
$("#evo-job-select").dispatchEvent(new Event("change"));
} },
{ title: "④ 人岗匹配诊断 + 学习路径", desc: "载入示例简历 → 确认解析 → 总分/四维/差距清单 → 一键生成学习路径",
go: () => {
{ 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());
} },
];
$("#btn-demo-tour").onclick = () => {
openModal("演示导览(预置路线)", `
<div class="tour-intro">按预置路线依次演示完整闭环,每步点击「前往」自动跳转到位:</div>
${DEMO_STEPS.map((s, i) => `
<div class="tour-step">
<div class="tour-step-body">
<div class="tour-step-title">${esc(s.title)}</div>
<div class="tour-step-desc">${esc(s.desc)}</div>
</div>
<button class="btn btn-primary btn-sm" data-step="${i}">前往</button>
</div>`).join("")}`);
$$("#modal-body [data-step]").forEach((b) => {
b.onclick = () => { closeModal(); DEMO_STEPS[+b.dataset.step].go(); };
});
};
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) {
@@ -1774,7 +2009,7 @@ $("#btn-config").onclick = () => openConfigModal(false);
/* ================= 启动 ================= */
window.addEventListener("resize", () => {
if (CN._trend) drawTrend($("#trend-canvas"), CN._trend);
if (CN._heat) drawHeatLine($("#evo-heat-canvas"), CN._heat);
drawHeat();
});
(async function boot() {
try {
@@ -1783,6 +2018,10 @@ window.addEventListener("resize", () => {
["#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();
@@ -1795,6 +2034,6 @@ window.addEventListener("resize", () => {
openConfigModal(false, true);
}
} catch (e) {
toast("后端连接失败:" + e.message, "error");
toast(friendlyErr(e, "后端连接失败,请检查服务是否已启动"), "error");
}
})();
+121 -24
View File
@@ -25,6 +25,7 @@
const canvas = () => $("#graph-canvas");
const NOTES_KEY = "cn_notes";
const VIEW_KEY = "cn_graph_view";
const DEFAULT_SOLO_JOB = "job_Java开发工程师"; // 首屏默认岗位子图(局部可读)
/* ================= 数据装载 ================= */
async function load() {
@@ -61,8 +62,16 @@
buildFilterOptions();
layout();
restoreView() || fitView();
/* 默认进入「Java开发工程师」岗位子图:首屏局部可读,顶栏徽标可退出全图(每次装载仅应用一次) */
if (!G._defaultApplied && !G.pendingLocate && G.idx[DEFAULT_SOLO_JOB]) {
G._defaultApplied = true;
G.soloJob = DEFAULT_SOLO_JOB;
updateSoloBadge();
selectNode(DEFAULT_SOLO_JOB, false);
centerOn(G.nodes[G.idx[DEFAULT_SOLO_JOB]], Math.max(G.cam.k, 1.05));
}
renderSuggest();
renderChain(null);
renderChain(G.soloJob || null);
$("#graph-loading").classList.add("hidden");
requestDraw();
drawMinimap();
@@ -513,7 +522,7 @@
G.events.slice(-4).reverse().forEach((e) => items.push({
color: "#2f54eb", kind: "event", id: e.job_id,
text: `「${esc(e.job_id.replace(/^job_/, ""))}」${esc(e.change_type)}技能 ${esc(e.skill)}`,
sub: `窗口 ${esc(e.window)} · 权重 ${e.prev_weight.toFixed(2)}→${e.curr_weight.toFixed(2)}`,
sub: `窗口 ${esc(e.window)} · 需求指数 ${e.prev_weight.toFixed(2)}→${e.curr_weight.toFixed(2)}`,
}));
$("#gs-list").innerHTML = items.length ? items.map((it, i) => `
<div class="gs-item" data-i="${i}">
@@ -596,6 +605,12 @@
const n = G.nodes[G.idx[id]];
if (!n) { G.pendingLocate = id; return; } // 数据未就绪:加载完成后补定位
G.pendingLocate = null;
/* 目标不在当前岗位子图内:自动退出子图,避免"定位到不可见节点" */
if (G.soloJob && id !== G.soloJob && !neighborsOf(G.soloJob).has(id)) {
G.soloJob = null;
updateSoloBadge();
fitView();
}
selectNode(id);
centerOn(n, Math.max(G.cam.k, 0.9));
}
@@ -611,7 +626,7 @@
}
}
const soloExit = $("#solo-exit");
if (soloExit) soloExit.onclick = () => { G.soloJob = null; updateSoloBadge(); requestDraw(); };
if (soloExit) soloExit.onclick = () => { G.soloJob = null; updateSoloBadge(); fitView(); };
/* ================= 备注(localStorage) ================= */
const getNotes = () => JSON.parse(localStorage.getItem(NOTES_KEY) || "{}");
@@ -640,6 +655,7 @@
if (!n) return;
const meta = TYPE_META[n.type] || { name: n.type, tag: "tag-gray", color: "#86909c" };
const isPending = n.pending || n.status === "pending";
const isJob = n.type === "Job" && !isPending;
/* 关联 */
const rels = { Job: [], Skill: [], Scenario: [], Industry: [] };
@@ -654,7 +670,12 @@
if (on && rels[on.type]) rels[on.type].push({ id: other, title: on.title || other, imp, pending: e.pending });
});
/* 基础信息 */
/* 岗位定义(结论区块的一句话定义来源;失败不阻塞面板) */
const jobDef = isJob
? await api(`/api/jobs/${encodeURIComponent(id)}`).then((d) => d.definition || d).catch(() => null)
: null;
/* 基础信息(非岗位节点 / 待审核候选保留原始平铺) */
const kv = [];
if (n.type === "Job") {
kv.push(["类别", n.category || "—"], ["期望年限", n.expected_years != null ? `${n.expected_years} 年` : "—"]);
@@ -662,7 +683,7 @@
}
if (n.type === "Skill") kv.push(["类别", n.category || "通用技能"]);
kv.push(["级别", n.level || (n.type === "Job" ? jobLevel(n) || "—" : "—")]);
if (n.weight != null) kv.push(["权重", Number(n.weight).toFixed(2)]);
if (n.weight != null) kv.push(["需求指数", Number(n.weight).toFixed(2)]);
if (n.importance) kv.push(["重要性", n.importance === "required" ? "必备" : n.importance]);
const latestWin = G.edges.filter((e) => (e.source === id || e.target === id) && e.intervals?.length)
.flatMap((e) => e.intervals.map((i) => i[0])).sort().at(-1);
@@ -685,6 +706,46 @@
const skillEvents = n.type === "Skill"
? G.events.filter((e) => e.skill === id).sort((a, b) => (a.window < b.window ? 1 : -1)).slice(0, 3) : [];
/* 证据来源行:可读文案「企业 · 平台 · 日期」异步填充,内部 source_id 不直接展示 */
const srcRowsHtml = srcList.length
? srcList.slice(0, 10).map((s, i) => `
<div class="src-item" data-sid="${esc(s)}">
<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
<span class="src-meta" data-sid="${esc(s)}">证据 JD · 第 ${i + 1} 条(来源信息加载中…)</span></div>`).join("")
+ (srcList.length > 10 ? `<div class="text-weak text-aux" style="padding:2px 8px">…共 ${srcList.length} 条</div>` : "")
: `<div class="text-weak text-aux">暂无来源记录</div>`;
/* 岗位节点四区块:结论 / 能力清单 / 近期变化 / 可信证据与操作 */
const reqSkills = rels.Skill.filter((r) => r.imp === "required");
const prefSkills = rels.Skill.filter((r) => r.imp !== "required");
const jobEvents = isJob
? G.events.filter((e) => e.job_id === id).sort((a, b) => (a.window < b.window ? 1 : -1)).slice(0, 3)
: [];
const dataCutoff = ((G.events.at(-1) || {}).window) || latestWin || "2026Q3";
const EV_TAG = { "新增": "tag-green", "增强": "tag-blue", "减弱": "tag-orange", "退出": "tag-red" };
const skillChip = (r, cls) => `<span class="tag ${cls} dt-skill" data-id="${esc(r.id)}" title="点击聚焦该技能">${esc(r.title)}</span>`;
const jobBlocks = isJob ? `
<div class="dt-sec"><div class="dt-sec-title">结论</div>
${jobDef && (jobDef.responsibilities || [])[0] ? `<div class="dt-def">${esc(jobDef.responsibilities[0])}</div>` : ""}
<div class="kv"><span class="k">级别</span><span class="v">${esc(n.level || jobLevel(n) || "—")}</span></div>
<div class="kv"><span class="k">类别</span><span class="v">${esc(n.category || "—")}</span></div>
<div class="dt-support">支撑 ${n.source_count ?? (jobDef || {}).source_count ?? "—"} 条 JD · ${n.company_count ?? (jobDef || {}).company_count ?? "—"} 家企业 · 数据截止 ${esc(dataCutoff)}</div>
</div>
<div class="dt-sec"><div class="dt-sec-title">能力清单</div>
<div class="kv"><span class="k">必备技能(${reqSkills.length})</span></div>
<div class="dt-chips">${reqSkills.map((r) => skillChip(r, "tag-green")).join("") || '<span class="text-weak text-aux">无</span>'}</div>
<div class="kv"><span class="k">加分技能(${prefSkills.length})</span></div>
<div class="dt-chips">${prefSkills.map((r) => skillChip(r, "tag-cyan")).join("") || '<span class="text-weak text-aux">无</span>'}</div>
</div>
<div class="dt-sec"><div class="dt-sec-title">近期变化</div>
${jobEvents.length ? jobEvents.map((e, i) => `
<div class="dt-ev" data-evi="${i}" title="点击查看证据 JD">
<span class="tag ${EV_TAG[e.change_type] || "tag-gray"}">${esc(e.change_type)}</span>
<span class="dt-ev-skill">${esc(e.skill)}</span>
<span class="text-weak text-aux">${esc(e.window)} · ${(e.source_ids || []).length} 条 JD 支撑</span>
</div>`).join("") : `<div class="text-weak text-aux">近期无变化记录</div>`}
</div>` : "";
$("#detail-empty").classList.add("hidden");
const body = $("#detail-body");
body.classList.remove("hidden");
@@ -704,27 +765,25 @@
</div>
${G.navStack.length ? `<button class="btn btn-text btn-sm dt-back" id="dt-back">← 返回上一个节点</button>` : ""}
<div class="dt-sec"><div class="dt-sec-title">基础信息</div>
${kv.map(([k, v]) => `<div class="kv"><span class="k">${k}</span><span class="v">${esc(v)}</span></div>`).join("") || `<div class="text-weak text-aux">暂无属性</div>`}
</div>
${jobBlocks}
${relHtml ? `<div class="dt-sec"><div class="dt-sec-title">关联信息(点击跳转)</div>${relHtml}</div>` : ""}
${!isJob ? `<div class="dt-sec"><div class="dt-sec-title">基础信息</div>
${kv.map(([k, v]) => `<div class="kv"><span class="k">${k}</span><span class="v">${esc(v)}</span></div>`).join("") || `<div class="text-weak text-aux">暂无属性</div>`}
</div>` : ""}
${!isJob && relHtml ? `<div class="dt-sec"><div class="dt-sec-title">关联信息(点击跳转)</div>${relHtml}</div>` : ""}
${n.type === "Skill" ? `
<div class="dt-sec"><div class="dt-sec-title">演化信息</div>
<div class="sparkline-box"><canvas id="dt-spark" width="264" height="52"></canvas>
<div class="text-weak text-aux" style="margin-top:4px">近 12 个季度热度(活跃岗位关系数)</div></div>
${skillEvents.map((e) => `<div class="audit-item"><div class="au-head">
<span class="tag ${({ "新增": "tag-green", "增强": "tag-blue", "减弱": "tag-orange", "退出": "tag-red" })[e.change_type] || "tag-gray"}">${e.change_type}</span>
<span class="tag ${EV_TAG[e.change_type] || "tag-gray"}">${e.change_type}</span>
<b>${esc(e.job_id.replace(/^job_/, ""))}</b><span class="text-weak">${esc(e.window)}</span></div></div>`).join("")}
</div>` : ""}
<div class="dt-sec"><div class="dt-sec-title">证据来源(支撑 JD,点击查看原文)</div>
${srcList.length ? srcList.slice(0, 10).map((s) => `
<div class="src-item" data-sid="${esc(s)}">
<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>${esc(s)}</div>`).join("")
+ (srcList.length > 10 ? `<div class="text-weak text-aux" style="padding:2px 8px">…共 ${srcList.length} 条</div>` : "")
: `<div class="text-weak text-aux">暂无来源记录</div>`}
<div class="dt-sec"><div class="dt-sec-title">可信证据(支撑 JD,点击查看原文)</div>
${srcRowsHtml}
</div>
<div class="dt-sec"><div class="dt-sec-title">操作</div>
@@ -733,11 +792,12 @@
<button class="btn btn-primary btn-sm" id="dt-accept">确认入库</button>
<button class="btn btn-danger btn-sm" id="dt-reject">驳回</button>` : `
<button class="btn btn-secondary btn-sm" id="dt-edit">编辑节点</button>
<button class="btn btn-secondary btn-sm" id="dt-sources">查看来源</button>`}
<button class="btn btn-secondary btn-sm" id="dt-sources">查看支撑 JD</button>`}
<button class="btn btn-secondary btn-sm" id="dt-note">添加备注</button>
${n.type === "Job" && !isPending ? `<button class="btn btn-secondary btn-sm" id="dt-evo">查看演化</button>
${isJob ? `<button class="btn btn-secondary btn-sm" id="dt-evo">查看演化</button>
<button class="btn btn-secondary btn-sm" id="dt-solo">岗位子图</button>
<button class="btn btn-primary btn-sm full" id="dt-learn">生成学习路径</button>` : ""}
<button class="btn btn-primary btn-sm full" id="dt-match">开始人岗诊断</button>
<button class="btn btn-secondary btn-sm full" id="dt-learn">生成学习路径</button>` : ""}
</div>
</div>
@@ -761,7 +821,20 @@
const back = $("#dt-back");
if (back) back.onclick = () => { const prev = G.navStack.pop(); if (prev) selectNode(prev, false); };
body.querySelectorAll(".rel-item").forEach((el) => { el.onclick = () => locateNode(el.dataset.id); });
body.querySelectorAll(".src-item").forEach((el) => { el.onclick = () => CN.showJdEvidence(el.dataset.sid); });
body.querySelectorAll(".dt-skill").forEach((el) => { el.onclick = () => locateNode(el.dataset.id); });
body.querySelectorAll(".src-item").forEach((el) => {
el.onclick = () => CN.showJdEvidence(el.dataset.sid, { jobTitle: n.title || n.id });
});
/* 来源行异步填充「企业 · 平台 · 日期」 */
body.querySelectorAll(".src-meta").forEach((el) => { CN.jdMetaLine(el, el.dataset.sid); });
body.querySelectorAll(".dt-ev").forEach((el) => {
el.onclick = () => {
const e = jobEvents[+el.dataset.evi];
CN.showJdEvidence(e.source_ids || [], {
skill: e.skill, jobTitle: n.title || n.id, changeType: e.change_type, window: e.window,
});
};
});
body.querySelectorAll(".nt-del").forEach((el) => {
el.onclick = () => { delNote(id, +el.dataset.i); renderDetail(id); };
});
@@ -771,8 +844,8 @@
};
const srcBtn = $("#dt-sources");
if (srcBtn) srcBtn.onclick = () => {
const first = srcList[0];
if (first) CN.showJdEvidence(first); else toast("暂无来源记录", "info");
if (srcList.length) CN.showJdEvidence(srcList, { jobTitle: n.title || n.id });
else toast("暂无来源记录", "info");
};
const editBtn = $("#dt-edit");
if (editBtn) editBtn.onclick = () => openEditModal(n);
@@ -785,9 +858,18 @@
const soloBtn = $("#dt-solo");
if (soloBtn) soloBtn.onclick = () => {
G.soloJob = G.soloJob === id ? null : id;
G._defaultApplied = true;
updateSoloBadge();
requestDraw();
if (G.soloJob) toast(`已进入「${n.title || id}」岗位子图(只看该岗位与一跳技能/场景/行业)`, "success");
else fitView();
};
const matchBtn = $("#dt-match");
if (matchBtn) matchBtn.onclick = () => {
CN.switchView("match");
$("#match-job-select").value = id;
$("#match-job-select").dispatchEvent(new Event("change"));
toast("已带入目标岗位,请上传或载入示例简历", "success");
};
const learnBtn = $("#dt-learn");
if (learnBtn) learnBtn.onclick = () => {
@@ -854,7 +936,7 @@
<option value="required" ${n.importance === "required" ? "selected" : ""}>必备</option>
<option value="preferred" ${n.importance === "preferred" ? "selected" : ""}>加分</option>
</select></div>
<div class="form-row"><label class="form-label">权重(0-1)</label>
<div class="form-row"><label class="form-label">需求指数(0-1)</label>
<input id="ef-weight" type="number" min="0" max="1" step="0.05" class="input" style="width:100%" value="${n.weight ?? ""}"></div>
<div class="form-row"><label class="form-label">操作人</label>
<input id="ef-operator" class="input" style="width:100%" value="评审专家"></div>
@@ -894,12 +976,27 @@
/* ================= 对外接口 ================= */
CN.graph = {
inited: false,
_p: null,
async init() {
if (this.inited) return;
this.inited = true;
bindCanvas();
bindToolbar();
await load();
this._p = load();
await this._p;
},
/* 数据装载完成 Promise(演示导览等需要等图谱就绪后定位) */
ready() { return this._p || Promise.resolve(); },
has(id) { return id in G.idx; },
/* 进入指定岗位的局部子图(演示导览第 3 步) */
solo(id) {
if (!(id in G.idx)) return;
G._defaultApplied = true;
G.soloJob = id;
updateSoloBadge();
selectNode(id, false);
centerOn(G.nodes[G.idx[id]], Math.max(G.cam.k, 1.05));
requestDraw();
},
onShow() { requestDraw(); drawMinimap(); },
async reload() {
+60 -25
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CareerNexus 职联 · 岗位与能力图谱平台</title>
<link rel="stylesheet" href="/static/style.css?v=20260803">
<link rel="stylesheet" href="/static/style.css?v=20260813">
</head>
<body>
<!-- ============ 顶部栏 56px ============ -->
@@ -15,6 +15,12 @@
</span>
<span class="brand-name">CareerNexus <b>职联</b></span>
</div>
<div class="topbar-left-actions">
<button class="btn btn-primary btn-sm" id="btn-demo-tour">
<svg viewBox="0 0 24 24"><polygon points="6 3 20 12 6 21 6 3"/></svg>
开始演示</button>
<span class="demo-badge" id="demo-badge" title="运行状态(点击查看设置)"><i class="dot"></i><span id="demo-badge-text">演示模式</span></span>
</div>
<div class="page-title" id="page-title">数据驾驶舱</div>
<div class="topbar-right">
<div class="global-search">
@@ -22,10 +28,6 @@
<input id="global-search-input" type="text" placeholder="搜索岗位 / 技能 / 场景…" autocomplete="off">
<div id="global-search-drop" class="search-drop hidden"></div>
</div>
<button class="btn btn-primary btn-sm" id="btn-demo-tour">
<svg viewBox="0 0 24 24"><polygon points="6 3 20 12 6 21 6 3"/></svg>
开始演示</button>
<span class="demo-badge" id="demo-badge" title="运行状态(点击查看设置)"><i class="dot"></i><span id="demo-badge-text">演示模式</span></span>
<span class="badge badge-version" id="data-version" title="图谱数据版本">数据版本 —</span>
<button class="icon-btn" id="btn-config" title="系统设置" aria-label="系统设置">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09a1.65 1.65 0 00-1-1.51 1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09a1.65 1.65 0 001.51-1 1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33h.01a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51h.01a1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82v.01a1.65 1.65 0 001.51 1H21a2 2 0 110 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
@@ -257,6 +259,10 @@
<!-- 统计条 -->
<div class="evo-stats" id="evo-stats"></div>
<!-- 本期结论(最近两个季度的新增/增强/减弱/退出) -->
<div class="evo-chart-title text-aux text-secondary">本期结论(最近两个季度)</div>
<div class="evo-summary" id="evo-summary"><div class="empty small">加载中…</div></div>
<!-- 时点对比器 -->
<div class="evo-compare">
<div class="evo-compare-head">
@@ -280,8 +286,14 @@
<div class="evo-chart-title text-aux text-secondary">技能生命周期(横条 = 技能在画像中的生效区间,圆点 = 变更事件)</div>
<div class="evo-gantt" id="evo-gantt"></div>
<!-- 热度走势(限高) -->
<div class="evo-chart-title text-aux text-secondary">技能热度走势(活跃技能关系数)</div>
<!-- 热度走势(限高,默认近 8 个季度,纵轴为活跃技能关系数) -->
<div class="evo-chart-title text-aux text-secondary evo-heat-head">
<span>技能热度走势(纵轴:活跃技能关系数)</span>
<span class="seg seg-sm" id="evo-heat-range">
<button data-range="8" class="active">近 8 季</button>
<button data-range="all">全部</button>
</span>
</div>
<div class="evo-heat-wrap"><canvas id="evo-heat-canvas"></canvas></div>
<!-- 事件时间轴 -->
@@ -306,22 +318,31 @@
<div class="step" data-step="3"><i>3</i><span>诊断结果</span></div>
</div>
<!-- 第 1 步 -->
<!-- 第 1 步:60/40 布局(左:简历输入;右:目标岗位信息卡) -->
<div class="match-pane" id="match-pane-1">
<div class="form-row">
<label class="form-label">目标岗位</label>
<select id="match-job-select" class="select w-320"></select>
</div>
<div class="form-row">
<label class="form-label">简历文本</label>
<textarea id="resume-text" class="textarea" rows="10" placeholder="粘贴简历全文(将自动脱敏与技能抽取),或点击下方按钮上传 / 载入示例简历"></textarea>
</div>
<div class="form-actions">
<button class="btn btn-secondary" id="btn-load-sample">载入示例简历</button>
<label class="btn btn-secondary" for="resume-file">上传简历文件</label>
<input type="file" id="resume-file" accept=".txt,.md,.docx,.pdf" class="hidden">
<span class="text-aux text-weak">支持 .txt / .docx / .pdf,≤5MB</span>
<button class="btn btn-primary" id="btn-parse-resume">解析简历</button>
<div class="match-input-grid">
<div class="match-input-left">
<div class="form-row">
<label class="form-label">目标岗位</label>
<select id="match-job-select" class="select w-320"></select>
</div>
<div class="form-row">
<label class="form-label">简历文本</label>
<textarea id="resume-text" class="textarea" rows="10" placeholder="粘贴简历全文(将自动脱敏与技能抽取),或点击下方按钮上传 / 载入示例简历"></textarea>
</div>
<div class="form-actions">
<button class="btn btn-secondary" id="btn-load-sample">载入示例简历</button>
<label class="btn btn-secondary" for="resume-file">上传简历文件</label>
<input type="file" id="resume-file" accept=".txt,.md,.docx,.pdf" class="hidden">
<span class="text-aux text-weak">支持 .txt / .docx / .pdf,≤5MB</span>
<button class="btn btn-primary" id="btn-parse-resume">开始解析</button>
</div>
</div>
<div class="match-input-right">
<div class="job-info-card" id="match-job-info">
<div class="text-weak text-aux">岗位信息加载中…</div>
</div>
</div>
</div>
</div>
@@ -374,6 +395,7 @@
<div class="gauge-text"><b id="gauge-score">0</b><span>综合匹配分</span></div>
</div>
<div class="match-verdict" id="match-verdict"></div>
<div class="match-advice" id="match-advice"></div>
<canvas id="radar-canvas" width="340" height="280"></canvas>
</div>
<div class="mrg-right">
@@ -491,7 +513,8 @@
<div class="path-flow" id="path-result">
<div class="empty">
<svg viewBox="0 0 24 24"><circle cx="6" cy="19" r="2.5"/><circle cx="18" cy="5" r="2.5"/><path d="M8 17.5C12 14 8 10 12 7.5c1.5-1 3-1.2 4-1.3"/></svg>
<p>在左侧选择目标岗位并标记已掌握技能<br>点击「生成学习路径」查看分阶段路线</p>
<p>尚未生成学习路径<br>先完成一次人岗诊断,再生成个性化路径(将自动带入岗位与技能缺口)</p>
<button class="btn btn-primary btn-sm" id="path-goto-match">去完成人岗诊断</button>
</div>
</div>
</div>
@@ -515,7 +538,19 @@
</div>
<div class="toast-wrap" id="toast-wrap"></div>
<script src="/static/app.js?v=20260803"></script>
<script src="/static/graph.js?v=20260803"></script>
<!-- 评委演示导览浮动条(右上角固定) -->
<div class="tour-bar hidden" id="tour-bar">
<span class="tour-prog" id="tour-prog">演示 1/6</span>
<span class="tour-text">
<b id="tour-title"></b>
<span class="tour-desc" id="tour-desc"></span>
</span>
<button class="btn btn-secondary btn-sm" id="tour-prev">上一步</button>
<button class="btn btn-primary btn-sm" id="tour-next">下一步</button>
<button class="btn btn-text btn-sm" id="tour-exit">退出</button>
</div>
<script src="/static/app.js?v=20260813"></script>
<script src="/static/graph.js?v=20260813"></script>
</body>
</html>
+77
View File
@@ -947,3 +947,80 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width:
.tour-step-title { font-size: 13px; font-weight: 600; color: var(--text-1); margin-bottom: 2px; }
.tour-step-desc { font-size: 12px; color: var(--text-3); }
.cfg-foot-note { text-align: center; font-size: 12px; color: var(--text-3); padding: 4px 0 2px; }
/* 顶栏左侧操作区(开始演示 + 状态徽标) */
.topbar-left-actions { display: flex; align-items: center; gap: 10px; margin-left: 22px; }
/* ============ 2026-08 答辩级改造新增样式 ============ */
/* 评委演示导览浮动条(右上角固定) */
.tour-bar {
position: fixed; top: calc(var(--topbar-h) + 10px); right: 16px; z-index: 900;
display: flex; align-items: center; gap: 10px;
background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px;
box-shadow: var(--shadow-pop); padding: 8px 12px; max-width: 640px;
}
.tour-prog {
font-size: 12px; font-weight: 600; color: #fff; background: var(--primary);
border-radius: 999px; padding: 3px 10px; white-space: nowrap;
}
.tour-text { display: flex; flex-direction: column; min-width: 0; }
.tour-text b { font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tour-desc { font-size: 11px; color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tour-bar .btn { white-space: nowrap; }
/* 证据弹窗:结论 / 来源 / 命中依据 / 弱化内部编号 */
.ev-conclusion { font-size: 15px; font-weight: 600; margin-bottom: 8px; }
.ev-conclusion .text-weak { font-size: 12px; font-weight: 400; }
.ev-source { font-size: 12px; color: var(--text-2); margin: 6px 0 10px; }
.ev-basis { font-size: 12px; color: var(--text-2); background: var(--primary-light); border-radius: 6px; padding: 6px 10px; margin-top: 10px; }
.ev-sid { font-size: 11px; color: var(--text-3); opacity: 0.6; margin-top: 10px; }
/* 来源列表行内可读元信息 */
.src-item .src-meta { font-size: 13px; }
/* 演化页:本期结论块 */
.evo-summary { margin: 4px 0 14px; display: flex; flex-direction: column; gap: 8px; }
.evs-row {
display: flex; gap: 12px; align-items: flex-start;
background: var(--bg-hover); border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px;
}
.evs-win { font-size: 13px; white-space: nowrap; padding-top: 2px; }
.evs-items { display: flex; flex-direction: column; gap: 6px; font-size: 13px; }
.evs-item .tag { margin-right: 6px; }
.evo-heat-head { display: flex; align-items: center; justify-content: space-between; }
.seg-sm button { font-size: 11px; padding: 2px 10px; }
/* 匹配页:60/40 输入布局 + 目标岗位信息卡 */
.match-input-grid { display: grid; grid-template-columns: 3fr 2fr; gap: 18px; align-items: start; }
.job-info-card {
background: var(--bg-hover); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px;
}
.jic-title { font-size: 16px; font-weight: 600; }
.jic-meta { font-size: 12px; color: var(--text-3); margin: 4px 0 8px; }
.jic-def { font-size: 13px; color: var(--text-2); margin-bottom: 10px; line-height: 1.6; }
.jic-sec { margin-top: 8px; }
.jic-label { font-size: 12px; color: var(--text-3); display: block; margin-bottom: 4px; }
.jic-chips { display: flex; flex-wrap: wrap; gap: 6px; }
/* 诊断结果自然语言结论 */
.match-advice {
font-size: 13px; color: var(--text-1); background: var(--primary-light);
border-radius: 8px; padding: 8px 12px; margin-top: 8px; line-height: 1.6; text-align: left;
}
/* 图谱详情面板四区块 */
.dt-def { font-size: 13px; color: var(--text-2); line-height: 1.6; margin-bottom: 8px; }
.dt-support { font-size: 12px; color: var(--text-2); background: var(--bg-hover); border-radius: 6px; padding: 6px 10px; margin-top: 6px; }
.dt-chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 2px 0 6px; }
.dt-chips .dt-skill { cursor: pointer; }
.dt-ev {
display: flex; align-items: center; gap: 8px; padding: 6px 4px;
border-bottom: 1px dashed var(--border); cursor: pointer; font-size: 13px;
}
.dt-ev:last-child { border-bottom: none; }
.dt-ev:hover { background: var(--bg-hover); }
.dt-ev-skill { font-weight: 500; }
/* 空状态内的行动按钮 */
.empty .btn { margin-top: 8px; }