diff --git a/app/api/routes.py b/app/api/routes.py index bc4f8c4..f85720e 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -336,6 +336,34 @@ def graph() -> dict: # ---------- JD 导入(异步任务) ---------- +@app.get("/api/stats/credibility") +def credibility_stats() -> dict: + """数据可信度指标(驾驶舱「数据可信度」卡): + 有效 JD 数 / 独立企业数 / 来源类型数 / 人工复核率 / 异常剔除率。 + 人工复核率 = 审核留痕数 / (留痕数 + 待审候选数)。""" + jd_dir = resolve(_cfg, "jd_raw_dir") + records, _ = load_jd_dir(jd_dir) + total = len(records) + valid = [r for r in records if not r.is_noise and not r.is_duplicate] + companies = {r.company for r in valid if r.company} + sources = sorted({r.source for r in valid if r.source}) + audit = state_db.get_audit(_db_path(), limit=200) + reviewed = sum(1 for r in audit if r.get("kind") != "node_update") + pending = len(load_pending(_db_path())) + denom = reviewed + pending + return _ok({ + "jd_total": total, + "jd_valid": len(valid), + "companies": len(companies), + "sources": len(sources), + "source_names": sources, + "reviewed": reviewed, + "pending": pending, + "review_rate": (reviewed / denom) if denom else None, + "excluded_rate": ((total - len(valid)) / total) if total else 0.0, + }) + + def _run_task(task_id: str) -> None: task = _state["tasks"][task_id] try: diff --git a/app/match/resume.py b/app/match/resume.py index 2f547c6..6815041 100644 --- a/app/match/resume.py +++ b/app/match/resume.py @@ -19,9 +19,12 @@ logger = logging.getLogger(__name__) _PHONE_RE = re.compile(r"1[3-9]\d{9}") _EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}") _NAME_RE = re.compile(r"姓名[::]\s*([一-龥]{2,4})") +# 简历常见写法:首行裸姓名(无"姓名:"前缀),如" 王伟航 " +_BARE_NAME_RE = re.compile(r"^\s*([一-龥]{2,4})\s*$", re.MULTILINE) _YEARS_RE = re.compile(r"(\d+(?:\.\d+)?)\s*年(?:以上)?[^\n,。;]{0,6}?经验") _GENDER_RE = re.compile(r"性别[::]?\s*(男|女)") -_EDU_RE = re.compile(r"(博士后|博士|硕士|研究生|MBA|本科|大专|专科)") +_EDU_RE = re.compile(r"(博士后|博士|专硕|学硕|硕士|研究生|MBA|本科|大专|专科)") +_EDU_MAP = {"专硕": "硕士", "学硕": "硕士", "研究生": "硕士"} _ADDR_RE = re.compile(r"(?:家庭住址|现居(?:住)?(?:地|址)?|联系地址|通讯地址|籍贯)[::]\s*([^\n,。;]{2,30})") # 常见简历分节标题(整行匹配,可带冒号) @@ -33,11 +36,28 @@ _SECTION_TITLES = { } +def _extract_name(text: str) -> str: + """姓名识别:优先"姓名:X"格式,否则取首个独立的 2-4 字中文裸行(简历常见排版)。""" + m = _NAME_RE.search(text) + if m: + return m.group(1) + for line in text.splitlines()[:5]: # 只在开头几行找,避免误伤正文 + b = _BARE_NAME_RE.match(line) + if b: + return b.group(1) + return "" + + def mask_text(text: str) -> str: - """脱敏:手机、邮箱、姓名、住址打码。""" + """脱敏:手机、邮箱、姓名(含首行裸姓名)、住址打码。""" text = _PHONE_RE.sub(lambda m: m.group(0)[:3] + "****" + m.group(0)[-2:], text) text = _EMAIL_RE.sub(lambda m: m.group(0)[0] + "***@" + m.group(0).split("@")[1], text) text = _NAME_RE.sub(lambda m: "姓名:" + m.group(1)[0] + "*" * (len(m.group(1)) - 1), text) + name = _extract_name(text) + if name: # 裸姓名行整体打码 + text = _BARE_NAME_RE.sub( + lambda m: m.group(0).replace(name, name[0] + "*" * (len(name) - 1)) + if m.group(1) == name else m.group(0), text, count=1) text = _ADDR_RE.sub(lambda m: m.group(0)[:2] + m.group(1)[:6] + "***" if len(m.group(1)) > 6 else m.group(0), text) return text @@ -122,6 +142,7 @@ def parse_resume(text: str, matcher: SkillMatcher | None = None, """从简历文本解析脱敏画像(含结构化基本信息与分节)。""" matcher = matcher or SkillMatcher(load_lexicon()) name_m = _NAME_RE.search(text) + name = name_m.group(1) if name_m else _extract_name(text) phone_m = _PHONE_RE.search(text) email_m = _EMAIL_RE.search(text) years_m = _YEARS_RE.search(text) @@ -130,7 +151,7 @@ def parse_resume(text: str, matcher: SkillMatcher | None = None, addr_m = _ADDR_RE.search(text) masked = mask_text(text) return ResumeProfile( - name=(name_m.group(1)[0] + "*" * (len(name_m.group(1)) - 1)) if name_m else "", + name=(name[0] + "*" * (len(name) - 1)) if name else "", phone=(phone_m.group(0)[:3] + "****" + phone_m.group(0)[-2:]) if phone_m else "", email=(email_m.group(0)[0] + "***@" + email_m.group(0).split("@")[1]) if email_m else "", years=float(years_m.group(1)) if years_m else 0.0, @@ -138,7 +159,7 @@ def parse_resume(text: str, matcher: SkillMatcher | None = None, scenarios=matcher.find_scenarios(text), raw_masked_text=masked, gender=gender_m.group(1) if gender_m else "", - education=edu_m.group(1) if edu_m else "", + education=_EDU_MAP.get(edu_m.group(1), edu_m.group(1)) if edu_m else "", address=_mask_addr(addr_m.group(1).strip()) if addr_m else "", photo_b64=photo_b64, sections=split_sections(masked), diff --git a/tests/test_api.py b/tests/test_api.py index 7700f56..be78af5 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -38,6 +38,20 @@ def test_graph_endpoint(client): assert any(e.get("rel") == "BELONGS_TO" for e in data["edges"]) +def test_credibility_stats(client): + """数据可信度卡:有效 JD / 独立企业 / 来源类型 / 人工复核率 / 异常剔除率。""" + r = client.get("/api/stats/credibility") + assert r.status_code == 200 + d = r.json()["data"] + assert d["jd_total"] >= d["jd_valid"] > 0 + assert d["companies"] >= 5 + assert d["sources"] >= 1 and len(d["source_names"]) == d["sources"] + assert 0.0 <= d["excluded_rate"] <= 1.0 + assert d["reviewed"] >= 0 and d["pending"] >= 0 + if d["review_rate"] is not None: + assert 0.0 <= d["review_rate"] <= 1.0 + + def test_jobs_list_and_filter(client): r = client.get("/api/jobs") assert r.status_code == 200 diff --git a/tests/test_frontend_logic.js b/tests/test_frontend_logic.js index db00c67..495cb8a 100644 --- a/tests/test_frontend_logic.js +++ b/tests/test_frontend_logic.js @@ -70,5 +70,28 @@ 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, "全部范围"); +/* candidateTrust:候选可信等级四指标(JD≥15 / 企业≥5 / 增长趋势 / 差异度≥0.40) */ +const candidateTrust = loadFn("candidateTrust"); +const stageEffortText = loadFn("stageEffortText"); +const existJobs = [{ title: "Java开发工程师", required_skills: ["Java", "MySQL"], preferred_skills: ["Redis"] }]; +const strong = candidateTrust({ + source_count: 31, company_count: 12, + support_windows: ["2025Q1", "2025Q2", "2025Q3", "2025Q4", "2026Q1", "2026Q2"], + required_skills: ["RAG", "向量检索"], preferred_skills: [], +}, existJobs); +eq(strong.dots.length, 4, "四指标点"); +eq(strong.level, "高", "四指标全达标为高"); +const weak = candidateTrust({ + source_count: 3, company_count: 1, support_windows: ["2026Q2"], + required_skills: ["Java", "MySQL"], preferred_skills: ["Redis"], +}, existJobs); +eq(weak.level, "低", "弱证据且与既有岗位重合为低"); +eq(weak.nearest.title, "Java开发工程师", "识别出最相近既有岗位"); +eq(weak.distinctness, 0, "技能完全重合时差异度为 0"); + +/* stageEffortText:阶段预计投入(每周约 8 小时) */ +eq(stageEffortText(2), "2 周 / 约 16 小时", "阶段投入文案"); +eq(stageEffortText(1), "1 周 / 约 8 小时", "单周投入文案"); + console.log(`\n${pass} passed, ${fail} failed`); process.exit(fail ? 1 : 0); diff --git a/tests/test_match.py b/tests/test_match.py index 356ef96..79ce2fc 100644 --- a/tests/test_match.py +++ b/tests/test_match.py @@ -91,3 +91,17 @@ def test_resume_parse_skills_and_years(): assert profile.years == 5.0 assert set(profile.skills) == {"Java", "MySQL", "Docker"} assert "138" not in profile.raw_masked_text + + +def test_resume_bare_name_and_edu_alias(): + """真实简历排版:首行裸姓名(无"姓名:"前缀)可识别并脱敏;专硕归一为硕士。""" + from app.match.resume import parse_resume + text = (" 王伟航 \n出生年月:2003年10月 现居地:辽宁大连\n" + "联系电话:19293819303 电子邮件:wwh@example.com\n" + "东北财经大学 金融科技(专硕) 2025.09-至今\n" + "职业技能:熟练掌握Python、Hadoop等数据分析技能。") + p = parse_resume(text) + assert p.name == "王**" and "王伟航" not in p.raw_masked_text # 裸姓名识别+脱敏 + assert p.education == "硕士" # 专硕→硕士 + assert p.address.startswith("辽宁大连") + assert "Python" in p.skills diff --git a/webui/app.js b/webui/app.js index 66f855a..0a0e787 100644 --- a/webui/app.js +++ b/webui/app.js @@ -230,7 +230,7 @@ CN.fetchJd = fetchJd; /* ================= 视图切换 / 侧栏 ================= */ const PAGE_TITLES = { - dashboard: "数据驾驶舱", graph: "全景图谱", evolution: "岗位演化", + dashboard: "人才能力变化指挥台", graph: "岗位能力证据地图", evolution: "岗位演化", match: "人岗匹配", path: "学习路径", }; const _loaded = {}; @@ -248,7 +248,10 @@ function switchView(name) { } if (name === "graph" && CN.graph && !_loaded.graph) { _loaded.graph = true; CN.graph.init(); } if (name === "graph" && CN.graph && _loaded.graph) CN.graph.onShow(); - if (name === "path" && !_loaded.path) { _loaded.path = true; loadPathJob($("#path-job-select").value); } + if (name === "path" && !_loaded.path) { + _loaded.path = true; + loadPathJob($("#path-job-select").value).then(() => renderDemoPath()); // 未诊断时展示示范路线 + } } CN.switchView = switchView; @@ -290,36 +293,15 @@ $$(".nav-item").forEach((btn) => { btn.onclick = () => switchView(btn.dataset.vi }); })(); -/* ================= 数据驾驶舱 ================= */ +/* ================= 人才能力变化指挥台(数据驾驶舱) ================= */ +/* 首屏 KPI:新岗位候选 / 关键技能增强 / 待人工复核 / 影响岗位(图谱规模降级为一行小字) */ const METRIC_DEFS = [ - { key: "jobs", label: "岗位节点", view: "graph", icon: '' }, - { key: "skills", label: "技能节点", view: "graph", icon: '' }, - { key: "edges", label: "图谱关系边", view: "graph", icon: '' }, - { key: "events", label: "演化事件", view: "evolution", icon: '' }, - { key: "pending", label: "待审核候选", view: "graph", icon: '' }, + { key: "candidates", label: "新岗位候选", act: "desk", sub: "聚类发现,待人工决策", icon: '' }, + { key: "boosted", label: "关键技能增强", act: "evolution", sub: "近 2 季度新增+增强事件", icon: '' }, + { key: "review", label: "待人工复核", act: "desk", sub: "候选岗位 + 候选技能关系", icon: '' }, + { key: "affected", label: "影响岗位", act: "evolution", sub: "近 2 季度发生变化的岗位", icon: '' }, ]; -function sparkline(canvas, values, color) { - const ctx = canvas.getContext("2d"); - const W = canvas.width, H = canvas.height; - ctx.clearRect(0, 0, W, H); - if (!values.length) return; - const max = Math.max(1, ...values), min = Math.min(...values); - ctx.beginPath(); - values.forEach((v, i) => { - const x = (i / Math.max(1, values.length - 1)) * (W - 4) + 2; - const y = H - 3 - ((v - min) / Math.max(1e-9, max - min)) * (H - 8); - ctx[i ? "lineTo" : "moveTo"](x, y); - }); - ctx.strokeStyle = color; ctx.lineWidth = 1.6; ctx.stroke(); -} - -function quarterlyCounts(events) { - const kv = {}; - events.forEach((e) => { kv[e.window] = (kv[e.window] || 0) + 1; }); - return Object.keys(kv).sort().map((k) => [k, kv[k]]); -} - async function loadDashboard() { const [health, graph, pending, audit] = await Promise.all([ api("/api/health"), api("/api/graph"), @@ -327,48 +309,90 @@ async function loadDashboard() { api("/api/audit?limit=8").catch(() => []), ]); CN.graphData = graph; + CN._pendingCache = pending; const s = health.graph || {}; updateBanners(health); - $("#data-version").textContent = `数据截止:${(graph.events.at(-1) || {}).window || "2026Q3"} · 模拟数据`; + const events = graph.events || []; + const lastWin = (events.at(-1) || {}).window || "2026Q3"; + $("#data-version").textContent = `数据截止:${lastWin} · 模拟数据`; - /* 指标卡 */ - const series = quarterlyCounts(graph.events || []); - const last12 = series.slice(-12).map(([, c]) => c); - const values = { jobs: s.jobs, skills: s.skills, edges: s.edges, events: s.events, pending: pending.length }; - $("#metric-cards").innerHTML = METRIC_DEFS.map((m, i) => { - const cur = last12.at(-1) ?? 0, prev = last12.at(-2) ?? cur; - const delta = m.key === "events" || m.key === "pending" ? cur - prev : 0; - const cls = delta > 0 ? "up" : delta < 0 ? "down" : "flat"; - const arrow = delta > 0 ? "↑" : delta < 0 ? "↓" : "—"; - const deltaTxt = m.key === "events" - ? `${arrow} 环比上季 ${Math.abs(delta)}` - : `实时统计`; - return `
+ /* 本期口径:最近 2 个季度的变化事件 */ + const wins2 = [...new Set(events.map((e) => e.window))].sort().slice(-2); + const recent = events.filter((e) => wins2.includes(e.window)); + const cnt = (t) => recent.filter((e) => e.change_type === t).length; + const readyCount = pending.filter((c) => ["高", "较高"].includes(candidateTrust(c, CN.jobs).level)).length; + const pendingRels = pending.reduce((a, c) => + a + (c.required_skills || []).length + (c.preferred_skills || []).length, 0); + + /* 指挥台首屏:结论 + 图谱规模(降级为小字) */ + $("#cmd-title").textContent = `${lastWin} 人才能力变化概览`; + $("#cmd-sub").textContent = pending.length + ? `本期确认 ${recent.length} 项关键变化(新增 ${cnt("新增")} · 增强 ${cnt("增强")} · 减弱 ${cnt("减弱")} · 退出 ${cnt("退出")});${readyCount} 个岗位候选已具备入库条件` + : `本期确认 ${recent.length} 项关键变化(新增 ${cnt("新增")} · 增强 ${cnt("增强")} · 减弱 ${cnt("减弱")} · 退出 ${cnt("退出")});当前无待审核候选`; + $("#cmd-scale").textContent = + `图谱规模:岗位 ${s.jobs ?? "—"} · 技能 ${s.skills ?? "—"} · 关系边 ${s.edges ?? "—"} · 演化事件 ${s.events ?? "—"}`; + + /* KPI 卡(点击直达对应处理界面) */ + const values = { + candidates: pending.length, + boosted: cnt("增强") + cnt("新增"), + review: pending.length + pendingRels, + affected: new Set(recent.map((e) => e.job_id)).size, + }; + $("#metric-cards").innerHTML = METRIC_DEFS.map((m) => ` +
${m.icon}${m.label}
-
${values[m.key] ?? "—"}
${deltaTxt} -
`; - }).join(""); +
${values[m.key] ?? "—"}
+ ${m.sub} +
`).join(""); $$("#metric-cards .metric-card").forEach((el) => { - el.onclick = () => switchView(el.dataset.view); - sparkline(el.querySelector("canvas"), last12.length ? last12 : [0, 0], "#2f54eb"); + el.onclick = () => (el.dataset.act === "desk" ? openReviewDesk() : switchView("evolution")); }); $("#pending-count").textContent = `${pending.length} 待办`; - /* 趋势图(近 12 季度事件数,柱状) */ - CN._trend = series.slice(-12); - drawTrend($("#trend-canvas"), CN._trend); + /* 主图:近 8 季度变化事件流(全岗位口径堆叠柱,点击柱段跳演化页) */ + CN._flowEvents = events; + drawEventFlow($("#trend-canvas"), events, 8); - /* 任务与审核状态表 */ + renderTrust(); renderTaskTable(audit); $("#btn-import-jd").onclick = () => openImportDialog(); - - /* 动态消息流 */ - renderFeed(graph.events || [], audit); - - /* 待审核候选 */ + $("#btn-cmd-report").onclick = () => switchView("evolution"); + $("#btn-open-review-desk").onclick = () => openReviewDesk(); + $("#btn-open-review-desk-2").onclick = () => openReviewDesk(); + $("#btn-audit-toggle").onclick = () => { + const wrap = $("#task-table-wrap"); + wrap.classList.toggle("hidden"); + $("#btn-audit-toggle").textContent = wrap.classList.contains("hidden") ? "展开台账" : "收起台账"; + }; + renderFeed(events); renderPending(pending); } +/* 数据可信度卡:有效 JD / 独立企业 / 来源类型 / 人工复核率 / 异常剔除率 */ +async function renderTrust() { + const box = $("#trust-card"); + try { + const t = await api("/api/stats/credibility"); + const rows = [ + ["有效 JD", `${t.jd_valid} 条`, `原始 ${t.jd_total} 条,去重降噪后入图`], + ["独立企业", `${t.companies} 家`, "跨企业交叉验证,防单一企业造词"], + ["来源类型", `${t.sources} 类`, (t.source_names || []).join("、") || "公开招聘平台"], + ["人工复核率", t.review_rate == null ? "—" : `${Math.round(t.review_rate * 100)}%`, + `审核留痕 ${t.reviewed} 条 · 待审 ${t.pending} 项`], + ["异常剔除率", `${Math.round((t.excluded_rate || 0) * 100)}%`, `剔除噪声/重复 ${t.jd_total - t.jd_valid} 条`], + ]; + box.innerHTML = rows.map(([k, v, sub]) => ` +
${k}${v} + ${esc(sub)}
`).join(""); + } catch (e) { + box.innerHTML = `
+

