diff --git a/start.bat b/start.bat
index 5cc1e50..7b6b750 100644
--- a/start.bat
+++ b/start.bat
@@ -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
diff --git a/tests/test_frontend_logic.js b/tests/test_frontend_logic.js
new file mode 100644
index 0000000..db00c67
--- /dev/null
+++ b/tests/test_frontend_logic.js
@@ -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);
diff --git a/webui/app.js b/webui/app.js
index e685f7a..66f855a 100644
--- a/webui/app.js
+++ b/webui/app.js
@@ -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)}`, `
加载中…
`);
+/* 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 = `
-
- ${esc(rec.job_title)}${esc(rec.company)}
- ${esc(rec.city || "—")}${esc(rec.salary || "—")}
- 发布 ${esc(rec.publish_date)}来源 ${esc(rec.source || "—")}
-
- ${esc(rec.text)}
`;
- } catch (e) {
- $("#modal-body").innerHTML = `原文读取失败:${esc(e.message)}
`;
+ 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 ? `
+
+ ${idx + 1} / ${list.length}
+ ` : ""}
+ `;
+ openModal("JD 原文证据", `证据加载中…
`, 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 ? `${esc(conclusion)}${ctx.window ? `(${esc(ctx.window)})` : ""}
` : ""}
+
+ ${esc(rec.job_title)}${esc(rec.company || "企业未披露")}
+ ${esc(rec.city || "—")}${esc(rec.salary || "—")}
+
+ 来源:${esc(rec.source || "公开招聘平台")} · ${esc(rec.company || "企业未披露")} · 发布时间:${esc(rec.publish_date || "未知")}
+ ${esc(rec.text)}
+ ${ctx.skill ? `命中依据:该 JD 对「${esc(ctx.skill)}」提出明确能力要求${ctx.changeType ? `(${esc(ctx.changeType)})` : ""}
` : ""}
+ ${esc(sid)}
`;
+ } catch (e) {
+ const gone = /不存在|非法|格式/.test((e && e.message) || "");
+ $("#modal-body").innerHTML = `
+
+
${gone ? "该能力当前没有可展示的公开原文,已进入人工核验队列"
+ : "暂时无法读取该条证据,请检查服务后重试"}
+
+
`;
+ $("#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 = `
+
演化数据暂时加载失败:${esc(friendlyErr(e))}
+
`;
+ $("#evo-retry").onclick = loadEvolution;
+ return;
+ }
renderEvoStats(id);
+ renderEvoSummary();
renderEvolution();
runEvoCompare(); // 默认对比:最早事件前 → 今天
}
+/* 本期结论:最近 2 个季度的新增/增强/减弱/退出清单(业务化文案,避免机器词) */
+function renderEvoSummary() {
+ const box = $("#evo-summary");
+ if (!evoEvents.length) {
+ box.innerHTML = `该岗位暂无演化事件,暂无本期结论
`;
+ 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 `${t}${skills} ${verb}`;
+ }).filter(Boolean).join("");
+ return ``;
+ }).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 = `对比失败:${esc(e.message)}
`;
+ box.innerHTML = `
+
时点对比暂时失败:${esc(friendlyErr(e))}
+
`;
+ $("#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 `
${esc(e.window)}
${esc(e.change_type)}
${esc(e.skill)}
- 权重 ${e.prev_weight.toFixed(2)} → ${e.curr_weight.toFixed(2)}
-
${esc(e.description)}
+
需求指数 ${e.prev_weight.toFixed(2)} → ${e.curr_weight.toFixed(2)}
+ ${esc((e.description || "").replace(/权重/g, "需求指数"))}
证据 JD ${e.source_ids.length} 条 · 点击查看原文
`;
}).join("") : `该岗位暂无演化事件
`;
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) => ``).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 = `请先选择目标岗位
`; return; }
+ box.innerHTML = `岗位信息加载中…
`;
+ 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) => `${esc(s)}`).join(" ") || `无`;
+ box.innerHTML = `
+ ${esc(def.title || jobId)}
+ ${level} · 支撑 ${def.source_count ?? "—"} 条 JD · ${def.company_count ?? "—"} 家企业 · 数据截止 2026Q3
+ ${(def.responsibilities || [])[0] ? `${esc(def.responsibilities[0])}
` : ""}
+ 必备技能${chips(def.required_skills, "tag-green", 6)}
+ 加分技能${chips(def.preferred_skills, "tag-cyan", 4)}
`;
+ } catch (e) {
+ box.innerHTML = `
+
岗位信息暂时加载失败:${esc(friendlyErr(e))}
+
`;
+ $("#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
? `✓ 达到匹配阈值 目标岗位:${esc(m.job_title)}`
: `未达匹配阈值(60 分) 目标岗位:${esc(m.job_title)}`;
+ $("#match-advice").innerHTML = `诊断结论:${matchAdvice(m.total_score)}`;
drawRadar($("#radar-canvas"), m.dimension_scores);
const chip = (s, cls) => `${esc(s)}`;
$("#matched-chips").innerHTML =
[...m.matched_required.map((s) => chip(s, "tag-green")), ...m.matched_preferred.map((s) => chip(s, "tag-cyan"))].join("") ||
`无命中(简历中未识别到本岗位所需技能)`;
- $("#missing-chips").innerHTML =
- [...m.missing_required.map((s) => chip(s, "tag-red")), ...m.missing_preferred.map((s) => chip(s, "tag-orange"))].join("") ||
- `无`;
+ /* 缺口技能:超过 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 = `无`;
+ } else if (missing.length <= 5) {
+ missBox.innerHTML = missing.join("");
+ } else {
+ const renderMiss = (expanded) => {
+ missBox.innerHTML = (expanded ? missing : missing.slice(0, 5)).join("") +
+ ``;
+ $("#miss-toggle").onclick = () => renderMiss(!expanded);
+ };
+ renderMiss(false);
+ }
$("#transfer-chips").innerHTML = m.transferable_skills.map((s) => chip(s, "tag-purple")).join("") ||
`暂无可迁移技能`;
// 声明掌握但与本岗位无关的技能:显式告知"不计入评分",避免用户疑惑
@@ -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 = `批量匹配失败:${esc(e2.message)}
`;
+ box.innerHTML = `
+
批量匹配失败:${esc(friendlyErr(e2))}
+
`;
}
};
@@ -1263,7 +1439,12 @@ async function loadPathJob(jobId, presetKnown) {
pathState.preferred = def.preferred_skills || [];
renderSkillPool();
updatePathSummary();
- } catch (e) { pool.innerHTML = `加载失败:${esc(e.message)}
`; }
+ } catch (e) {
+ pool.innerHTML = `
+
技能清单暂时加载失败:${esc(friendlyErr(e))}
+
`;
+ $("#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 = `正在生成学习路径:对齐岗位图谱 → 计算技能缺口 → 拆分阶段…
`;
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 = `
+
学习路径生成失败:${esc(friendlyErr(e))}
+
`;
+ $("#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) {
${done ? "✓" : st.stage}
- 阶段 ${st.stage} · ${st.skills.length} 项技能
+ 阶段 ${st.stage} · ${stageName(i)}(${st.skills.length} 项技能)
建议 ${weeks[i]} 周
${i > 0 ? `
前置:建议完成阶段 ${st.stage - 1} 后开始(按技能前置关系拓扑排序)
` : ""}
@@ -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("演示导览(预置路线)", `
-
按预置路线依次演示完整闭环,每步点击「前往」自动跳转到位:
- ${DEMO_STEPS.map((s, i) => `
-
-
-
${esc(s.title)}
-
${esc(s.desc)}
-
-
-
`).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");
}
})();
diff --git a/webui/graph.js b/webui/graph.js
index f2f4a4d..9581302 100644
--- a/webui/graph.js
+++ b/webui/graph.js
@@ -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) => `
@@ -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) => `
+
+
+
证据 JD · 第 ${i + 1} 条(来源信息加载中…)`).join("")
+ + (srcList.length > 10 ? `
…共 ${srcList.length} 条
` : "")
+ : `
暂无来源记录
`;
+
+ /* 岗位节点四区块:结论 / 能力清单 / 近期变化 / 可信证据与操作 */
+ 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) => `
${esc(r.title)}`;
+ const jobBlocks = isJob ? `
+
结论
+ ${jobDef && (jobDef.responsibilities || [])[0] ? `
${esc(jobDef.responsibilities[0])}
` : ""}
+
级别${esc(n.level || jobLevel(n) || "—")}
+
类别${esc(n.category || "—")}
+
支撑 ${n.source_count ?? (jobDef || {}).source_count ?? "—"} 条 JD · ${n.company_count ?? (jobDef || {}).company_count ?? "—"} 家企业 · 数据截止 ${esc(dataCutoff)}
+
+
能力清单
+
必备技能(${reqSkills.length})
+
${reqSkills.map((r) => skillChip(r, "tag-green")).join("") || '无'}
+
加分技能(${prefSkills.length})
+
${prefSkills.map((r) => skillChip(r, "tag-cyan")).join("") || '无'}
+
+
近期变化
+ ${jobEvents.length ? jobEvents.map((e, i) => `
+
+ ${esc(e.change_type)}
+ ${esc(e.skill)}
+ ${esc(e.window)} · ${(e.source_ids || []).length} 条 JD 支撑
+
`).join("") : `
近期无变化记录
`}
+
` : "";
+
$("#detail-empty").classList.add("hidden");
const body = $("#detail-body");
body.classList.remove("hidden");
@@ -704,27 +765,25 @@
${G.navStack.length ? `
` : ""}
-
基础信息
- ${kv.map(([k, v]) => `
${k}${esc(v)}
`).join("") || `
暂无属性
`}
-
+ ${jobBlocks}
- ${relHtml ? `
` : ""}
+ ${!isJob ? `
基础信息
+ ${kv.map(([k, v]) => `
${k}${esc(v)}
`).join("") || `
暂无属性
`}
+
` : ""}
+
+ ${!isJob && relHtml ? `
` : ""}
${n.type === "Skill" ? `
演化信息
${skillEvents.map((e) => `
- ${e.change_type}
+ ${e.change_type}
${esc(e.job_id.replace(/^job_/, ""))}${esc(e.window)}
`).join("")}
` : ""}
-
证据来源(支撑 JD,点击查看原文)
- ${srcList.length ? srcList.slice(0, 10).map((s) => `
-
`).join("")
- + (srcList.length > 10 ? `
…共 ${srcList.length} 条
` : "")
- : `
暂无来源记录
`}
+
可信证据(支撑 JD,点击查看原文)
+ ${srcRowsHtml}
操作
@@ -733,11 +792,12 @@
` : `
-
`}
+
`}
- ${n.type === "Job" && !isPending ? `
+ ${isJob ? `
-
` : ""}
+
+
` : ""}
@@ -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 @@
-
+
数据驾驶舱
-
-
演示模式
数据版本 —
-
-
+
+
+ 演示 1/6
+
+
+
+
+ 上一步
+ 下一步
+ 退出
+
+
+
+