前端答辩级改造:错误中文化、证据弹窗可读化、图谱默认岗位子图、演化报告化、匹配60/40、六步导览
- cnFetch 统一网络层,业务化错误文案+原位重试,屏蔽内部文件名 - 证据弹窗:结论/来源平台·企业·日期/原文/命中依据+翻页 - 图谱默认 Java 岗位子图;详情面板四区块(结论/能力清单/近期变化/证据操作) - 演化页本期结论块(需求指数口径)+近8季默认视图 - 匹配页输入区 60/40 + 岗位信息卡 + 结论分档文案 + 缺口折叠 - 学习路径空状态引导+阶段语义化命名;六步导览浮动条 - tests/test_frontend_logic.js 21 用例
This commit is contained in:
+309
-70
@@ -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");
|
||||
}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user