前端答辩级改造:错误中文化、证据弹窗可读化、图谱默认岗位子图、演化报告化、匹配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
+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");
}
})();