From ac6d08cdf281c93d358ffdb647fd1ac1feaee8f3 Mon Sep 17 00:00:00 2001 From: RoyirinRosmontis Date: Thu, 13 Aug 2026 12:33:13 +0800 Subject: [PATCH] =?UTF-8?q?=E5=89=8D=E7=AB=AF=E7=AD=94=E8=BE=A9=E7=BA=A7?= =?UTF-8?q?=E6=94=B9=E9=80=A0=EF=BC=9A=E9=94=99=E8=AF=AF=E4=B8=AD=E6=96=87?= =?UTF-8?q?=E5=8C=96=E3=80=81=E8=AF=81=E6=8D=AE=E5=BC=B9=E7=AA=97=E5=8F=AF?= =?UTF-8?q?=E8=AF=BB=E5=8C=96=E3=80=81=E5=9B=BE=E8=B0=B1=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E5=B2=97=E4=BD=8D=E5=AD=90=E5=9B=BE=E3=80=81=E6=BC=94=E5=8C=96?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E5=8C=96=E3=80=81=E5=8C=B9=E9=85=8D60/40?= =?UTF-8?q?=E3=80=81=E5=85=AD=E6=AD=A5=E5=AF=BC=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cnFetch 统一网络层,业务化错误文案+原位重试,屏蔽内部文件名 - 证据弹窗:结论/来源平台·企业·日期/原文/命中依据+翻页 - 图谱默认 Java 岗位子图;详情面板四区块(结论/能力清单/近期变化/证据操作) - 演化页本期结论块(需求指数口径)+近8季默认视图 - 匹配页输入区 60/40 + 岗位信息卡 + 结论分档文案 + 缺口折叠 - 学习路径空状态引导+阶段语义化命名;六步导览浮动条 - tests/test_frontend_logic.js 21 用例 --- start.bat | 2 +- tests/test_frontend_logic.js | 74 +++++++ webui/app.js | 379 ++++++++++++++++++++++++++++------- webui/graph.js | 145 +++++++++++--- webui/index.html | 85 +++++--- webui/style.css | 77 +++++++ 6 files changed, 642 insertions(+), 120 deletions(-) create mode 100644 tests/test_frontend_logic.js 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 `
${esc(w)}
${parts}
`; + }).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) => `
- ${esc(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 ? `
关联信息(点击跳转)
${relHtml}
` : ""} + ${!isJob ? `
基础信息
+ ${kv.map(([k, v]) => `
${k}${esc(v)}
`).join("") || `
暂无属性
`} +
` : ""} + + ${!isJob && relHtml ? `
关联信息(点击跳转)
${relHtml}
` : ""} ${n.type === "Skill" ? `
演化信息
近 12 个季度热度(活跃岗位关系数)
${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) => ` -
- ${esc(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 @@
-
+
@@ -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() { diff --git a/webui/index.html b/webui/index.html index 384b9f8..879876a 100644 --- a/webui/index.html +++ b/webui/index.html @@ -4,7 +4,7 @@ CareerNexus 职联 · 岗位与能力图谱平台 - + @@ -15,6 +15,12 @@ CareerNexus 职联
+
+ + 演示模式 +
数据驾驶舱
- - 演示模式 数据版本 — + + +
@@ -306,22 +318,31 @@
3诊断结果
- +
-
- - -
-
- - -
-
- - - - 支持 .txt / .docx / .pdf,≤5MB - +
+
+
+ + +
+
+ + +
+
+ + + + 支持 .txt / .docx / .pdf,≤5MB + +
+
+
+
+
岗位信息加载中…
+
+
@@ -374,6 +395,7 @@
0综合匹配分
+
@@ -491,7 +513,8 @@
-

在左侧选择目标岗位并标记已掌握技能
点击「生成学习路径」查看分阶段路线

+

尚未生成学习路径
先完成一次人岗诊断,再生成个性化路径(将自动带入岗位与技能缺口)

+
@@ -515,7 +538,19 @@
- - + + + + + diff --git a/webui/style.css b/webui/style.css index 25f04aa..8d80742 100644 --- a/webui/style.css +++ b/webui/style.css @@ -947,3 +947,80 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: .tour-step-title { font-size: 13px; font-weight: 600; color: var(--text-1); margin-bottom: 2px; } .tour-step-desc { font-size: 12px; color: var(--text-3); } .cfg-foot-note { text-align: center; font-size: 12px; color: var(--text-3); padding: 4px 0 2px; } + +/* 顶栏左侧操作区(开始演示 + 状态徽标) */ +.topbar-left-actions { display: flex; align-items: center; gap: 10px; margin-left: 22px; } + +/* ============ 2026-08 答辩级改造新增样式 ============ */ + +/* 评委演示导览浮动条(右上角固定) */ +.tour-bar { + position: fixed; top: calc(var(--topbar-h) + 10px); right: 16px; z-index: 900; + display: flex; align-items: center; gap: 10px; + background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; + box-shadow: var(--shadow-pop); padding: 8px 12px; max-width: 640px; +} +.tour-prog { + font-size: 12px; font-weight: 600; color: #fff; background: var(--primary); + border-radius: 999px; padding: 3px 10px; white-space: nowrap; +} +.tour-text { display: flex; flex-direction: column; min-width: 0; } +.tour-text b { font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.tour-desc { font-size: 11px; color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.tour-bar .btn { white-space: nowrap; } + +/* 证据弹窗:结论 / 来源 / 命中依据 / 弱化内部编号 */ +.ev-conclusion { font-size: 15px; font-weight: 600; margin-bottom: 8px; } +.ev-conclusion .text-weak { font-size: 12px; font-weight: 400; } +.ev-source { font-size: 12px; color: var(--text-2); margin: 6px 0 10px; } +.ev-basis { font-size: 12px; color: var(--text-2); background: var(--primary-light); border-radius: 6px; padding: 6px 10px; margin-top: 10px; } +.ev-sid { font-size: 11px; color: var(--text-3); opacity: 0.6; margin-top: 10px; } + +/* 来源列表行内可读元信息 */ +.src-item .src-meta { font-size: 13px; } + +/* 演化页:本期结论块 */ +.evo-summary { margin: 4px 0 14px; display: flex; flex-direction: column; gap: 8px; } +.evs-row { + display: flex; gap: 12px; align-items: flex-start; + background: var(--bg-hover); border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px; +} +.evs-win { font-size: 13px; white-space: nowrap; padding-top: 2px; } +.evs-items { display: flex; flex-direction: column; gap: 6px; font-size: 13px; } +.evs-item .tag { margin-right: 6px; } +.evo-heat-head { display: flex; align-items: center; justify-content: space-between; } +.seg-sm button { font-size: 11px; padding: 2px 10px; } + +/* 匹配页:60/40 输入布局 + 目标岗位信息卡 */ +.match-input-grid { display: grid; grid-template-columns: 3fr 2fr; gap: 18px; align-items: start; } +.job-info-card { + background: var(--bg-hover); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; +} +.jic-title { font-size: 16px; font-weight: 600; } +.jic-meta { font-size: 12px; color: var(--text-3); margin: 4px 0 8px; } +.jic-def { font-size: 13px; color: var(--text-2); margin-bottom: 10px; line-height: 1.6; } +.jic-sec { margin-top: 8px; } +.jic-label { font-size: 12px; color: var(--text-3); display: block; margin-bottom: 4px; } +.jic-chips { display: flex; flex-wrap: wrap; gap: 6px; } + +/* 诊断结果自然语言结论 */ +.match-advice { + font-size: 13px; color: var(--text-1); background: var(--primary-light); + border-radius: 8px; padding: 8px 12px; margin-top: 8px; line-height: 1.6; text-align: left; +} + +/* 图谱详情面板四区块 */ +.dt-def { font-size: 13px; color: var(--text-2); line-height: 1.6; margin-bottom: 8px; } +.dt-support { font-size: 12px; color: var(--text-2); background: var(--bg-hover); border-radius: 6px; padding: 6px 10px; margin-top: 6px; } +.dt-chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 2px 0 6px; } +.dt-chips .dt-skill { cursor: pointer; } +.dt-ev { + display: flex; align-items: center; gap: 8px; padding: 6px 4px; + border-bottom: 1px dashed var(--border); cursor: pointer; font-size: 13px; +} +.dt-ev:last-child { border-bottom: none; } +.dt-ev:hover { background: var(--bg-hover); } +.dt-ev-skill { font-weight: 500; } + +/* 空状态内的行动按钮 */ +.empty .btn { margin-top: 8px; }