可信度统计暂时加载失败:${esc(friendlyErr(e))}

+
`; + $("#trust-retry").onclick = renderTrust; + } +} + /* JD 数据导入对话框:上传文件 / 粘贴文本 / 直接全量重建 */ async function openImportDialog() { const choice = await sysDialog({ @@ -433,7 +457,7 @@ function renderTaskTable(audit) { tbody.innerHTML = rows.map((r) => { const isNode = r.kind === "node_update"; const obj = isNode ? r.node_id : (r.candidate_id || "").replace(/^cand_/, ""); - const act = isNode ? "节点编辑" : { accept: "审核通过", reject: "审核驳回", modify: "审核修改" }[r.action] || r.action; + const act = isNode ? "节点编辑" : { accept: "审核通过", reject: "退回观察", modify: "审核修改" }[r.action] || r.action; const badge = isNode ? "badge-version" : (r.action === "reject" ? "badge-warning" : "badge-success"); return `${act} ${esc(obj)}${esc(r.operator || "—")} @@ -441,64 +465,64 @@ function renderTaskTable(audit) { }).join(""); } -function renderFeed(events, audit) { - const items = []; - events.slice(-8).reverse().forEach((e) => items.push({ - icon: "#2f54eb", bg: "#f0f5ff", kind: "evo", - text: `${esc(e.job_id.replace(/^job_/, ""))} ${esc(e.change_type)}技能「${esc(e.skill)}」`, - time: e.window, job: e.job_id, - })); - (audit || []).slice(0, 4).forEach((r) => items.push({ - icon: "#00b42a", bg: "#e8ffea", kind: "audit", - text: r.kind === "node_update" - ? `${esc(r.operator)} 编辑了节点「${esc(r.node_id)}」` - : `${esc(r.operator)} ${{ accept: "通过", reject: "驳回", modify: "修改" }[r.action] || r.action}了候选「${esc((r.candidate_id || "").replace(/^cand_/, ""))}」`, - time: r.time || "", - })); - items.sort((a, b) => (b.time > a.time ? 1 : -1)); - $("#activity-feed").innerHTML = items.slice(0, 10).map((it, i) => ` -
- - -
${it.text}
${esc(it.time)}
-
`).join("") || `
暂无动态
`; +/* 变化播报卡:技能图标 + 变化类型 + 影响岗位 + 证据数 + 跳转演化页(审核动作归入台账) */ +function renderFeed(events) { + const verb = { "新增": "成为新要求", "增强": "需求显著增强", "减弱": "需求明显减弱", "退出": "退出能力画像" }; + const items = (events || []).slice(-6).reverse(); + const jobTitle = (id) => ((CN.jobs || []).find((j) => j.job_id === id) || {}).title || id.replace(/^job_/, ""); + $("#activity-feed").innerHTML = items.length ? items.map((e, i) => { + const m = CHANGE_META[e.change_type] || { tag: "tag-gray", color: "#86909c" }; + return `
+ + +
+
${esc(e.skill)} ${verb[e.change_type] || esc(e.change_type)}
+
影响岗位:${esc(jobTitle(e.job_id))} · 证据 ${(e.source_ids || []).length} 条 JD · ${esc(e.window)}
+
+ ${esc(e.change_type)} +
`; + }).join("") : `
暂无变化播报
`; $$("#activity-feed .feed-item").forEach((el) => { el.onclick = () => { - const it = items[+el.dataset.i]; - if (it.kind === "evo" && it.job) { - switchView("evolution"); - $("#evo-job-select").value = it.job; - loadEvolution(); - } + const e = items[+el.dataset.i]; + switchView("evolution"); + $("#evo-job-select").value = e.job_id; + loadEvolution(); }; }); } +/* 待办行动卡(最多 3 张:结论 + 证据强度 + 处理按钮;完整台账见审核台/下方台账) */ function renderPending(pending) { const box = $("#pending-list"); if (!pending.length) { box.innerHTML = `
暂无待审核候选(可在图谱页触发新岗位发现)
`; return; } - box.innerHTML = pending.map((c) => ` -
-
${esc(c.title)}待审核 - ${esc(c.category || "")}
-
支撑 ${c.source_count} 条 JD · ${c.company_count} 家企业 · 期望年限 ${c.expected_years} 年
-
必备:${c.required_skills.map((s) => `${esc(s)}`).join(" ")} - ${c.preferred_skills.length ? `
加分:${c.preferred_skills.map((s) => `${esc(s)}`).join(" ")}` : ""}
+ box.innerHTML = pending.slice(0, 3).map((c) => { + const t = candidateTrust(c, CN.jobs); + const ready = ["高", "较高"].includes(t.level); + return `
+
${esc(c.title)} + ${ready ? "建议批准入图" : "建议继续观察"}
+
结论:四指标达标 ${t.okCount}/4(可信等级 ${t.level}) · 处理人:待分配
+
证据强度:${c.source_count} 条 JD · ${c.company_count} 家企业 · ${(c.support_windows || []).length} 个季度跨度
- - - + + +
-
`).join(""); +
`; + }).join("") + (pending.length > 3 + ? `` : ""); box.querySelectorAll("[data-act]").forEach((b) => { b.onclick = () => doReview(b.dataset.id, b.dataset.act); }); - box.querySelectorAll("[data-locate]").forEach((b) => { - b.onclick = () => { switchView("graph"); if (CN.gotoNode) CN.gotoNode(b.dataset.locate); }; + box.querySelectorAll("[data-desk]").forEach((b) => { + b.onclick = () => openReviewDesk(b.dataset.desk); }); + const more = $("#pending-more"); + if (more) more.onclick = () => openReviewDesk(); } async function doReview(id, action) { @@ -506,18 +530,139 @@ async function doReview(id, action) { if (operator === null) return; let reason = "webui 审核通过"; if (action === "reject") { - reason = await CN.sysPrompt({ title: "驳回候选", label: "请输入驳回原因:", value: "证据不足" }); + reason = await CN.sysPrompt({ title: "退回继续观察", label: "请输入退回原因(候选保留观察,暂不入库):", value: "证据不足,继续观察" }); if (reason === null) return; } try { await post("/api/review", { candidate_id: id, action, operator: operator || "评审专家", reason }); - toast(`审核完成:${action === "accept" ? "已通过入库" : "已驳回"}`, "success"); + toast(action === "accept" ? "已批准入图:图谱已实时更新" : "已退回继续观察", "success"); loadDashboard(); + if (!$("#review-desk-mask").classList.contains("hidden")) renderReviewDesk(); if (CN.graph && CN.graph.reload) CN.graph.reload(); } catch (e) { toast(e.message, "error"); } } CN.doReview = doReview; +/* ================= 候选岗位审核台(显性二级界面:发现 → 证据 → 人工决策 → 自动入图) ================= */ +/* 候选可信等级四指标:支撑 JD 数 ≥15 / 独立企业数 ≥5 / 增长趋势(支撑窗口后半段不弱于前半段)/ 与既有岗位差异度 ≥0.40 */ +function candidateTrust(c, jobs) { + const jdOk = (c.source_count || 0) >= 15; + const compOk = (c.company_count || 0) >= 5; + const wins = (c.support_windows || []).slice().sort(); + const half = Math.max(1, Math.floor(wins.length / 2)); + const before = wins.slice(0, wins.length - half).length; + const recent = wins.slice(-half).length; + const growthOk = before === 0 ? wins.length >= 2 : recent / before >= 1.0; + const skills = new Set([...(c.required_skills || []), ...(c.preferred_skills || [])].map((s) => s.toLowerCase())); + let best = { sim: 0, title: "" }; + (jobs || []).forEach((j) => { + const js = new Set([...(j.required_skills || []), ...(j.preferred_skills || [])].map((s) => s.toLowerCase())); + if (!skills.size || !js.size) return; + const inter = [...skills].filter((s) => js.has(s)).length; + const sim = inter / (skills.size + js.size - inter); + if (sim > best.sim) best = { sim, title: j.title }; + }); + const distinct = 1 - best.sim; + const dots = [ + { label: "支撑 JD", ok: jdOk, value: `${c.source_count || 0} 条(≥15 达标)` }, + { label: "独立企业", ok: compOk, value: `${c.company_count || 0} 家(≥5 达标)` }, + { label: "增长趋势", ok: growthOk, value: `支撑窗口覆盖 ${wins.length} 个季度` }, + { label: "差异度", ok: distinct >= 0.4, value: `${distinct.toFixed(2)}(≥0.40 达标)` }, + ]; + const okCount = dots.filter((d) => d.ok).length; + const level = okCount >= 4 ? "高" : okCount === 3 ? "较高" : okCount === 2 ? "中" : "低"; + return { dots, level, okCount, distinctness: distinct, nearest: best }; +} + +function openReviewDesk(focusId) { + renderReviewDesk(focusId); + $("#review-desk-mask").classList.remove("hidden"); +} +CN.openReviewDesk = openReviewDesk; + +$("#review-desk-close").onclick = () => $("#review-desk-mask").classList.add("hidden"); +$("#review-desk-mask").addEventListener("click", (e) => { + if (e.target.id === "review-desk-mask") $("#review-desk-mask").classList.add("hidden"); +}); + +async function renderReviewDesk(focusId) { + const box = $("#review-desk-body"); + if (!box) return; + let pending = CN._pendingCache; + try { + pending = await api("/api/review/pending"); + CN._pendingCache = pending; + } catch (e) { + box.innerHTML = `
+

候选数据暂时加载失败:${esc(friendlyErr(e))}

+
`; + $("#desk-retry").onclick = () => renderReviewDesk(); + return; + } + if (!pending.length) { + box.innerHTML = `
暂无待审核候选(可在图谱页触发新岗位发现)
`; + return; + } + const levelTag = { "高": "tag-green", "较高": "tag-cyan", "中": "tag-orange", "低": "tag-red" }; + box.innerHTML = pending.map((c) => { + const t = candidateTrust(c, CN.jobs); + const wins = (c.support_windows || []).slice().sort(); + const span = wins.length ? `${wins[0]} ~ ${wins[wins.length - 1]}` : "—"; + return `
+
候选:${esc(c.title)} + 可信等级:${t.level}
+
职责摘要:${esc((c.responsibilities || [])[0] || `负责${c.title}相关的方案设计、开发与交付(职责描述待人工补充)`)}
+
${t.dots.map((d) => + ``).join("")} + ${t.okCount}/4 指标达标(JD / 企业 / 增长 / 差异)
+
多源证据:${c.source_count} JD · ${c.company_count} 企业 · 时间跨度 ${esc(span)}
+
与相近岗位:${t.nearest.title + ? `「${esc(t.nearest.title)}」技能重合 ${(t.nearest.sim * 100).toFixed(0)}%,职责差异度 ${t.distinctness.toFixed(2)}` + : "暂无可比较岗位"}
+
必备:${(c.required_skills || []).slice(0, 6).map((s) => `${esc(s)}`).join(" ") || "—"} + ${(c.preferred_skills || []).length ? `
加分:${c.preferred_skills.slice(0, 4).map((s) => `${esc(s)}`).join(" ")}` : ""}
+
+ + + + +
+
`; + }).join(""); + box.querySelectorAll("[data-act]").forEach((b) => { + b.onclick = () => doReview(b.dataset.id, b.dataset.act); + }); + box.querySelectorAll("[data-ev]").forEach((b) => { + b.onclick = () => { + const c = pending.find((x) => x.job_id === b.dataset.ev); + showJdEvidence((c && c.support_source_ids) || [], { jobTitle: (c || {}).title || b.dataset.ev }); + }; + }); + box.querySelectorAll("[data-cmp]").forEach((b) => { + b.onclick = () => { + const c = pending.find((x) => x.job_id === b.dataset.cmp); + const t = candidateTrust(c, CN.jobs); + const near = (CN.jobs || []).find((j) => j.title === t.nearest.title); + if (!near) return toast("暂无可比较的相近岗位", "info"); + const chips = (list, cls) => (list || []).map((s) => `${esc(s)}`).join(" ") || "—"; + openModal(`相近岗位比较:${esc(c.title)} vs ${esc(near.title)}`, ` +
技能重合 ${(t.nearest.sim * 100).toFixed(0)}% · 职责差异度 ${t.distinctness.toFixed(2)}(≥0.40 视为新岗位)
+
+
候选「${esc(c.title)}」必备
+
${chips(c.required_skills, "tag-blue")}
+
「${esc(near.title)}」必备
+
${chips(near.required_skills, "tag-green")}
+
候选独有技能
+
${chips((c.required_skills || []).filter((s) => !(near.required_skills || []).includes(s)), "tag-orange")}
+
`); + }; + }); + if (focusId) { + const el = box.querySelector(`[data-cand="${focusId}"]`); + if (el) { el.scrollIntoView({ block: "center" }); el.classList.add("flash"); } + } +} + /* 画布自适应:占满父容器(宽 100%、高 100%)+ dpr 高清,返回 CSS 像素坐标系下的 ctx/W/H */ function fitCanvas(canvas, cssH) { const dpr = window.devicePixelRatio || 1; @@ -532,36 +677,67 @@ function fitCanvas(canvas, cssH) { return { ctx, W, H }; } -function drawTrend(canvas, kv) { +/* 变化事件流:近 N 季度全岗位口径 新增/增强/减弱/退出 堆叠柱,点击柱段跳演化页对应岗位 */ +function drawEventFlow(canvas, events, n) { const { ctx, W, H } = fitCanvas(canvas, 220); ctx.clearRect(0, 0, W, H); - if (!kv.length) return; - const max = Math.max(1, ...kv.map(([, c]) => c)); - const bw = W / kv.length; - ctx.font = "11px sans-serif"; ctx.textAlign = "center"; - kv.forEach(([k, c], i) => { - const h = (c / max) * (H - 46); - const x = i * bw + bw * 0.18, y = H - h - 24; - const grad = ctx.createLinearGradient(0, y, 0, H - 24); - grad.addColorStop(0, "#2f54eb"); grad.addColorStop(1, "#85a5ff"); - ctx.fillStyle = grad; - ctx.beginPath(); - ctx.roundRect(x, y, bw * 0.64, h, [3, 3, 0, 0]); - ctx.fill(); - ctx.fillStyle = "#86909c"; - ctx.fillText(k.slice(2), i * bw + bw / 2, H - 8); - ctx.fillStyle = "#4e5969"; - ctx.fillText(c, i * bw + bw / 2, y - 5); + $("#cmd-stack-legend").innerHTML = Object.entries(CHANGE_META).map(([t, m]) => + `${t}`).join(""); + const windows = [...new Set(events.map((e) => e.window))].sort().slice(-n); + if (!windows.length) return; + const types = Object.keys(CHANGE_META); + const counts = windows.map((w) => { + const row = {}; + types.forEach((t) => { row[t] = events.filter((e) => e.window === w && e.change_type === t).length; }); + return row; }); + const maxTotal = Math.max(1, ...counts.map((r) => types.reduce((a, t) => a + r[t], 0))); + const bw = W / windows.length; + const baseY = H - 22; + ctx.font = "10px sans-serif"; ctx.textAlign = "center"; + windows.forEach((w, i) => { + let y = baseY; + types.forEach((t) => { + const v = counts[i][t]; + if (!v) return; + const h = (v / maxTotal) * (H - 52); + ctx.fillStyle = CHANGE_META[t].color; + ctx.fillRect(i * bw + bw * 0.2, y - h, bw * 0.6, h); + y -= h; + }); + const total = types.reduce((a, t) => a + counts[i][t], 0); + if (total) { ctx.fillStyle = "#4e5969"; ctx.fillText(total, i * bw + bw / 2, y - 4); } + ctx.fillStyle = "#86909c"; + ctx.fillText(w.slice(2), i * bw + bw / 2, H - 6); + }); + canvas._bars = windows.map((w, i) => ({ w, x0: i * bw, x1: (i + 1) * bw })); + canvas.onclick = (ev) => { + const rect = canvas.getBoundingClientRect(); + const x = ev.clientX - rect.left; + const bar = (canvas._bars || []).find((b) => x >= b.x0 && x < b.x1); + if (!bar) return; + /* 跳到该季度事件最多的岗位演化报告 */ + const byJob = {}; + events.filter((e) => e.window === bar.w).forEach((e) => { byJob[e.job_id] = (byJob[e.job_id] || 0) + 1; }); + const top = Object.entries(byJob).sort((a, b) => b[1] - a[1])[0]; + switchView("evolution"); + if (top && $("#evo-job-select").querySelector(`option[value="${top[0]}"]`)) { + $("#evo-job-select").value = top[0]; + loadEvolution(); + } + toast(`已定位 ${bar.w} 季度变化最多的岗位`, "info"); + }; } /* ================= 岗位演化 ================= */ +/* 变化类型视觉语义:新增=青绿 / 增强=蓝紫 / 减弱=琥珀 / 退出=砖红(持续=中性蓝灰) */ const CHANGE_META = { - "新增": { tag: "tag-green", color: "#00b42a" }, - "增强": { tag: "tag-blue", color: "#2f54eb" }, + "新增": { tag: "tag-cyan", color: "#13c2c2" }, + "增强": { tag: "tag-purple", color: "#722ed1" }, "减弱": { tag: "tag-orange", color: "#ff7d00" }, "退出": { tag: "tag-red", color: "#f53f3f" }, }; +const STEADY_COLOR = "#86909c"; // 持续:中性蓝灰 let evoEvents = []; /* 全部岗位 × 季度 演化热力图 */ @@ -573,7 +749,7 @@ function renderEvoHeatmap() { box.innerHTML = `
暂无演化事件数据
`; return; } - const windows = [...new Set(events.map((e) => e.window))].sort(); + const windows = [...new Set(events.map((e) => e.window))].sort().slice(-12); // 全岗位扫描仅近 12 季度 // 统计每个岗位每季度事件数与类型分布 const cell = {}; events.forEach((e) => { @@ -614,11 +790,26 @@ function renderEvoHeatmap() { box.querySelectorAll(".hm-row-label").forEach((el) => { el.onclick = () => { $("#evo-job-select").value = el.dataset.job; - loadEvolution().then(renderEvoHeatmap); + /* 点击行 → 转入该岗位报告(默认 Tab) */ + $$("#evo-tabs .match-tab").forEach((x) => x.classList.toggle("active", x.dataset.evotab === "report")); + $("#evo-tab-report").classList.remove("hidden"); + $("#evo-tab-scan").classList.add("hidden"); + loadEvolution(); }; }); } +/* 演化页二级 Tab:岗位报告(默认) / 全岗位扫描(热力图) */ +$$("#evo-tabs .match-tab").forEach((b) => { + b.onclick = () => { + $$("#evo-tabs .match-tab").forEach((x) => x.classList.toggle("active", x === b)); + const tab = b.dataset.evotab; + $("#evo-tab-report").classList.toggle("hidden", tab !== "report"); + $("#evo-tab-scan").classList.toggle("hidden", tab !== "scan"); + if (tab === "scan") renderEvoHeatmap(); + }; +}); + function bucketWindow(w, gran) { if (gran === "year") return w.slice(0, 4); if (gran === "month") { /* 季度窗口展开为季中月 */ return `${w.slice(0, 4)}-${String((+w.slice(5) - 1) * 3 + 2).padStart(2, "0")}`; } @@ -766,6 +957,7 @@ function renderEvolution() { const gran = $$("#evo-gran button").find((b) => b.classList.contains("active"))?.dataset.gran || "quarter"; drawStackBars($("#evo-stack-canvas"), evoEvents); renderEvoGantt($("#evo-job-select").value); + renderEvoReplace($("#evo-job-select").value); /* 热度走势:每季度实际生效的技能关系数(按 REQUIRES 边的生效区间统计) */ const jobEdges = ((CN.graphData || {}).edges || []) .filter((e) => e.source === $("#evo-job-select").value && e.rel === "REQUIRES" && (e.intervals || []).length); @@ -876,57 +1068,101 @@ function drawStackBars(canvas, events) { }); } -/* 技能生命周期甘特图 */ +/* 能力生命线(Swimlane):每个核心技能一行(近 8 季度),线段=生效区间,圆点=变更事件。 + * 行状态取该技能最近一次事件:新增=青绿 / 增强=蓝紫 / 减弱=琥珀 / 退出=砖红 / 持续=蓝灰 */ function renderEvoGantt(jobId) { const box = $("#evo-gantt"); const g = CN.graphData || {}; const allEvents = g.events || []; - const windows = [...new Set(allEvents.map((e) => e.window))].sort(); + const windows = [...new Set(allEvents.map((e) => e.window))].sort().slice(-8); const edges = (g.edges || []).filter((e) => e.source === jobId && e.rel === "REQUIRES" && (e.intervals || []).length); if (!windows.length || !edges.length) { - box.innerHTML = `
暂无该岗位的技能生命周期数据
`; + box.innerHTML = `
暂无该岗位的能力生命线数据
`; return; } - // 排序:当前仍生效的在前(按起始窗口升序),已退出的在后;限 14 行 + /* Top 12:当前仍生效优先,按当前需求指数降序 */ const alive = (e) => e.intervals.some(([s, en]) => en === null); - edges.sort((a, b) => { - const d = (alive(b) ? 1 : 0) - (alive(a) ? 1 : 0); - if (d) return d; - return (a.intervals[0][0] < b.intervals[0][0] ? -1 : 1); - }); - const rows = edges.slice(0, 14); - const labelEvery = Math.ceil(windows.length / 12); - // 事件 → 技能 × 窗口 的圆点 + const rows = edges.slice().sort((a, b) => + ((alive(b) ? 1 : 0) - (alive(a) ? 1 : 0)) || ((b.weight || 0) - (a.weight || 0))).slice(0, 12); const dots = {}; evoEvents.forEach((e) => { dots[`${e.skill}|${e.window}`] = e.change_type; }); - const cellHtml = (edge, w) => { + const lastWin = windows[windows.length - 1]; + const recentWins = windows.slice(-4); + const rowMeta = (edge) => { + const activeNow = edge.intervals.some(([s, en]) => s <= lastWin && (en === null || lastWin < en)); + if (!activeNow) return { label: "退出", color: CHANGE_META["退出"].color }; + const lastEv = evoEvents.filter((e) => e.skill === edge.target && recentWins.includes(e.window)).at(-1); + if (lastEv) return { label: lastEv.change_type, color: (CHANGE_META[lastEv.change_type] || {}).color || STEADY_COLOR }; + return { label: edge.importance === "required" ? "核心" : "加分", color: STEADY_COLOR }; + }; + const cellHtml = (edge, w, color) => { const active = edge.intervals.some(([s, en]) => s <= w && (en === null || w < en)); const dotType = dots[`${edge.target}|${w}`]; - const barColor = edge.importance === "required" ? "var(--primary)" : "var(--cyan)"; const dot = dotType - ? `` : ""; - return `
- ${active ? `
` : ""}${dot}
`; + ? `` : ""; + return `
+ ${active ? `
` : ""}${dot}
`; }; box.innerHTML = ` -
+
- ${windows.map((w, i) => `
${i % labelEvery === 0 ? esc(w.slice(2)) : ""}
`).join("")} - ${rows.map((e) => ` -
${esc(e.target)}
- ${windows.map((w) => cellHtml(e, w)).join("")}`).join("")} + ${windows.map((w) => `
${esc(w.slice(2))}
`).join("")} +
状态
+ ${rows.map((e) => { + const m = rowMeta(e); + return `
${esc(e.target)}
+ ${windows.map((w) => cellHtml(e, w, m.color)).join("")} +
${m.label}
`; + }).join("")}
- 生效区间:必备 - 加分 - 圆点: - ${Object.entries(CHANGE_META).map(([t, m]) => `${t}`).join("")} + 行状态: + ${Object.entries(CHANGE_META).map(([t, m]) => `${t}`).join("")} + 持续 + (圆点 = 该季度变更事件,点击技能名跳转图谱)
`; box.querySelectorAll(".gt-label").forEach((el) => { el.onclick = () => { switchView("graph"); if (CN.gotoNode) CN.gotoNode(el.dataset.skill); }; }); } +/* 能力更替流:旧核心(已退出必备) → 持续能力 → 新核心(近 4 季度新进入) */ +function renderEvoReplace(jobId) { + const box = $("#evo-replace"); + const g = CN.graphData || {}; + const wins = [...new Set((g.events || []).map((e) => e.window))].sort(); + const recent4 = new Set(wins.slice(-4)); + const edges = (g.edges || []).filter((e) => e.source === jobId && e.rel === "REQUIRES" && (e.intervals || []).length); + if (!edges.length) { + box.innerHTML = `
暂无能力更替数据
`; + return; + } + const alive = (e) => e.intervals.some(([s, en]) => en === null); + const start = (e) => e.intervals.map((i) => i[0]).sort()[0]; + const oldCore = edges.filter((e) => !alive(e)); + const cont = edges.filter((e) => alive(e) && !recent4.has(start(e))); + const newCore = edges.filter((e) => alive(e) && recent4.has(start(e))); + const chip = (e) => + `${esc(e.target)}`; + const col = (title, list, cls, note) => ` +
+
${title}(${list.length})
+
${note}
+
${list.slice(0, 10).map(chip).join("") || '无'}
+
`; + box.innerHTML = ` +
+ ${col("旧核心(已退出)", oldCore, "old", "曾为核心/加分,当前已退出画像")} +
→
+ ${col("持续能力", cont, "keep", "跨周期稳定生效,构成岗位基本盘")} +
→
+ ${col("新核心(近 4 季度进入)", newCore, "new", "本期新增或升级的必备/加分能力")} +
`; + box.querySelectorAll(".rp-chip").forEach((el) => { + el.onclick = () => { switchView("graph"); if (CN.gotoNode) CN.gotoNode(el.dataset.skill); }; + }); +} + /* ================= 人岗匹配(三步向导) ================= */ const matchState = { step: 1, skills: [], years: 0, result: null, profileText: "" }; let lastProfile = null; // 最近一次解析的完整简历画像(结构化模板与导出用) @@ -1062,6 +1298,12 @@ $("#btn-run-match").onclick = async () => { flushSkillInput(); // 输入框里未回车的技能先入库,避免丢失 const jobId = $("#match-job-select").value; matchState.years = +$("#parse-years").value || 0; + const btn = $("#btn-run-match"); + btn.disabled = true; + const embedReady = lastHealth && lastHealth.embed && lastHealth.embed.ready; + btn.textContent = embedReady + ? "诊断中…(首次语义召回需加载模型,约 10 秒)" + : "诊断中…"; try { const m = await post("/api/match", { job_id: jobId, skills: matchState.skills, years: matchState.years, @@ -1071,6 +1313,7 @@ $("#btn-run-match").onclick = async () => { renderMatchResult(m); setMatchStep(3); } catch (e) { toast(e.message, "error"); } + finally { btn.disabled = false; btn.textContent = "开始诊断"; } }; $("#btn-back-2").onclick = () => setMatchStep(1); $("#btn-goto-path").onclick = () => { @@ -1484,7 +1727,12 @@ $("#path-weeks").querySelectorAll(".seg-item").forEach((b) => { pathState.weeks = parseInt(b.dataset.weeks, 10); }; }); -$("#path-job-select").addEventListener("change", () => loadPathJob($("#path-job-select").value)); +$("#path-job-select").addEventListener("change", () => { + const id = $("#path-job-select").value; + loadPathJob(id).then(() => { + if (lastPathData && lastPathData._demo) { lastPathData = null; renderDemoPath(); } // 示范路线跟随岗位切换 + }); +}); $("#btn-path").onclick = async () => { const jobId = $("#path-job-select").value; @@ -1511,23 +1759,43 @@ function stageWeeks(n) { return Array.from({ length: n }, () => base); } +/* 阶段预计投入文案:按每周约 8 小时有效学习估算 */ +function stageEffortText(w) { + return `${w} 周 / 约 ${w * 8} 小时`; +} + function updatePathOverview() { if (!lastPathData || !lastPathData.learning_path.length) return; const n = lastPathData.learning_path.length; - const gaps = lastPathData.learning_path.reduce((a, st) => a + st.skills.length, 0); const done = pathState.done.size; const pct = n ? Math.round((done / n) * 100) : 0; + /* 当前匹配分来自匹配页诊断结果(同岗位才展示);目标分 = 匹配阈值 60 */ + const cur = (matchState.result && matchState.result.job_id === pathState.jobId) + ? `${Math.round(matchState.result.total_score)} 分` : "未诊断"; $("#path-overview").innerHTML = ` + ${lastPathData._demo ? `
示范路线 · 完成人岗诊断后将替换为你的个人结果
` : ""}
目标岗位${esc(lastPathData.title)}
-
待补技能${gaps} 项
-
阶段${n} 个
+
当前匹配分${cur}
+
目标分60 分
预计周期${pathState.weeks} 周
-
学习记录:已完成 ${done}/${n} 阶段(${pct}%)
+
学习记录:已完成 ${done}/${n} 阶段(${pct}%)· 完成阶段后可重新匹配验证
`; } +/* 未诊断时展示示范路线(当前选中岗,零已掌握),完成诊断后由个人结果替换 */ +async function renderDemoPath() { + if (lastPathData && lastPathData.learning_path && lastPathData.learning_path.length) return; + const jobId = $("#path-job-select").value || "job_Java开发工程师"; + try { + const data = await post("/api/learning-path", { job_id: jobId, known_skills: [] }); + data._demo = true; + lastPathData = data; + renderPath(data); + } catch { /* 静默:保留空状态引导 */ } +} + function renderPath(data) { const box = $("#path-result"); $("#btn-export-path").classList.toggle("hidden", !data.learning_path.length); @@ -1540,7 +1808,11 @@ function renderPath(data) { /* 阶段语义化命名:基础补齐 → 岗位核心 → 项目验证(超出 3 阶段顺延为综合提升) */ const STAGE_NAMES = ["基础补齐", "岗位核心", "项目验证"]; const stageName = (i) => STAGE_NAMES[i] || "综合提升"; - box.innerHTML = data.learning_path.map((st, i) => { + box.innerHTML = (data._demo ? ` +
+ 示范路线(以零已掌握技能生成):完成一次人岗诊断后,此处将自动替换为你的个人成长路线图 + +
` : "") + data.learning_path.map((st, i) => { const done = pathState.done.has(st.stage); return `
@@ -1554,6 +1826,7 @@ function renderPath(data) {
${st.skills.map((s) => `${esc(s)}`).join("")}
+ 预计投入:${stageEffortText(weeks[i])} 资源类型:${RESOURCE_TYPES[i % RESOURCE_TYPES.length]} 技能性质:必备补齐 ${st.acceptance ? `能力类别:${esc(st.acceptance.category)}` : ""} @@ -1585,11 +1858,27 @@ function renderPath(data) { box.querySelectorAll(".stage-check").forEach((el) => { el.onclick = () => { const st = parseInt(el.dataset.stage, 10); - if (pathState.done.has(st)) pathState.done.delete(st); else pathState.done.add(st); + const stage = (lastPathData.learning_path || []).find((x) => x.stage === st); + if (pathState.done.has(st)) { + pathState.done.delete(st); + } else { + pathState.done.add(st); + /* 完成阶段 → 左栏「已掌握」区同步点亮该阶段技能,并提示重新匹配验证 */ + if (stage && !lastPathData._demo) { + stage.skills.forEach((s) => pathState.known.add(s)); + renderSkillPool(); + updatePathSummary(); + toast("该阶段技能已同步为「已掌握」,可回到人岗匹配重新诊断验证", "success"); + } else if (stage) { + toast("示范路线:完成诊断后,阶段完成状态将同步到个人技能画像", "info"); + } + } localStorage.setItem(pathDoneKey(pathState.jobId), JSON.stringify([...pathState.done])); renderPath(lastPathData); }; }); + const gotoMatch2 = $("#path-goto-match-2"); + if (gotoMatch2) gotoMatch2.onclick = () => switchView("match"); updatePathOverview(); } @@ -2008,7 +2297,7 @@ $("#btn-config").onclick = () => openConfigModal(false); /* ================= 启动 ================= */ window.addEventListener("resize", () => { - if (CN._trend) drawTrend($("#trend-canvas"), CN._trend); + if (CN._flowEvents) drawEventFlow($("#trend-canvas"), CN._flowEvents, 8); drawHeat(); }); (async function boot() { diff --git a/webui/graph.js b/webui/graph.js index 9581302..4836013 100644 --- a/webui/graph.js +++ b/webui/graph.js @@ -26,6 +26,67 @@ const NOTES_KEY = "cn_notes"; const VIEW_KEY = "cn_graph_view"; const DEFAULT_SOLO_JOB = "job_Java开发工程师"; // 首屏默认岗位子图(局部可读) + /* 变化类型颜色(与 app.js CHANGE_META 一致):新增=青绿 / 增强=蓝紫 / 减弱=琥珀 / 退出=砖红 */ + const CHANGE_COLORS = { "新增": "#13c2c2", "增强": "#722ed1", "减弱": "#ff7d00", "退出": "#f53f3f" }; + const CHANGE_MARK = { "新增": "新", "增强": "↑", "减弱": "↓", "退出": "退" }; + + /* 变化角标数据:最近 2 季度有事件的技能(子图模式下按当前岗位过滤) */ + function updateRecentChange() { + const wins = [...new Set(G.events.map((e) => e.window))].sort().slice(-2); + const m = new Map(); + G.events.forEach((e) => { + if (!wins.includes(e.window)) return; + if (G.soloJob && e.job_id !== G.soloJob) return; + m.set(e.skill, e); // 事件按窗口升序,后者覆盖前者 = 取最近事件 + }); + G.recentChange = m; + } + + /* 同心分层布局(岗位子图):中心岗位 → 第一圈必备技能 → 第二圈加分技能 → 外圈场景/行业 */ + function applySoloLayout() { + const job = G.nodes[G.idx[G.soloJob]]; + if (!job) return; + const rings = [[], [], []]; + G.edges.forEach((e) => { + if (e.source !== G.soloJob) return; + const t = G.nodes[G.idx[e.target]]; + if (!t) return; + if (e.rel === "REQUIRES") rings[e.importance === "required" ? 0 : 1].push(t); + else rings[2].push(t); // APPLIES_TO / BELONGS_TO → 外圈 + }); + const base = [150, 260, 370]; + let prevR = 0; + rings.forEach((nodes, ri) => { + nodes.sort((a, b) => String(a.title || a.id).localeCompare(String(b.title || b.id), "zh")); + const R = Math.max(base[ri], prevR + 90, (nodes.length * 34) / (2 * Math.PI)); + prevR = R; + nodes.forEach((n, i) => { + const a = (i / Math.max(1, nodes.length)) * Math.PI * 2 - Math.PI / 2; + n.x = job.x + Math.cos(a) * R; + n.y = job.y + Math.sin(a) * R; + n.vx = 0; n.vy = 0; + }); + }); + } + + /* 视野适配子图(只按子图节点的包围盒,而非全图) */ + function fitSoloView() { + const c = canvas(); + const ids = new Set([G.soloJob, ...neighborsOf(G.soloJob)]); + let minX = 1e9, minY = 1e9, maxX = -1e9, maxY = -1e9; + G.nodes.forEach((n) => { + if (!ids.has(n.id) || !visible(n)) return; + minX = Math.min(minX, n.x); maxX = Math.max(maxX, n.x); + minY = Math.min(minY, n.y); maxY = Math.max(maxY, n.y); + }); + if (minX > maxX) return fitView(); + const w = maxX - minX || 1, h = maxY - minY || 1; + const W = c.clientWidth, H = c.clientHeight; + G.cam.k = Math.max(0.2, Math.min(W / (w + 160), H / (h + 160), 1.6)); + G.cam.x = (W - w * G.cam.k) / 2 - minX * G.cam.k; + G.cam.y = (H - h * G.cam.k) / 2 - minY * G.cam.k; + requestDraw(); drawMinimap(); + } /* ================= 数据装载 ================= */ async function load() { @@ -62,14 +123,17 @@ buildFilterOptions(); layout(); restoreView() || fitView(); - /* 默认进入「Java开发工程师」岗位子图:首屏局部可读,顶栏徽标可退出全图(每次装载仅应用一次) */ + /* 默认进入「Java开发工程师」岗位子图:同心分层布局(中心岗位 → 必备 → 加分 → 场景/行业), + * 退出子图即全量「岗位生态总览」(每次装载仅应用一次) */ if (!G._defaultApplied && !G.pendingLocate && G.idx[DEFAULT_SOLO_JOB]) { G._defaultApplied = true; G.soloJob = DEFAULT_SOLO_JOB; updateSoloBadge(); + applySoloLayout(); selectNode(DEFAULT_SOLO_JOB, false); - centerOn(G.nodes[G.idx[DEFAULT_SOLO_JOB]], Math.max(G.cam.k, 1.05)); + fitSoloView(); } + updateRecentChange(); renderSuggest(); renderChain(G.soloJob || null); $("#graph-loading").classList.add("hidden"); @@ -311,8 +375,39 @@ ctx.fillStyle = n.type === "Job" ? "#1d2129" : "#4e5969"; ctx.fillText(label, p.x, p.y - r - 7); } + /* 变化角标:最近 2 季度有事件的技能节点右上角显示 新/↑/↓/退 */ + const ch = n.type === "Skill" && G.recentChange ? G.recentChange.get(n.id) : null; + if (ch && !dim) { + const mark = CHANGE_MARK[ch.change_type] || ""; + const bc = CHANGE_COLORS[ch.change_type] || "#86909c"; + const bx = p.x + r * 0.95, by = p.y - r * 0.95; + ctx.fillStyle = bc; + ctx.beginPath(); ctx.arc(bx, by, 7, 0, 7); ctx.fill(); + ctx.strokeStyle = "#fff"; ctx.lineWidth = 1.2; + ctx.beginPath(); ctx.arc(bx, by, 7, 0, 7); ctx.stroke(); + ctx.fillStyle = "#fff"; ctx.font = "bold 8px sans-serif"; + ctx.textAlign = "center"; ctx.textBaseline = "middle"; + ctx.fillText(mark, bx, by + 0.5); + ctx.textBaseline = "alphabetic"; + } ctx.restore(); }); + + /* 悬停提示:有近期变化的技能显示变化时间与证据量 */ + if (G.hover && G.recentChange && G.recentChange.has(G.hover) && G.idx[G.hover] != null) { + const n = G.nodes[G.idx[G.hover]]; + const e = G.recentChange.get(G.hover); + const p = toScreen(n); + const r = Math.max(2.5, nodeRadius(n) * Math.sqrt(G.cam.k)); + const txt = `${e.window} ${e.change_type} · 支撑 ${(e.source_ids || []).length} 条 JD`; + ctx.font = "11px sans-serif"; + const tw = ctx.measureText(txt).width; + const bx = Math.max(4, Math.min(c.clientWidth - tw - 20, p.x - tw / 2 - 8)); + ctx.fillStyle = "rgba(29,33,41,0.92)"; + ctx.beginPath(); ctx.roundRect(bx, p.y + r + 10, tw + 16, 20, 4); ctx.fill(); + ctx.fillStyle = "#fff"; ctx.textAlign = "center"; + ctx.fillText(txt, bx + tw / 2 + 8, p.y + r + 24); + } } /* ================= 小地图 ================= */ @@ -460,17 +555,56 @@ $("#fp-category").value = ""; $("#fp-level").value = ""; requestDraw(); drawMinimap(); }; - /* 头部按钮 */ + /* 头部主操作:查看演化 / 查看证据 / 开始匹配;保存/分享/导出收进「更多」 */ + const activeJobId = () => { + const n = G.selected != null ? G.nodes[G.idx[G.selected]] : null; + if (n && n.type === "Job" && !n.pending) return G.selected; + if (G.soloJob) return G.soloJob; + return DEFAULT_SOLO_JOB; + }; + $("#btn-goto-evo").onclick = () => { + CN.switchView("evolution"); + $("#evo-job-select").value = activeJobId(); + $("#evo-job-select").dispatchEvent(new Event("change")); + }; + $("#btn-goto-match").onclick = () => { + CN.switchView("match"); + $("#match-job-select").value = activeJobId(); + $("#match-job-select").dispatchEvent(new Event("change")); + toast("已带入目标岗位,请上传或载入示例简历", "success"); + }; + $("#btn-goto-evidence").onclick = () => { + const id = G.selected || G.soloJob; + if (!id) return toast("请先在画布中选择一个节点", "info"); + const sources = new Set(); + G.edges.forEach((e) => { + if (e.source === id || e.target === id) (e.sources || []).forEach((s) => sources.add(s)); + }); + const list = [...sources]; + if (!list.length) return toast("该节点暂无来源证据记录", "info"); + const n = G.nodes[G.idx[id]]; + CN.showJdEvidence(list, { jobTitle: n.title || id }); + }; + $("#btn-more-menu").onclick = (e) => { + e.stopPropagation(); + $("#more-menu").classList.toggle("hidden"); + }; + document.addEventListener("click", (e) => { + if (!e.target.closest(".more-wrap")) $("#more-menu").classList.add("hidden"); + }); $("#btn-save-view").onclick = () => { localStorage.setItem(VIEW_KEY, JSON.stringify({ cam: G.cam, filters: G.filters, labels: G.showLabels })); + $("#more-menu").classList.add("hidden"); toast("当前视图(位置/缩放/筛选)已保存", "success"); }; $("#btn-share-graph").onclick = async () => { const url = `${location.origin}/#view=graph${G.selected ? `&node=${encodeURIComponent(G.selected)}` : ""}`; + $("#more-menu").classList.add("hidden"); try { await navigator.clipboard.writeText(url); toast("分享链接已复制到剪贴板", "success"); } catch { CN.sysCopy({ title: "分享链接(请复制)", text: url }); } }; $("#btn-export-graph").onclick = () => { + $("#more-menu").classList.add("hidden"); const c = canvas(); const tmp = document.createElement("canvas"); tmp.width = c.width; tmp.height = c.height; @@ -487,7 +621,6 @@ toast("图谱 PNG 已导出", "success"); }); }; - $("#gs-toggle").onclick = () => $("#graph-suggest").classList.toggle("collapsed"); } function restoreView() { @@ -511,34 +644,61 @@ } catch { return false; } } - /* ================= 智能建议 ================= */ + /* ================= 系统发现(原画布浮层「智能建议」,移入右侧详情面板底部可折叠区块) ================= */ + const IGNORED_KEY = "cn_ignored_findings"; + const ignoredSet = () => new Set(JSON.parse(localStorage.getItem(IGNORED_KEY) || "[]")); function renderSuggest() { + const ig = ignoredSet(); const items = []; G.pending.forEach((c) => items.push({ - color: "#ff7d00", kind: "pending", id: c.job_id, + color: "#ff7d00", kind: "pending", id: c.job_id, nodeId: c.job_id, text: `新岗位候选「${esc(c.title)}」待审核`, - sub: `${c.source_count} 条 JD · ${c.company_count} 家企业支撑`, + sub: `原因:聚类发现 ${c.source_count} 条 JD / ${c.company_count} 家企业指向未收录岗位 · 证据 ${c.source_count} 条`, })); G.events.slice(-4).reverse().forEach((e) => items.push({ - color: "#2f54eb", kind: "event", id: e.job_id, + color: "#2f54eb", kind: "event", id: `${e.job_id}|${e.skill}|${e.window}`, nodeId: 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)} · ${(e.source_ids || []).length} 条 JD`, })); - $("#gs-list").innerHTML = items.length ? items.map((it, i) => ` + const list = items.filter((it) => !ig.has(`${it.kind}|${it.id}`)); + $("#gs-list").innerHTML = list.length ? list.map((it, i) => `
-
${it.text}
${it.sub}
- ${it.kind === "pending" ? `去审核 →` : ""}
-
`).join("") : `
暂无建议
`; +
+
${it.text}
+
${it.sub}
+
+ + +
+
+
`).join("") : `
暂无系统发现
`; $$("#gs-list .gs-item").forEach((el) => { el.onclick = (ev) => { - if (ev.target.dataset.review) return; - const it = items[+el.dataset.i]; - locateNode(it.id); + if (ev.target.dataset.accept !== undefined || ev.target.dataset.ignore !== undefined) return; + locateNode(list[+el.dataset.i].nodeId); }; }); - $$("#gs-list [data-review]").forEach((a) => { - a.onclick = () => locateNode(a.dataset.review); + $$("#gs-list [data-accept]").forEach((b) => { + b.onclick = () => { + const it = list[+b.dataset.accept]; + if (it.kind === "pending") { + if (CN.openReviewDesk) CN.openReviewDesk(it.id); + } else { + CN.switchView("evolution"); + $("#evo-job-select").value = it.nodeId; + $("#evo-job-select").dispatchEvent(new Event("change")); + } + }; + }); + $$("#gs-list [data-ignore]").forEach((b) => { + b.onclick = () => { + const it = list[+b.dataset.ignore]; + const s = ignoredSet(); + s.add(`${it.kind}|${it.id}`); + localStorage.setItem(IGNORED_KEY, JSON.stringify([...s])); + renderSuggest(); + }; }); } @@ -605,10 +765,11 @@ 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(); + updateRecentChange(); fitView(); } selectNode(id); @@ -626,7 +787,13 @@ } } const soloExit = $("#solo-exit"); - if (soloExit) soloExit.onclick = () => { G.soloJob = null; updateSoloBadge(); fitView(); }; + if (soloExit) soloExit.onclick = () => { + G.soloJob = null; + updateSoloBadge(); + updateRecentChange(); + fitView(); + toast("已退出子图,当前为岗位生态总览(全量图)", "info"); + }; /* ================= 备注(localStorage) ================= */ const getNotes = () => JSON.parse(localStorage.getItem(NOTES_KEY) || "{}"); @@ -722,7 +889,7 @@ ? 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 EV_TAG = { "新增": "tag-cyan", "增强": "tag-purple", "减弱": "tag-orange", "退出": "tag-red" }; const skillChip = (r, cls) => `${esc(r.title)}`; const jobBlocks = isJob ? `
结论
@@ -789,8 +956,8 @@
操作
${isPending ? ` - - ` : ` + + ` : ` `} @@ -860,9 +1027,12 @@ G.soloJob = G.soloJob === id ? null : id; G._defaultApplied = true; updateSoloBadge(); - requestDraw(); - if (G.soloJob) toast(`已进入「${n.title || id}」岗位子图(只看该岗位与一跳技能/场景/行业)`, "success"); - else fitView(); + updateRecentChange(); + if (G.soloJob) { + applySoloLayout(); + fitSoloView(); + toast(`已进入「${n.title || id}」岗位子图(同心分层:中心岗位 → 必备 → 加分 → 场景/行业)`, "success"); + } else fitView(); }; const matchBtn = $("#dt-match"); if (matchBtn) matchBtn.onclick = () => { @@ -988,19 +1158,21 @@ /* 数据装载完成 Promise(演示导览等需要等图谱就绪后定位) */ ready() { return this._p || Promise.resolve(); }, has(id) { return id in G.idx; }, - /* 进入指定岗位的局部子图(演示导览第 3 步) */ + /* 进入指定岗位的局部子图(演示导览第 3 步):同心分层布局 */ solo(id) { if (!(id in G.idx)) return; G._defaultApplied = true; G.soloJob = id; updateSoloBadge(); + updateRecentChange(); + applySoloLayout(); selectNode(id, false); - centerOn(G.nodes[G.idx[id]], Math.max(G.cam.k, 1.05)); - requestDraw(); + fitSoloView(); }, onShow() { requestDraw(); drawMinimap(); }, async reload() { await load(); + if (G.soloJob && G.idx[G.soloJob]) { applySoloLayout(); fitSoloView(); } // 子图模式下重排同心分层 if (G.selected && !(G.selected in G.idx)) selectNode(null, false); else if (G.selected) renderDetail(G.selected); }, diff --git a/webui/index.html b/webui/index.html index 879876a..27445c5 100644 --- a/webui/index.html +++ b/webui/index.html @@ -4,7 +4,7 @@ CareerNexus 职联 · 岗位与能力图谱平台 - + @@ -79,62 +79,104 @@
- +
-
+
+
+

人才能力变化概览

+
正在汇总本期变化…
+
+
+
+ + + +
+
+
-

近 12 个季度演化事件趋势

- 按事件窗口统计 +

近 8 季度变化事件流(全岗位)

+ 点击柱段进入该季度演化详情 +
+
+ +
-
-

任务与审核状态

- -
-
- - - -
类型对象操作人时间结果
- +

数据可信度

+ 多源交叉验证口径
+
统计中…
-

动态消息流

点击可跳转
+

变化播报

点击跳转演化详情
-

待审核新岗位候选

0 待办
+
+

待办行动

+ + 0 待办 + + +
+
+
+

任务与审核台账

+ +
+ +
-

岗位能力图谱工作台

+

岗位能力证据地图

演示模式 技能点级 审核已开启
- - - + + + +
+ + +
@@ -203,15 +245,7 @@
- -
-
- - 智能建议 - -
-
-
+
@@ -233,6 +267,11 @@

支持拖拽平移 / 滚轮缩放 / 点击选中

+ +
+ 系统发现(智能建议) +
+
@@ -252,10 +291,19 @@
- -
全部岗位演化热力图(每格 = 该季度演化事件数,点击行切换岗位)
-
加载中…
+ +
+ + +
+ + +
@@ -263,6 +311,14 @@
本期结论(最近两个季度)
加载中…
+ +
能力生命线(近 8 季度 · 按当前需求指数取 Top 12;线段 = 生效区间,圆点 = 变更事件)
+
+ + +
能力更替流(旧核心 → 持续能力 → 新核心)
+
+
@@ -282,10 +338,6 @@
- -
技能生命周期(横条 = 技能在画像中的生效区间,圆点 = 变更事件)
-
-
技能热度走势(纵轴:活跃技能关系数) @@ -296,9 +348,10 @@
- -
演化事件时间轴(点击查看证据 JD)
+ +
本期变化卡(变化前 → 变化后 / 支撑 JD 数 / 点击查看证据原文)
+
@@ -538,6 +591,18 @@
+ + + - - + + diff --git a/webui/style.css b/webui/style.css index 8d80742..394475b 100644 --- a/webui/style.css +++ b/webui/style.css @@ -481,8 +481,8 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: content: ""; position: absolute; left: -21px; top: 16px; width: 10px; height: 10px; border-radius: 50%; border: 2px solid #fff; } -.evo-event.t-新增::before { background: var(--success); } -.evo-event.t-增强::before { background: var(--primary); } +.evo-event.t-新增::before { background: var(--cyan); } +.evo-event.t-增强::before { background: var(--purple); } .evo-event.t-减弱::before { background: var(--warning); } .evo-event.t-退出::before { background: var(--danger); } .evo-event .ee-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; } @@ -1024,3 +1024,141 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: /* 空状态内的行动按钮 */ .empty .btn { margin-top: 8px; } + +/* ============ 2026Q3 改版:指挥台 / 审核台 / 证据地图 / 生命线 / 路线图 ============ */ + +/* 指挥台首屏 */ +.cmd-hero { + display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; + padding: 16px 20px; margin-bottom: 16px; + background: linear-gradient(135deg, #f0f5ff 0%, #ffffff 55%); +} +.cmd-hero-main h2 { font-size: 18px; font-weight: 600; } +.cmd-sub { font-size: 13px; color: var(--text-2); margin-top: 6px; } +.cmd-scale { font-size: 12px; margin-top: 6px; } +.cmd-hero-actions { display: flex; gap: 8px; flex: none; padding-top: 4px; } +.cmd-kpis { grid-template-columns: repeat(4, 1fr); margin-bottom: 16px; } +.cmd-head-right { display: inline-flex; align-items: center; gap: 4px; } +.cmd-ledger { margin-top: 16px; } +.cmd-ledger .card-body { padding-top: 8px; } + +/* 数据可信度卡 */ +.trust-row { + display: flex; align-items: baseline; gap: 10px; + padding: 8px 2px; border-bottom: 1px dashed var(--border); +} +.trust-row:last-child { border-bottom: none; } +.trust-k { width: 76px; flex: none; font-size: 13px; color: var(--text-2); } +.trust-v { font-size: 18px; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--primary); min-width: 64px; } +.trust-sub { font-size: 12px; color: var(--text-3); } + +/* 变化播报卡 */ +.feed-item.bcast { align-items: center; } +.feed-item .bcast-body { flex: 1; min-width: 0; } +.feed-item.bcast .tag { flex: none; } + +/* 待办行动卡 */ +.review-item.action-card { border-left: 3px solid var(--warning); } + +/* 候选岗位审核台(右侧抽屉) */ +.drawer-mask { + position: fixed; inset: 0; background: rgba(29, 33, 41, 0.35); z-index: 450; +} +.drawer { + position: absolute; top: 0; right: 0; bottom: 0; width: 480px; max-width: calc(100vw - 48px); + background: var(--bg-page); box-shadow: -6px 0 16px rgba(29, 33, 41, 0.12); + display: flex; flex-direction: column; +} +.drawer-head { + display: flex; align-items: center; gap: 10px; + padding: 14px 16px; background: #fff; border-bottom: 1px solid var(--border); +} +.drawer-head h3 { font-size: 15px; } +.drawer-head .icon-btn { margin-left: auto; } +.drawer-body { flex: 1; overflow-y: auto; padding: 14px 16px; } +.cand-card { + background: #fff; border: 1px solid var(--border); border-radius: var(--radius); + padding: 14px 16px; margin-bottom: 12px; +} +.cand-card.flash { animation: cand-flash 1.6s ease; } +@keyframes cand-flash { 0%, 60% { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(47, 84, 235, 0.15); } } +.cc-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; } +.cc-head b { font-size: 15px; } +.cc-def { font-size: 13px; color: var(--text-2); line-height: 1.6; margin-bottom: 8px; } +.cc-dots { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; } +.cred-dot { + width: 12px; height: 12px; border-radius: 50%; + background: var(--bg-page); border: 1.5px solid var(--border-dark); +} +.cred-dot.on { background: var(--success); border-color: var(--success); } +.cc-dots-label { font-size: 12px; color: var(--text-3); margin-left: 4px; } +.cc-meta { font-size: 12px; color: var(--text-2); margin-bottom: 4px; } +.cc-diff { font-size: 12px; color: var(--text-2); margin-bottom: 8px; } +.cc-skills { font-size: 12px; color: var(--text-2); line-height: 1.9; margin-bottom: 10px; } +.cc-actions { display: flex; flex-wrap: wrap; gap: 8px; } +.cmp-meta { font-size: 13px; color: var(--text-2); margin-bottom: 12px; } + +/* 图谱页「更多」下拉 */ +.more-wrap { position: relative; } +.more-menu { + position: absolute; top: 38px; right: 0; z-index: 60; min-width: 150px; + background: #fff; border: 1px solid var(--border); border-radius: var(--radius); + box-shadow: var(--shadow-pop); padding: 4px; display: flex; flex-direction: column; +} +.more-item { + display: flex; align-items: center; gap: 8px; border: none; background: none; + padding: 8px 12px; font-size: 13px; color: var(--text-1); border-radius: var(--radius-sm); + text-align: left; cursor: pointer; white-space: nowrap; +} +.more-item:hover { background: var(--primary-light); color: var(--primary); } +.more-item svg { width: 14px; height: 14px; } + +/* 右侧详情面板底部「系统发现」(原画布智能建议浮层) */ +.sys-findings { margin-top: auto; border-top: 1px solid var(--border); background: var(--bg-hover); } +.sys-findings > summary { + cursor: pointer; padding: 10px 16px; font-size: 13px; font-weight: 600; color: var(--text-1); + user-select: none; list-style: none; +} +.sys-findings > summary::before { content: "▸ "; color: var(--warning); } +.sys-findings[open] > summary::before { content: "▾ "; } +.sys-findings .gs-list { max-height: 240px; overflow-y: auto; padding: 0 8px 10px; } +.gs-item .gs-body { flex: 1; min-width: 0; } +.gs-acts { display: flex; gap: 10px; margin-top: 4px; } +.gs-ignore { color: var(--text-3); } +.gs-ignore:hover { color: var(--danger); } + +/* 能力生命线(Swimlane) */ +.sl-grid { display: grid; gap: 2px; min-width: 640px; align-items: center; } +.sl-cell { position: relative; height: 20px; display: flex; align-items: center; } +.sl-seg { position: absolute; left: 0; right: 0; height: 4px; border-radius: 2px; opacity: .8; } +.sl-dot { + position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); + width: 9px; height: 9px; border-radius: 50%; border: 1.5px solid #fff; z-index: 1; +} +.sl-status { font-size: 11px; text-align: center; font-weight: 600; } + +/* 能力更替流(旧核心 → 持续能力 → 新核心) */ +.evo-replace { margin-bottom: 16px; } +.rp-grid { display: grid; grid-template-columns: 1fr 28px 1fr 28px 1fr; gap: 8px; align-items: stretch; } +.rp-col { background: var(--bg-hover); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 12px; } +.rp-col.old { border-left: 3px solid var(--danger); } +.rp-col.keep { border-left: 3px solid var(--text-3); } +.rp-col.new { border-left: 3px solid var(--cyan); } +.rp-title { font-size: 12px; font-weight: 600; color: var(--text-1); } +.rp-note { font-size: 11px; color: var(--text-3); margin: 2px 0 8px; } +.rp-chips { display: flex; flex-wrap: wrap; gap: 6px; } +.rp-chip { cursor: pointer; } +.rp-chip:hover { filter: brightness(0.92); } +.rp-arrow { align-self: center; text-align: center; color: var(--text-3); font-size: 16px; } + +/* 成长路线图:概要条示范标识 / 示范路线横幅 */ +.po-demo { + width: 100%; font-size: 12px; color: var(--warning); + background: var(--warning-light); border-radius: var(--radius-sm); padding: 4px 10px; +} +.path-demo-banner { + display: flex; align-items: center; gap: 10px; flex-wrap: wrap; + background: var(--warning-light); border: 1px solid #ffd591; border-radius: var(--radius); + padding: 10px 14px; font-size: 13px; color: #ad4e00; margin-bottom: 14px; +} +.path-demo-banner .btn { margin-left: auto; }