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