Compare commits

...
2 Commits
6 changed files with 753 additions and 175 deletions
+122
View File
@@ -0,0 +1,122 @@
# CareerNexus 职联 · 技术栈清单
> 记录本项目实际使用的全部技术栈(2026-08,与代码仓库一致)。
> 选型总原则:**离线优先、零重依赖、单命令启动、可复现**。
---
## 一、语言与运行时
| 技术 | 版本 | 用途 |
|---|---|---|
| Python | 3.11+(开发环境 3.13) | 后端、算法、数据管线、脚本 |
| JavaScript(原生) | ES2020+ | 前端全部交互(无 TypeScript/无构建) |
| Node.js | 20+ | 仅用于前端语法检查与逻辑单测(不参与运行) |
## 二、后端
| 技术 | 版本 | 用途 |
|---|---|---|
| FastAPI | ≥0.110 | REST 接口层(20+ 端点,统一响应格式/任务轮询/静态托管) |
| Uvicorn | ≥0.29 | ASGI 服务(127.0.0.1:9008,可切 0.0.0.0 + Token 鉴权) |
| Pydantic | v2 ≥2.6 | 全链路数据模型与 Schema 强校验(JD/抽取/图谱/匹配/简历) |
| httpx | ≥0.27 | LLM API 与 Embedding API 调用 |
| networkx | ≥3.2 | 时序知识图谱的底层有向图结构 |
| python-docx | ≥1.1 | 简历 DOCX 解析、规范简历 DOCX 导出 |
| pypdf | ≥4.0 | 简历 PDF 文本与内嵌照片提取 |
| reportlab | 仅样例生成 | 中文演示 PDF 样例制作(非运行依赖) |
## 三、前端(零构建、完全离线)
| 技术 | 说明 |
|---|---|
| 原生 HTML/CSS/JS | 无 React/Vue/npm,FastAPI 直接托管静态文件 |
| Canvas 2D | 图谱力导向/同心分层/职级分带/时序四种布局渲染、趋势图、雷达图、甘特图 |
| 自研设计系统 | 设计 token(主色 #2f54eb)、组件库(按钮/tag/卡片/抽屉/弹窗/分段控件)、内联 SVG 图标 |
| sysDialog 组件族 | 统一系统弹窗(sysPrompt/sysConfirm/sysAlert/sysCopy),替代浏览器原生弹窗 |
| cnFetch | 统一网络层:业务化中文错误、屏蔽内部细节、原位重试 |
| localStorage/sessionStorage | 视图状态、备注、学习记录、引导标记缓存 |
## 四、数据与存储
| 技术 | 用途 |
|---|---|
| 自研 TemporalKG(时序知识图谱) | 岗位-技能关系带生效区间/变更事件/证据源,任意时点切片 |
| JSON 快照(graph.json) | 图谱持久化与快速加载 |
| SQLite(标准库 sqlite3,WAL) | 应用状态库:审核留痕 audit_log、候选 review_candidates、任务 tasks、人工覆写 job_overrides |
| JSONL | JD 语料交换格式(jd_raw/、data/real/) |
| config.yaml + config.json | 静态配置 + 运行时设置(设置面板持久化,白名单键) |
| 文件系统 data/ | 语料/测试集/词表/简历/报告分目录治理 |
## 五、算法(全部纯 Python 自实现,无重型 ML 框架)
| 算法 | 实现 |
|---|---|
| MinHash LSH 近重复检测 | 256 哈希签名 + 5-gram shingles + 16 band 分带(抄袭 JD 聚簇) |
| 时间衰减加权 | w=exp(-λ·age),抑制 JD 时滞 |
| TF-IDF + 凝聚聚类 | 字符 2/3-gram 向量化、平均链接贪心合并(新岗位发现) |
| 时序演化分析 | 季度窗口加权频次对比 → 新增/增强/减弱/退出事件 |
| 新岗位四指标判定 | 支撑 JD 数/独立企业数/双年窗口增长率/与既有岗位差异度 |
| Kahn 拓扑分层 | 学习路径按技能前置关系分层 + 每阶段 ≤3 技能拆分 |
| 四维加权匹配评分 | 必备 50%/加分 15%/经验 20%/场景 15%,权重可配 |
| 技能归一化四级容错 | 词表别名 → 包含匹配 → 类别迁移 → BGE-M3 语义召回(阈值 0.62) |
| 简历解析 | 正则族 + 版面分节 + 脱敏(姓名/手机/邮箱/住址)+ 照片提取 |
## 六、AI 模型
| 模型 | 接入方式 | 用途 |
|---|---|---|
| 讯飞星火 Spark 4.0 Ultra | OpenAI 兼容 API(JSON 模式 + Schema 校验 + 失败重试 + 离线降级) | JD 结构化抽取主通道(密钥经设置面板注入) |
| BGE-M3 | sentence-transformers 本地权重(models/bge-m3,4.3GB) | 语义相似召回兜底(词表外技能映射) |
| 通义千问 text-embedding-v4 | DashScope OpenAI 兼容 API(可选替代本地权重) | 联网语义向量 |
| 离线确定性抽取器 | 技能词表 + 版面规则(无模型) | 全部 AI 通道的降级兜底 |
## 七、测试与评测
| 技术 | 用途 |
|---|---|
| pytest + pytest-cov | 77 个单元/接口/持久化测试,覆盖率 ≥80% |
| node 逻辑单测 | tests/test_frontend_logic.js(49 个前端纯函数用例) |
| 自研 evaluate.py | 三层指标:合成集(100%)/ 真实 JD(AutoWeakS,F1 99.8%)/ 真实简历(100%) |
| run_demo.py | 端到端冒烟(pipeline + 图谱 + 匹配示例) |
## 八、部署与运维
| 技术 | 用途 |
|---|---|
| Docker(python:3.11-slim) | 一体化镜像;`INCLUDE_EMBED=1` 可选语义增强变体 |
| docker-compose | app 编排 + Neo4j `advanced` profile(可选) |
| Neo4j 5 Community(可选) | 生产图数据库增强,scripts/sync_neo4j.py MERGE 幂等同步 |
| init.bat / init.sh | 一键初始化(venv + 依赖镜像站安装 + 离线默认配置 + 数据集生成) |
| start.bat / start.sh | 一键启动(项目内 venv 直起服务,自动开浏览器) |
| ModelScope | BGE-M3 权重国内镜像下载(设置面板一键配置管线) |
## 九、数据源
| 数据集 | 授权 | 用途 |
|---|---|---|
| AutoWeakS(北大/人大,arXiv:2012.14234) | 论文公开语料 | 真实 JD 语料(2915 条被动接入)+ 500 条真实评测集 |
| AIStudio 中文简历标注集 | CC0 | 200 份真实简历评测 |
| Chinese_resume 数据集 | 公开(研究用途) | 批量匹配演示简历(脱敏后 32 份) |
| ISCO/ESCO 国际职业分类 | EU 公开 | 岗位标准外部锚点 |
| 程序生成语料(seed=2026) | 自产 | 1720 条演示 JD、176 条测试集、词表 179 项、匹配标注 52 对 |
## 十、版本与协作
| 技术 | 用途 |
|---|---|
| Git + Gitea | 代码托管(`https://git.bu4.ren/RoyirinRosmontis/CareerNexus.git`) |
| .gitignore/.dockerignore | 数据内容、密钥配置、模型权重不入库/不进镜像 |
---
## 已评估未采用(及原因)
| 技术 | 未采用原因 |
|---|---|
| React/Vue + AntV G6 | 引入 npm 构建链,违背"零构建离线" |
| FAISS / BGE reranker / BM25 引擎 | 需模型权重或额外服务;词表归一化 + 透明加权已满足精度 |
| BERTopic / HDBSCAN | 重依赖;纯 Python TF-IDF + 凝聚聚类可复现且足够 |
| GLiNER | 重型 NER 模型;离线确定性抽取器更轻且确定性更强 |
| RAGAS | 需在线评测框架;evaluate.py 字段级 F1 + 真实数据集评测已覆盖 |
| PostgreSQL | 单机演示规模用不上;SQLite 零配置,存储边界已隔离可随时切换 |
+47
View File
@@ -111,6 +111,53 @@ eq(d.del.map((r) => r.skill).join(","), "JSP", "diff 退出");
eq(d.keep.length, 1, "diff 保留数");
eq(d.keep[0].up, true, "加分升级必备标记");
/* easeOutCubic:count-up 缓动曲线(0→1,中点已过 87.5%) */
const easeOutCubic = loadFn("easeOutCubic");
eq(easeOutCubic(0), 0, "缓动起点为 0");
eq(easeOutCubic(1), 1, "缓动终点为 1");
eq(easeOutCubic(0.5), 0.875, "缓动中点 easeOut 特征");
eq(easeOutCubic(0.25) < easeOutCubic(0.5), true, "缓动单调递增");
/* trendSeries:近 n 季度事件序列(总量 + 四类计数) */
const trendSeries = new Function(
`const CHANGE_META = { "新增": {}, "增强": {}, "减弱": {}, "退出": {} };\n${extractFn("trendSeries")}; return trendSeries;`)();
const tsEvts = [
...Array.from({ length: 9 }, (_, i) => ({ window: `20${10 + i}Q1`, change_type: "新增" })),
{ window: "2018Q1", change_type: "增强" },
{ window: "2018Q1", change_type: "退出" },
{ window: "2018Q1", change_type: "其他" },
];
const ts = trendSeries(tsEvts, 8);
eq(ts.length, 8, "趋势序列只取近 8 季");
eq(ts[7].window, "2018Q1", "最近窗口在序列末尾");
eq(ts[7].total, 4, "末季总量含未分类事件");
eq(ts[7].byType["新增"], 1, "末季新增计数");
eq(ts[7].byType["增强"], 1, "末季增强计数");
eq(ts[7].byType["退出"], 1, "末季退出计数");
eq(trendSeries([], 8).length, 0, "空事件空序列");
/* computeReplaceBuckets:能力更替三桶(旧核心 / 持续 / 新核心,近 4 季度口径) */
const computeReplaceBuckets = loadFn("computeReplaceBuckets");
const rpWins = ["2025Q2", "2025Q3", "2025Q4", "2026Q1", "2026Q2", "2026Q3"];
const rpEdges = [
{ target: "JSP", intervals: [["2020Q1", "2025Q4"]] }, // 已退出 → 旧核心
{ target: "Java", intervals: [["2020Q1", null]] }, // 持续
{ target: "Kubernetes", intervals: [["2026Q1", null]] }, // 近 4 季进入 → 新核心
{ target: "Docker", intervals: [["2025Q2", null]] }, // 起点不在近 4 季 → 持续
];
const rp = computeReplaceBuckets(rpEdges, rpWins);
eq(rp.oldCore.map((e) => e.target).join(","), "JSP", "旧核心=已退出");
eq(rp.cont.map((e) => e.target).join(","), "Java,Docker", "持续=生效且非近期进入");
eq(rp.newCore.map((e) => e.target).join(","), "Kubernetes", "新核心=近 4 季进入");
eq(computeReplaceBuckets(rpEdges, []).newCore.length, 0, "无窗口时无新核心");
/* radarValues:双雷达候选人四维归一化(缺失 0 / 超界截断) */
const radarValues = new Function(
`const RADAR_KEYS = ["required", "preferred", "experience", "scenario"];\n${extractFn("radarValues")}; return radarValues;`)();
eq(radarValues({ required: 80, preferred: 50, experience: 100, scenario: 0 }).join(","), "0.8,0.5,1,0", "雷达四维映射");
eq(radarValues({ required: 120 }).join(","), "1,0,0,0", "雷达超界截断");
eq(radarValues(null).join(","), "0,0,0,0", "雷达空对象兜底");
/* graph.js 纯逻辑:时序边生效判定 / 职级带归属(函数在 IIFE 内,按两空格缩进闭合提取) */
const gsrc = fs.readFileSync(path.join(__dirname, "..", "webui", "graph.js"), "utf-8");
function loadGraphFns(names) {
+320 -102
View File
@@ -150,7 +150,7 @@ const CN = window.CN = {
};
const TYPE_META = {
Job: { name: "岗位", color: "#2f54eb", tag: "tag-blue" },
Job: { name: "岗位", color: "#1890ff", tag: "tag-blue" },
Skill: { name: "技能", color: "#00b42a", tag: "tag-green" },
Industry: { name: "行业", color: "#13c2c2", tag: "tag-cyan" },
Scenario: { name: "场景", color: "#722ed1", tag: "tag-purple" },
@@ -223,7 +223,7 @@ async function showJdEvidence(sids, ctx = {}) {
} catch (e) {
const gone = /不存在|非法|格式/.test((e && e.message) || "");
$("#modal-body").innerHTML = `
<div class="empty small">
<div class="empty small">${EMPTY_SVG}
<p>${gone ? "该能力当前没有可展示的公开原文,已进入人工核验队列"
: "暂时无法读取该条证据,请检查服务后重试"}</p>
<button class="btn btn-secondary btn-sm" id="ev-retry">重试</button>
@@ -302,19 +302,43 @@ $$(".nav-item").forEach((btn) => { btn.onclick = () => switchView(btn.dataset.vi
})();
/* ================= 人才能力变化指挥台(数据驾驶舱) ================= */
/* 首屏 KPI:新岗位候选 / 关键技能增强 / 待人工复核 / 影响岗位(图谱规模降级为一行小字) */
const METRIC_DEFS = [
{ key: "candidates", label: "新岗位候选", act: "desk", sub: "聚类发现,待人工决策", icon: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>' },
{ key: "boosted", label: "关键技能增强", act: "evolution", sub: "近 2 季度新增+增强事件", icon: '<svg viewBox="0 0 24 24"><path d="M3 17l5.5-5.5 3.5 3.5L20 7"/><path d="M15 7h5v5"/></svg>' },
{ key: "review", label: "待人工复核", act: "desk", sub: "候选岗位 + 候选技能关系", icon: '<svg viewBox="0 0 24 24"><path d="M9 11l3 3 8-8"/><path d="M21 12v6a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2h11"/></svg>' },
{ key: "affected", label: "影响岗位", act: "evolution", sub: "近 2 季度发生变化的岗位", icon: '<svg viewBox="0 0 24 24"><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V5a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>' },
/* 空状态统一占位图形(SVG + 引导文案 + 行动按钮中的图形部分) */
const EMPTY_SVG = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01"/></svg>';
/* Hero 指标区:有效 JD / 独立企业 / 技能演化事件 / 人工复核率(浅色渐变卡 + count-up 数字滚动) */
const HERO_DEFS = [
{ key: "jd", label: "有效 JD", unit: " 条", sub: "去重降噪后入图",
icon: '<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>' },
{ key: "companies", label: "独立企业", unit: " 家", sub: "跨企业交叉验证,防单一企业造词",
icon: '<svg viewBox="0 0 24 24"><path d="M3 21h18M5 21V8l6-4 6 4v13M9 12h2M13 12h2M9 16h2M13 16h2"/></svg>' },
{ key: "events", label: "技能演化事件", unit: " 项", sub: "岗位能力动态更新累计", act: "evolution",
icon: '<svg viewBox="0 0 24 24"><path d="M3 17l5.5-5.5 3.5 3.5L20 7"/><path d="M15 7h5v5"/></svg>' },
{ key: "review", label: "人工复核率", unit: "%", sub: "审核留痕覆盖率", act: "desk",
icon: '<svg viewBox="0 0 24 24"><path d="M9 11l3 3 8-8"/><path d="M21 12v6a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2h11"/></svg>' },
];
/* count-up 缓动(纯逻辑,node 可测):easeOutCubic,800ms 完成 */
function easeOutCubic(t) {
return 1 - Math.pow(1 - t, 3);
}
/* 数字滚动微动效:requestAnimationFrame + easeOutCubic,约 800ms */
function countUp(el, target, unit = "", dur = 800) {
const t0 = performance.now();
const step = (now) => {
const p = Math.min(1, (now - t0) / dur);
el.textContent = Math.round(target * easeOutCubic(p)) + unit;
if (p < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
}
async function loadDashboard() {
const [health, graph, pending, audit] = await Promise.all([
const [health, graph, pending, audit, cred] = await Promise.all([
api("/api/health"), api("/api/graph"),
api("/api/review/pending").catch(() => []),
api("/api/audit?limit=8").catch(() => []),
api("/api/stats/credibility").catch(() => null),
]);
CN.graphData = graph;
CN._pendingCache = pending;
@@ -329,10 +353,8 @@ async function loadDashboard() {
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} 个岗位候选已具备入库条件`
@@ -340,29 +362,38 @@ async function loadDashboard() {
$("#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,
/* Hero 指标区(count-up 数字滚动;演化事件/复核率两张可点击直达) */
const heroValues = {
jd: cred ? cred.jd_valid : null,
companies: cred ? cred.companies : null,
events: s.events ?? events.length,
review: cred && cred.review_rate != null ? Math.round(cred.review_rate * 100) : null,
};
$("#metric-cards").innerHTML = METRIC_DEFS.map((m) => `
<div class="metric-card" data-act="${m.act}">
<div class="mc-label">${m.icon}${m.label}</div>
<div class="mc-num">${values[m.key] ?? "—"}</div>
<span class="mc-delta flat">${m.sub}</span>
$("#metric-cards").innerHTML = HERO_DEFS.map((m) => `
<div class="hero-kpi${m.act ? " clickable" : ""}"${m.act ? ` data-act="${m.act}"` : ""}>
<div class="hk-label">${m.icon}${m.label}</div>
<div class="hk-num" data-key="${m.key}">—</div>
<div class="hk-sub">${m.sub}</div>
</div>`).join("");
$$("#metric-cards .metric-card").forEach((el) => {
el.onclick = () => (el.dataset.act === "desk" ? openReviewDesk() : switchView("evolution"));
$$("#metric-cards .hero-kpi").forEach((el) => {
if (el.dataset.act === "desk") el.onclick = () => openReviewDesk();
else if (el.dataset.act === "evolution") el.onclick = () => switchView("evolution");
});
HERO_DEFS.forEach((m) => {
const el = $(`#metric-cards .hk-num[data-key="${m.key}"]`);
const v = heroValues[m.key];
if (!el) return;
if (v == null) { el.textContent = "—"; return; }
countUp(el, v, m.unit);
});
$("#pending-count").textContent = `${pending.length} 待办`;
/* 主图:近 8 季度变化事件流(全岗位口径堆叠柱,点击柱段跳演化页) */
/* 主图:人才能力变化趋势(平滑折线 + 面积渐变 + hover tooltip,点击定位岗位) */
CN._flowEvents = events;
drawEventFlow($("#trend-canvas"), events, 8);
drawTrendLine($("#trend-canvas"), events, 8);
renderCmdReplace();
renderTrust();
renderTrust(cred);
renderTaskTable(audit);
$("#btn-import-jd").onclick = () => openImportDialog();
$("#btn-cmd-report").onclick = () => switchView("evolution");
@@ -377,11 +408,12 @@ async function loadDashboard() {
renderPending(pending);
}
/* 数据可信度卡:有效 JD / 独立企业 / 来源类型 / 人工复核率 / 异常剔除率 */
async function renderTrust() {
/* 数据可信度卡:有效 JD / 独立企业 / 来源类型 / 人工复核率 / 异常剔除率
* t 由 loadDashboard 一并取回(避免重复请求);为空时自行拉取(重试用) */
async function renderTrust(t) {
const box = $("#trust-card");
try {
const t = await api("/api/stats/credibility");
if (!t) t = await api("/api/stats/credibility");
const rows = [
["有效 JD", `${t.jd_valid} 条`, `原始 ${t.jd_total} 条,去重降噪后入图`],
["独立企业", `${t.companies} 家`, "跨企业交叉验证,防单一企业造词"],
@@ -394,10 +426,10 @@ async function renderTrust() {
<div class="trust-row"><span class="trust-k">${k}</span><b class="trust-v">${v}</b>
<span class="trust-sub">${esc(sub)}</span></div>`).join("");
} catch (e) {
box.innerHTML = `<div class="empty small">
box.innerHTML = `<div class="empty small">${EMPTY_SVG}
<p>可信度统计暂时加载失败:${esc(friendlyErr(e))}</p>
<button class="btn btn-secondary btn-sm" id="trust-retry">重试</button></div>`;
$("#trust-retry").onclick = renderTrust;
$("#trust-retry").onclick = () => renderTrust();
}
}
@@ -473,14 +505,20 @@ function renderTaskTable(audit) {
}).join("");
}
/* 变化播报卡:技能图标 + 变化类型 + 影响岗位 + 证据数 + 跳转演化页(审核动作归入台账) */
/* 变化播报:最新 5 条卡片翻页轮播(3 秒淡入淡出自动切换,悬停暂停,点击跳演化页) */
function renderFeed(events) {
const verb = { "新增": "成为新要求", "增强": "需求显著增强", "减弱": "需求明显减弱", "退出": "退出能力画像" };
const items = (events || []).slice(-6).reverse();
const items = (events || []).slice(-5).reverse();
const box = $("#activity-feed");
if (CN._bcastTimer) { clearInterval(CN._bcastTimer); CN._bcastTimer = null; }
const jobTitle = (id) => ((CN.jobs || []).find((j) => j.job_id === id) || {}).title || id.replace(/^job_/, "");
$("#activity-feed").innerHTML = items.length ? items.map((e, i) => {
if (!items.length) {
box.innerHTML = `<div class="empty small">${EMPTY_SVG}<p>暂无变化播报</p></div>`;
return;
}
box.innerHTML = `<div class="bcast-stage">${items.map((e, i) => {
const m = CHANGE_META[e.change_type] || { tag: "tag-gray", color: "#86909c" };
return `<div class="feed-item bcast" data-i="${i}">
return `<div class="bcast-card feed-item${i === 0 ? " on" : ""}" data-i="${i}">
<span class="fi-icon" style="color:${m.color};background:${m.color}1a">
<svg viewBox="0 0 24 24"><path d="M12 2l2.4 4.9 5.4.8-3.9 3.8.9 5.4-4.8-2.5-4.8 2.5.9-5.4L4.2 7.7l5.4-.8L12 2z"/></svg></span>
<div class="bcast-body">
@@ -489,8 +527,24 @@ function renderFeed(events) {
</div>
<span class="tag ${m.tag}">${esc(e.change_type)}</span>
</div>`;
}).join("") : `<div class="empty small">暂无变化播报</div>`;
$$("#activity-feed .feed-item").forEach((el) => {
}).join("")}</div>
<div class="bcast-dots">${items.map((_, i) => `<i data-i="${i}" class="${i === 0 ? "on" : ""}"></i>`).join("")}</div>`;
const cards = $$("#activity-feed .bcast-card");
const dots = $$("#activity-feed .bcast-dots i");
let idx = 0;
const show = (k) => {
idx = k;
cards.forEach((c, j) => c.classList.toggle("on", j === k));
dots.forEach((d, j) => d.classList.toggle("on", j === k));
};
const start = () => {
if (CN._bcastTimer) clearInterval(CN._bcastTimer);
CN._bcastTimer = setInterval(() => show((idx + 1) % items.length), 3000);
};
start();
box.onmouseenter = () => { if (CN._bcastTimer) { clearInterval(CN._bcastTimer); CN._bcastTimer = null; } };
box.onmouseleave = start;
cards.forEach((el) => {
el.onclick = () => {
const e = items[+el.dataset.i];
switchView("evolution");
@@ -498,13 +552,17 @@ function renderFeed(events) {
loadEvolution();
};
});
dots.forEach((d) => { d.onclick = () => { show(+d.dataset.i); start(); } });
}
/* 待办行动卡(最多 3 张:结论 + 证据强度 + 处理按钮;完整台账见审核台/下方台账) */
function renderPending(pending) {
const box = $("#pending-list");
if (!pending.length) {
box.innerHTML = `<div class="empty small">暂无待审核候选(可在图谱页触发新岗位发现)</div>`;
box.innerHTML = `<div class="empty small">${EMPTY_SVG}
<p>暂无待审核候选(可在图谱页触发新岗位发现)</p>
<button class="btn btn-secondary btn-sm" id="pending-goto-graph">打开岗位图谱</button></div>`;
$("#pending-goto-graph").onclick = () => switchView("graph");
return;
}
box.innerHTML = pending.slice(0, 3).map((c) => {
@@ -608,7 +666,13 @@ async function renderReviewDesk(focusId) {
return;
}
if (!pending.length) {
box.innerHTML = `<div class="empty small">暂无待审核候选(可在图谱页触发新岗位发现)</div>`;
box.innerHTML = `<div class="empty">${EMPTY_SVG}
<p>暂无待审核候选<br>可在图谱页触发新岗位发现,候选经人工审核后才会入图</p>
<button class="btn btn-secondary btn-sm" id="desk-goto-graph">打开岗位图谱</button></div>`;
$("#desk-goto-graph").onclick = () => {
$("#review-desk-mask").classList.add("hidden");
switchView("graph");
};
return;
}
const levelTag = { "高": "tag-green", "较高": "tag-cyan", "中": "tag-orange", "低": "tag-red" };
@@ -692,55 +756,116 @@ function fitCanvas(canvas, cssH) {
return { ctx, W, H };
}
/* 变化事件流:近 N 季度全岗位口径 新增/增强/减弱/退出 堆叠柱,点击柱段跳演化页对应岗位 */
function drawEventFlow(canvas, events, n) {
const { ctx, W, H } = fitCanvas(canvas, 220);
/* 季度变化事件序列(纯逻辑,node 可测):近 n 个季度,每季总量 + 四类变化计数 */
function trendSeries(events, n) {
const windows = [...new Set(events.map((e) => e.window))].sort().slice(-n);
return windows.map((w) => {
const byType = {};
Object.keys(CHANGE_META).forEach((t) => { byType[t] = 0; });
let total = 0;
events.forEach((e) => {
if (e.window !== w) return;
total += 1;
if (e.change_type in byType) byType[e.change_type] += 1;
});
return { window: w, total, byType };
});
}
/* Catmull-Rom 转三次贝塞尔:过点平滑曲线(canvas 绘制辅助) */
function smoothPath(ctx, pts) {
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[Math.max(0, i - 1)], p1 = pts[i];
const p2 = pts[i + 1], p3 = pts[Math.min(pts.length - 1, i + 2)];
ctx.bezierCurveTo(
p1.x + (p2.x - p0.x) / 6, p1.y + (p2.y - p0.y) / 6,
p2.x - (p3.x - p1.x) / 6, p2.y - (p3.y - p1.y) / 6,
p2.x, p2.y);
}
}
/* 人才能力变化趋势:平滑折线 + 面积渐变 + hover 数值 tooltip,点击定位该季度变化最多的岗位 */
function drawTrendLine(canvas, events, n) {
const { ctx, W, H } = fitCanvas(canvas, 240);
ctx.clearRect(0, 0, W, H);
$("#cmd-stack-legend").innerHTML = Object.entries(CHANGE_META).map(([t, m]) =>
`<span><i style="background:${m.color}"></i>${t}</span>`).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); }
const kv = trendSeries(events, n);
if (!kv.length) return;
const pad = { l: 34, r: 14, t: 14, b: 26 };
const max = Math.max(1, ...kv.map((p) => p.total));
/* y 轴网格与刻度 */
ctx.strokeStyle = "#f0f0f0"; ctx.fillStyle = "#86909c"; ctx.font = "10px sans-serif";
for (let g = 0; g <= 4; g++) {
const y = pad.t + ((H - pad.t - pad.b) * g) / 4;
ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(W - pad.r, y); ctx.stroke();
ctx.textAlign = "right";
ctx.fillText(Math.round(max * (1 - g / 4)), pad.l - 6, y + 3);
}
const px = (i) => pad.l + (i / Math.max(1, kv.length - 1)) * (W - pad.l - pad.r);
const py = (v) => pad.t + (1 - v / max) * (H - pad.t - pad.b);
const pts = kv.map((p, i) => ({ x: px(i), y: py(p.total) }));
const hover = canvas._hoverIdx;
/* 平滑折线 + 面积渐变 */
ctx.beginPath();
smoothPath(ctx, pts);
ctx.strokeStyle = "#1890ff"; ctx.lineWidth = 2; ctx.stroke();
ctx.lineTo(pts[pts.length - 1].x, H - pad.b); ctx.lineTo(pts[0].x, H - pad.b); ctx.closePath();
const grad = ctx.createLinearGradient(0, pad.t, 0, H - pad.b);
grad.addColorStop(0, "rgba(24,144,255,0.22)"); grad.addColorStop(1, "rgba(24,144,255,0.02)");
ctx.fillStyle = grad; ctx.fill();
/* 数据点 + x 轴季度标签 */
ctx.textAlign = "center";
pts.forEach((p, i) => {
ctx.fillStyle = i === hover ? "#096dd9" : "#1890ff";
ctx.beginPath(); ctx.arc(p.x, p.y, i === hover ? 4.5 : 3, 0, 7); ctx.fill();
ctx.fillStyle = "#86909c";
ctx.fillText(w.slice(2), i * bw + bw / 2, H - 6);
ctx.fillText(kv[i].window.slice(2), p.x, H - 8);
});
canvas._bars = windows.map((w, i) => ({ w, x0: i * bw, x1: (i + 1) * bw }));
const nearestIdx = (clientX) => {
const x = clientX - canvas.getBoundingClientRect().left;
let best = null, bd = 1e9;
pts.forEach((p, i) => { const d = Math.abs(p.x - x); if (d < bd) { bd = d; best = i; } });
return { best, bd };
};
canvas.onmousemove = (ev) => {
const tip = $("#trend-tip");
const { best, bd } = nearestIdx(ev.clientX);
if (best == null || bd > 48) {
if (canvas._hoverIdx != null) { canvas._hoverIdx = null; drawTrendLine(canvas, events, n); }
tip.classList.add("hidden");
return;
}
if (canvas._hoverIdx !== best) { canvas._hoverIdx = best; drawTrendLine(canvas, events, n); }
const p = kv[best];
const tip2 = $("#trend-tip");
tip2.innerHTML = `<b>${esc(p.window)}</b> · 变化事件 ${p.total} 项<br>` +
(Object.entries(p.byType).filter(([, v]) => v).map(([t, v]) => `${t} ${v}`).join(" · ") || "本季度无四类变化");
tip2.classList.remove("hidden");
const ox = canvas.offsetLeft, oy = canvas.offsetTop;
tip2.style.left = `${Math.min(Math.max(pts[best].x + ox + 12, 4), W + ox - 150)}px`;
tip2.style.top = `${Math.max(pts[best].y + oy - 12, 4)}px`;
};
canvas.onmouseleave = () => {
$("#trend-tip").classList.add("hidden");
if (canvas._hoverIdx != null) { canvas._hoverIdx = null; drawTrendLine(canvas, events, n); }
};
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 { best } = nearestIdx(ev.clientX);
if (best == null) return;
const w = kv[best].window;
/* 跳到该季度事件最多的岗位演化报告 */
const byJob = {};
events.filter((e) => e.window === bar.w).forEach((e) => { byJob[e.job_id] = (byJob[e.job_id] || 0) + 1; });
events.filter((e) => e.window === 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");
toast(`已定位 ${w} 季度变化最多的岗位`, "info");
};
}
@@ -1037,15 +1162,15 @@ function drawHeatLine(canvas, kv) {
/* 面积 + 折线 */
ctx.beginPath();
kv.forEach(([, v], i) => ctx[i ? "lineTo" : "moveTo"](px(i), py(v)));
ctx.strokeStyle = "#2f54eb"; ctx.lineWidth = 2; ctx.stroke();
ctx.strokeStyle = "#1890ff"; ctx.lineWidth = 2; ctx.stroke();
ctx.lineTo(px(kv.length - 1), H - pad.b); ctx.lineTo(px(0), H - pad.b); ctx.closePath();
const grad = ctx.createLinearGradient(0, pad.t, 0, H - pad.b);
grad.addColorStop(0, "rgba(47,84,235,0.25)"); grad.addColorStop(1, "rgba(47,84,235,0.02)");
grad.addColorStop(0, "rgba(24,144,255,0.25)"); grad.addColorStop(1, "rgba(24,144,255,0.02)");
ctx.fillStyle = grad; ctx.fill();
/* 点与 x 轴 */
ctx.textAlign = "center";
kv.forEach(([k, v], i) => {
ctx.fillStyle = "#2f54eb";
ctx.fillStyle = "#1890ff";
ctx.beginPath(); ctx.arc(px(i), py(v), 3, 0, 7); ctx.fill();
if (kv.length <= 14 || i % Math.ceil(kv.length / 14) === 0) {
ctx.fillStyle = "#86909c"; ctx.fillText(k, px(i), H - 8);
@@ -1093,22 +1218,65 @@ function drawStackBars(canvas, events) {
/* 能力生命线(Swimlane):每个核心技能一行(近 8 季度),线段=生效区间,圆点=变更事件。
* 行状态取该技能最近一次事件:新增=青绿 / 增强=蓝紫 / 减弱=琥珀 / 退出=砖红 / 持续=蓝灰 */
/* 能力更替分桶(纯逻辑,node 可测):旧核心(已退出) → 持续能力 → 新核心(近 4 季度进入) */
function computeReplaceBuckets(edges, windows) {
const wins = (windows || []).slice().sort();
const recent4 = new Set(wins.slice(-4));
const alive = (e) => e.intervals.some(([s, en]) => en === null);
const start = (e) => e.intervals.map((i) => i[0]).sort()[0];
return {
oldCore: edges.filter((e) => !alive(e)),
cont: edges.filter((e) => alive(e) && !recent4.has(start(e))),
newCore: edges.filter((e) => alive(e) && recent4.has(start(e))),
};
}
/* 驾驶舱「岗位能力更替」卡:全岗位口径近 4 季度(与演化页更替流共用分桶逻辑) */
function renderCmdReplace() {
const box = $("#cmd-replace");
const g = CN.graphData || {};
const wins = [...new Set((g.events || []).map((e) => e.window))].sort();
const edges = (g.edges || []).filter((e) => e.rel === "REQUIRES" && (e.intervals || []).length);
if (!edges.length) {
box.innerHTML = `<div class="empty small">${EMPTY_SVG}<p>暂无能力更替数据</p></div>`;
return;
}
const buckets = computeReplaceBuckets(edges, wins);
const col = (title, list, cls, note, tagCls) => {
const skills = [...new Set(list.map((e) => e.target))];
return `<div class="rp-col ${cls}">
<div class="rp-title">${title}(${skills.length})</div>
<div class="rp-note">${note}</div>
<div class="rp-chips">${skills.slice(0, 10).map((s) =>
`<span class="tag ${tagCls} rp-chip" data-skill="${esc(s)}" title="点击跳转图谱节点">${esc(s)}</span>`).join("") ||
'<span class="text-weak text-aux">无</span>'}
${skills.length > 10 ? `<span class="text-weak text-aux">+${skills.length - 10}</span>` : ""}</div>
</div>`;
};
box.innerHTML = `<div class="rp-grid cmd-rp">
${col("旧核心(已退出)", buckets.oldCore, "old", "曾生效,当前已退出画像", "tag-red")}
<div class="rp-arrow">→</div>
${col("持续能力", buckets.cont, "keep", "跨周期稳定生效的岗位基本盘", "tag-blue")}
<div class="rp-arrow">→</div>
${col("新核心(近 4 季度)", buckets.newCore, "new", "本期新增或升级的能力", "tag-cyan")}
</div>`;
box.querySelectorAll(".rp-chip").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 = `<div class="empty small">暂无能力更替数据</div>`;
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 { oldCore, cont, newCore } = computeReplaceBuckets(edges, wins);
const chip = (e) =>
`<span class="tag ${alive(e) ? (e.importance === "required" ? "tag-blue" : "tag-cyan") : "tag-red"} rp-chip" data-skill="${esc(e.target)}">${esc(e.target)}</span>`;
const col = (title, list, cls, note) => `
@@ -1289,6 +1457,23 @@ $("#btn-goto-path").onclick = () => {
loadPathJob($("#match-job-select").value, matchState.skills).then(() => $("#btn-path").click());
};
/* 缺失技能「去学习」:跳学习路径页,带入岗位与已掌握技能,生成路线并在技能清单中闪烁定位目标技能 */
function gotoLearnSkill(skill) {
const jobId = $("#match-job-select").value;
$("#path-job-select").value = jobId;
switchView("path");
loadPathJob(jobId, matchState.skills).then(() => {
$("#btn-path").click();
if (!skill) return;
const chip = $$("#path-skill-pool .pool-chip").find((el) => el.dataset.skill === skill);
if (chip) {
chip.scrollIntoView({ block: "center", behavior: "smooth" });
chip.classList.add("flash");
setTimeout(() => chip.classList.remove("flash"), 1800);
}
});
}
/* 总分 → 自然语言结论(答辩口径:分数只是结论的辅助) */
function matchAdvice(score) {
if (score >= 80) return "强烈推荐投递:能力画像与目标岗位高度匹配,可优先安排面试。";
@@ -1307,24 +1492,37 @@ function renderMatchResult(m) {
$("#matched-chips").innerHTML =
[...m.matched_required.map((s) => chip(s, "tag-green")), ...m.matched_preferred.map((s) => chip(s, "tag-cyan"))].join("") ||
`<span class="text-weak text-aux">无命中(简历中未识别到本岗位所需技能)</span>`;
/* 缺口技能:超过 5 项默认折叠,避免清单压垮用户 */
/* 缺口技能:chips 可点击「去学习」(跳学习路径页并闪烁定位该技能);超过 5 项默认折叠 */
const learnChip = (s, cls) =>
`<span class="tag ${cls} miss-learn" data-skill="${esc(s)}" title="点击跳学习路径,专攻该技能">${esc(s)}</span>`;
const missing = [
...m.missing_required.map((s) => chip(s, "tag-red")),
...m.missing_preferred.map((s) => chip(s, "tag-orange")),
...m.missing_required.map((s) => learnChip(s, "tag-red")),
...m.missing_preferred.map((s) => learnChip(s, "tag-orange")),
];
const missSkills = [...m.missing_required, ...m.missing_preferred];
const missBox = $("#missing-chips");
const learnFoot = missing.length ? `<button class="link-btn" id="miss-learn-all">去学习 →</button>` : "";
const bindLearn = () => {
missBox.querySelectorAll(".miss-learn").forEach((el) => {
el.onclick = () => gotoLearnSkill(el.dataset.skill);
});
const all = $("#miss-learn-all");
if (all) all.onclick = () => gotoLearnSkill(missSkills[0]);
};
if (!missing.length) {
missBox.innerHTML = `<span class="text-weak text-aux">无</span>`;
} else if (missing.length <= 5) {
missBox.innerHTML = missing.join("");
missBox.innerHTML = missing.join("") + learnFoot;
} else {
const renderMiss = (expanded) => {
missBox.innerHTML = (expanded ? missing : missing.slice(0, 5)).join("") +
`<button class="link-btn" id="miss-toggle">${expanded ? "收起" : `展开全部(共 ${missing.length} 项)`}</button>`;
`<button class="link-btn" id="miss-toggle">${expanded ? "收起" : `展开全部(共 ${missing.length} 项)`}</button>` + learnFoot;
$("#miss-toggle").onclick = () => renderMiss(!expanded);
bindLearn();
};
renderMiss(false);
}
bindLearn();
$("#transfer-chips").innerHTML = m.transferable_skills.map((s) => chip(s, "tag-purple")).join("") ||
`<span class="text-weak text-aux">暂无可迁移技能</span>`;
// 声明掌握但与本岗位无关的技能:显式告知"不计入评分",避免用户疑惑
@@ -1435,19 +1633,28 @@ function drawGauge(canvas, score) {
ctx.lineWidth = 14; ctx.lineCap = "round";
ctx.strokeStyle = "#f0f1f5";
ctx.beginPath(); ctx.arc(cx, cy, R, Math.PI, 2 * Math.PI); ctx.stroke();
const color = score >= 80 ? "#00b42a" : score >= 60 ? "#2f54eb" : "#ff7d00";
const color = score >= 80 ? "#00b42a" : score >= 60 ? "#1890ff" : "#ff7d00";
ctx.strokeStyle = color;
ctx.beginPath(); ctx.arc(cx, cy, R, Math.PI, Math.PI * (1 + Math.min(100, score) / 100)); ctx.stroke();
$("#gauge-score").textContent = Math.round(score);
$("#gauge-score").style.color = color;
}
const RADAR_KEYS = ["required", "preferred", "experience", "scenario"];
const RADAR_LABELS = { required: "必备技能", preferred: "加分技能", experience: "经验年限", scenario: "场景经验" };
/* 雷达四维归一化(纯逻辑,node 可测):缺失键按 0,超界截断到 [0,1] */
function radarValues(dims) {
return RADAR_KEYS.map((k) => Math.max(0, Math.min(100, (dims || {})[k] || 0)) / 100);
}
/* 双系列雷达:实线=岗位基准(四维 100% 轮廓),虚线=候选人四维得分(图例见画布下方 DOM) */
function drawRadar(canvas, dims) {
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, 340, 280);
const cx = 170, cy = 145, R = 100;
const labels = { required: "必备技能", preferred: "加分技能", experience: "经验年限", scenario: "场景经验" };
const keys = Object.keys(labels);
const keys = RADAR_KEYS;
const labels = RADAR_LABELS;
const angle = (i) => -Math.PI / 2 + (i * 2 * Math.PI) / keys.length;
for (let ring = 1; ring <= 4; ring++) {
ctx.beginPath();
@@ -1462,20 +1669,31 @@ function drawRadar(canvas, dims) {
ctx.lineTo(cx + R * Math.cos(angle(i)), cy + R * Math.sin(angle(i)));
ctx.strokeStyle = "#f0f0f0"; ctx.stroke();
});
/* 岗位基准:四维 100% 实线轮廓 */
ctx.beginPath();
keys.forEach((k, i) => {
const r = (R * (dims[k] || 0)) / 100;
keys.forEach((_, i) => {
ctx[i ? "lineTo" : "moveTo"](cx + R * Math.cos(angle(i)), cy + R * Math.sin(angle(i)));
});
ctx.closePath();
ctx.strokeStyle = "#1890ff"; ctx.lineWidth = 2; ctx.stroke();
/* 候选人:虚线 + 面积填充 */
const vals = radarValues(dims);
ctx.beginPath();
keys.forEach((_, i) => {
const r = R * vals[i];
ctx[i ? "lineTo" : "moveTo"](cx + r * Math.cos(angle(i)), cy + r * Math.sin(angle(i)));
});
ctx.closePath();
ctx.fillStyle = "rgba(47,84,235,0.18)"; ctx.fill();
ctx.strokeStyle = "#2f54eb"; ctx.lineWidth = 2; ctx.stroke();
ctx.fillStyle = "rgba(19,194,194,0.15)"; ctx.fill();
ctx.setLineDash([5, 4]);
ctx.strokeStyle = "#13c2c2"; ctx.lineWidth = 2; ctx.stroke();
ctx.setLineDash([]);
keys.forEach((k, i) => {
const r = (R * (dims[k] || 0)) / 100;
ctx.fillStyle = "#2f54eb";
const r = R * vals[i];
ctx.fillStyle = "#13c2c2";
ctx.beginPath(); ctx.arc(cx + r * Math.cos(angle(i)), cy + r * Math.sin(angle(i)), 3.5, 0, 7); ctx.fill();
ctx.fillStyle = "#4e5969"; ctx.font = "12px sans-serif"; ctx.textAlign = "center";
ctx.fillText(`${labels[k]} ${Math.round(dims[k] || 0)}`,
ctx.fillText(`${labels[k]} ${Math.round((dims || {})[k] || 0)}`,
cx + (R + 36) * Math.cos(angle(i)), cy + (R + 22) * Math.sin(angle(i)) + 4);
});
}
@@ -2039,7 +2257,7 @@ async function openConfigModal(needToken, firstRun) {
? `<span class="tag tag-orange">离线回退</span>` : `<span class="tag tag-green">联网就绪</span>`;
openModal(firstRun ? "欢迎使用 CareerNexus 职联 · 初始设置" : "系统设置", `
${firstRun ? `<div class="cfg-welcome">
<b>首次使用,建议先完成基础配置</b>(均可稍后通过右上角 ⚙ 修改):
<b>首次使用,建议先完成基础配置</b>(均可稍后通过右上角设置按钮修改):
<br>· 配置 <b>LLM API 密钥</b>可启用大模型抽取(不配则离线确定性方案兜底)
<br>· 配置<b>语义向量模型</b>(本地 BGE-M3 或联网 API)可启用语义相似匹配
<br>· 需要对外提供服务时,再调整<b>监听地址与访问令牌</b>
@@ -2109,7 +2327,7 @@ async function openConfigModal(needToken, firstRun) {
</div>
<div id="cfg-embed-local" class="${cfg.embed.provider === "api" ? "hidden" : ""}">
<div class="setup-hero">
<button class="btn btn-primary" id="cfg-embed-setup">⚡ 一键下载并配置</button>
<button class="btn btn-primary" id="cfg-embed-setup">一键下载并配置</button>
<div class="setup-hero-desc">
自动完成全部配置,<b>推荐首次使用</b>:
<br>① 安装 sentence-transformers 依赖(PyPI 镜像站)
@@ -2290,7 +2508,7 @@ $("#btn-config").onclick = () => openConfigModal(false);
/* ================= 启动 ================= */
window.addEventListener("resize", () => {
if (CN._flowEvents) drawEventFlow($("#trend-canvas"), CN._flowEvents, 8);
if (CN._flowEvents) drawTrendLine($("#trend-canvas"), CN._flowEvents, 8);
drawHeat();
});
(async function boot() {
+84 -12
View File
@@ -458,7 +458,7 @@
ctx.save();
LEVEL_BANDS.forEach((b, i) => {
const sy = b.y * G.cam.k + G.cam.y;
ctx.fillStyle = i % 2 ? "rgba(47,84,235,0.025)" : "rgba(19,194,194,0.03)";
ctx.fillStyle = i % 2 ? "rgba(24,144,255,0.025)" : "rgba(19,194,194,0.03)";
ctx.fillRect(0, sy - 175 * G.cam.k, c.clientWidth, 350 * G.cam.k);
ctx.fillStyle = "rgba(78,89,105,0.85)";
ctx.font = "bold 13px sans-serif"; ctx.textAlign = "left";
@@ -481,10 +481,10 @@
const label = `${a.cat}(${n})`;
ctx.font = "bold 13px sans-serif";
const tw = ctx.measureText(label).width;
ctx.fillStyle = "rgba(47,84,235,0.08)";
ctx.strokeStyle = "rgba(47,84,235,0.35)";
ctx.fillStyle = "rgba(24,144,255,0.08)";
ctx.strokeStyle = "rgba(24,144,255,0.35)";
ctx.beginPath(); ctx.roundRect(p.x - tw / 2 - 10, p.y - 14, tw + 20, 24, 12); ctx.fill(); ctx.stroke();
ctx.fillStyle = "#2f54eb"; ctx.textAlign = "center";
ctx.fillStyle = "#1890ff"; ctx.textAlign = "center";
ctx.fillText(label, p.x, p.y + 3);
});
ctx.restore();
@@ -505,7 +505,7 @@
ctx.setLineDash([]);
ctx.lineWidth = isHot ? 1.4 : 0.8;
} else if (isReq) {
ctx.strokeStyle = dim ? "rgba(78,89,105,0.05)" : isHot ? "rgba(47,84,235,0.75)" : "rgba(78,89,105,0.28)";
ctx.strokeStyle = dim ? "rgba(78,89,105,0.05)" : isHot ? "rgba(24,144,255,0.75)" : "rgba(78,89,105,0.28)";
ctx.setLineDash([]);
ctx.lineWidth = isHot ? 1.8 : 1;
} else {
@@ -534,7 +534,7 @@
/* 关系标签(颜色 + 文字双重编码) */
if (G.showLabels && e.rel === "REQUIRES" && G.cam.k > 0.55 && !dim) {
ctx.font = "10px sans-serif"; ctx.textAlign = "center";
ctx.fillStyle = isReq ? "rgba(47,84,235,0.85)" : "rgba(9,151,156,0.9)";
ctx.fillStyle = isReq ? "rgba(24,144,255,0.85)" : "rgba(9,151,156,0.9)";
ctx.fillText(isReq ? "必备" : "加分", (pa.x + pb.x) / 2, (pa.y + pb.y) / 2 - 3);
}
ctx.restore();
@@ -550,7 +550,7 @@
const dim = sel && !isSel && !isHop;
const meta = TYPE_META[n.type] || { color: "#86909c" };
ctx.save();
ctx.globalAlpha = dim ? 0.15 : 1;
ctx.globalAlpha = dim ? 0.2 : 1;
ctx.fillStyle = meta.color;
if (n.type === "Skill") {
const s = r * 1.7;
@@ -568,7 +568,7 @@
ctx.strokeStyle = "#1d2129"; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(p.x, p.y, r + 4, 0, 7); ctx.stroke();
} else if (isHop) {
ctx.strokeStyle = "rgba(47,84,235,0.55)"; ctx.lineWidth = 1.5;
ctx.strokeStyle = "rgba(24,144,255,0.55)"; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(p.x, p.y, r + 2.5, 0, 7); ctx.stroke();
}
/* NEW 标记:待审核候选与近一年入库新岗位,节点右上方脉动圆点 + NEW 文字(canvas 绘制,alpha 呼吸复用 requestDraw 循环) */
@@ -582,7 +582,7 @@
ctx.beginPath(); ctx.arc(bx, by, 4, 0, 7); ctx.stroke();
ctx.font = "bold 9px sans-serif"; ctx.textAlign = "left";
ctx.fillText("NEW", bx + 6, by + 3);
ctx.globalAlpha = dim ? 0.15 : 1;
ctx.globalAlpha = dim ? 0.2 : 1;
G._newPulse = true; // 有 NEW 节点在画面上:持续请求重绘形成呼吸
}
/* 标签按需显示(文字重叠治理):岗位常显;技能/场景/行业仅在选中/悬停/选中节点一跳邻居时显示;
@@ -666,7 +666,7 @@
/* 视口框 */
const c = canvas();
const tl = toWorld(0, 0), br = toWorld(c.clientWidth, c.clientHeight);
ctx.strokeStyle = "#2f54eb"; ctx.lineWidth = 1.4;
ctx.strokeStyle = "#1890ff"; ctx.lineWidth = 1.4;
ctx.strokeRect(tl.x * k + ox, tl.y * k + oy, (br.x - tl.x) * k, (br.y - tl.y) * k);
mm._proj = { k, ox, oy };
}
@@ -736,6 +736,15 @@
const node = pickNode(e.clientX - rect.left, e.clientY - rect.top);
selectNode(node ? node.id : null);
});
/* 节点右键菜单:选中节点 + 弹出自定义菜单(查看证据 / 时点对比 / 人岗诊断 / 子图开关) */
c.addEventListener("contextmenu", (e) => {
e.preventDefault();
const rect = c.getBoundingClientRect();
const node = pickNode(e.clientX - rect.left, e.clientY - rect.top);
if (!node) { hideCtxMenu(); return; }
if (G.selected !== node.id) selectNode(node.id);
showCtxMenu(node, e.clientX, e.clientY);
});
c.addEventListener("wheel", (e) => {
e.preventDefault();
const rect = c.getBoundingClientRect();
@@ -744,8 +753,71 @@
window.addEventListener("resize", () => { requestDraw(); drawMinimap(); });
}
/* ================= 节点右键菜单(CSS 定位 DOM,点空白 / Esc 关闭) ================= */
function hideCtxMenu() {
const m = $("#ctx-menu");
if (m) m.classList.add("hidden");
}
function showCtxMenu(n, clientX, clientY) {
const menu = $("#ctx-menu");
const wrap = $("#graph-canvas-wrap");
const wr = wrap.getBoundingClientRect();
const isJob = n.type === "Job" && !(n.pending || n.status === "pending");
const items = [
{ key: "evidence", label: "查看证据 JD 原文" },
{ key: "compare", label: "能力画像时点对比", disabled: !isJob },
{ key: "match", label: "开始人岗诊断", disabled: !isJob },
{ key: "solo", label: G.soloJob === n.id ? "退出岗位子图" : "岗位子图开关", disabled: !isJob },
];
menu.innerHTML = `<div class="ctx-title">${esc(n.title || n.id)}</div>` + items.map((it) =>
`<button class="ctx-item" data-key="${it.key}"${it.disabled ? " disabled" : ""}>${it.label}</button>`).join("");
menu.classList.remove("hidden");
/* 先渲染量尺寸,再定位并防止溢出画布容器 */
let x = clientX - wr.left, y = clientY - wr.top;
x = Math.max(4, Math.min(x, wr.width - menu.offsetWidth - 4));
y = Math.max(4, Math.min(y, wr.height - menu.offsetHeight - 4));
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
menu.querySelectorAll(".ctx-item:not(:disabled)").forEach((b) => {
b.onclick = () => { hideCtxMenu(); ctxAction(b.dataset.key, n); };
});
}
function ctxAction(key, n) {
if (key === "evidence") {
const sources = new Set();
G.edges.forEach((e) => {
if (e.source === n.id || e.target === n.id) (e.sources || []).forEach((s) => sources.add(s));
});
const list = [...sources];
if (!list.length) return toast("该节点暂无来源证据记录", "info");
CN.showJdEvidence(list, { jobTitle: n.title || n.id });
} else if (key === "compare") {
CN.switchView("evolution");
$("#evo-job-select").value = n.id;
$("#evo-job-select").dispatchEvent(new Event("change"));
setTimeout(() => { const d = $("#evo-diff"); if (d) d.scrollIntoView({ block: "center" }); }, 450);
} else if (key === "match") {
CN.switchView("match");
$("#match-job-select").value = n.id;
$("#match-job-select").dispatchEvent(new Event("change"));
toast("已带入目标岗位,请上传或载入示例简历", "success");
} else if (key === "solo") {
G.soloJob = G.soloJob === n.id ? null : n.id;
G._defaultApplied = true;
updateSoloBadge();
updateRecentChange();
if (G.soloJob) {
applySoloLayout();
fitSoloView();
toast(`已进入「${n.title || n.id}」岗位子图(同心分层:中心岗位 → 必备 → 加分 → 场景/行业)`, "success");
} else fitView();
}
}
/* ================= 工具条 / 筛选 ================= */
function bindToolbar() {
document.addEventListener("click", (e) => { if (!e.target.closest("#ctx-menu")) hideCtxMenu(); });
document.addEventListener("keydown", (e) => { if (e.key === "Escape") hideCtxMenu(); });
$("#tool-zoom-in").onclick = () => zoomAt(1.25);
$("#tool-zoom-out").onclick = () => zoomAt(0.8);
$("#tool-fit").onclick = fitView;
@@ -903,7 +975,7 @@
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}|${e.skill}|${e.window}`, nodeId: e.job_id,
color: "#1890ff", 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)} · ${(e.source_ids || []).length} 条 JD`,
}));
@@ -1328,7 +1400,7 @@
values.forEach((v, i) => {
const bw = c.width / values.length;
const h = (v / max) * (c.height - 16);
ctx.fillStyle = "#2f54eb";
ctx.fillStyle = "#1890ff";
ctx.fillRect(i * bw + 2, c.height - h - 12, bw - 4, h);
if (i % 3 === 0) {
ctx.fillStyle = "#86909c"; ctx.font = "9px sans-serif"; ctx.textAlign = "center";
+49 -16
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CareerNexus 职联 · 岗位与能力图谱平台</title>
<link rel="stylesheet" href="/static/style.css?v=20260819">
<link rel="stylesheet" href="/static/style.css?v=20260827">
</head>
<body>
<!-- ============ 顶部栏 56px ============ -->
@@ -81,6 +81,9 @@
<!-- ======== 人才能力变化指挥台(数据驾驶舱) ======== -->
<section id="view-dashboard" class="view active">
<!-- Hero 指标区(紧贴导航):有效 JD / 独立企业 / 技能演化事件 / 人工复核率,数字滚动 -->
<div class="hero-kpis" id="metric-cards"></div>
<!-- 弱化的结论行:本期最重要变化一句话 + 图谱规模小字 -->
<div class="card cmd-hero">
<div class="cmd-hero-main">
<h2 id="cmd-title">人才能力变化概览</h2>
@@ -93,18 +96,37 @@
<button class="btn btn-secondary btn-sm" id="btn-import-jd">导入 JD 数据</button>
</div>
</div>
<div class="metric-grid cmd-kpis" id="metric-cards"></div>
<div class="grid-2">
<!-- 核心可视化区:左趋势折线 / 右岗位能力更替,两图等高占主视觉 -->
<div class="grid-2 cmd-main">
<div class="card">
<div class="card-head">
<h3>近 8 季度变化事件流(全岗位)</h3>
<span class="text-weak text-aux">点击柱段进入该季度演化详情</span>
<h3>人才能力变化趋势</h3>
<span class="text-weak text-aux">近 8 季度 · 悬停看明细 · 点击定位岗位</span>
</div>
<div class="card-body">
<canvas id="trend-canvas" height="220"></canvas>
<div class="card-body trend-wrap">
<canvas id="trend-canvas" height="240"></canvas>
<div class="trend-tip hidden" id="trend-tip"></div>
<div class="stack-legend" id="cmd-stack-legend"></div>
</div>
</div>
<div class="card">
<div class="card-head">
<h3>岗位能力更替(全岗位 · 近 4 季度)</h3>
<span class="text-weak text-aux">旧核心 → 持续 → 新核心</span>
</div>
<div class="card-body" id="cmd-replace"><div class="empty small">统计中…</div></div>
</div>
</div>
<!-- 变化播报:卡片翻页轮播(3 秒淡入淡出,悬停暂停,点击跳演化页) -->
<div class="card cmd-bcast-card">
<div class="card-head">
<h3>变化播报</h3>
<span class="text-weak text-aux">自动轮播 · 悬停暂停 · 点击跳转演化详情</span>
</div>
<div class="card-body"><div class="bcast-carousel" id="activity-feed"></div></div>
</div>
<!-- 下移一层:可信度 + 待办行动 -->
<div class="grid-2">
<div class="card">
<div class="card-head">
<h3>数据可信度</h3>
@@ -112,12 +134,6 @@
</div>
<div class="card-body" id="trust-card"><div class="empty small">统计中…</div></div>
</div>
</div>
<div class="grid-2">
<div class="card">
<div class="card-head"><h3>变化播报</h3><span class="text-weak text-aux">点击跳转演化详情</span></div>
<div class="card-body feed" id="activity-feed"></div>
</div>
<div class="card">
<div class="card-head">
<h3>待办行动</h3>
@@ -266,8 +282,21 @@
<div class="gc-chips" id="gc-chips"><span class="text-weak text-aux">—</span></div>
</div>
<!-- 节点右键菜单(CSS 定位 DOM,点空白关闭) -->
<div class="ctx-menu hidden" id="ctx-menu"></div>
<!-- 图谱加载骨架屏:节点/连线占位 + shimmer 扫光,布局完成即消失 -->
<div class="graph-loading" id="graph-loading">
<div class="skeleton-graph"></div>
<div class="sk-graph" aria-hidden="true">
<span class="sk-edge sk-e1"></span><span class="sk-edge sk-e2"></span>
<span class="sk-edge sk-e3"></span><span class="sk-edge sk-e4"></span>
<span class="sk-edge sk-e5"></span><span class="sk-edge sk-e6"></span>
<span class="sk-node sk-center"></span>
<span class="sk-node sk-n1"></span><span class="sk-node sk-n2"></span>
<span class="sk-node sk-n3"></span><span class="sk-node sk-n4"></span>
<span class="sk-node sk-sq sk-n5"></span><span class="sk-node sk-sq sk-n6"></span>
<span class="sk-node sk-sq sk-n7"></span>
</div>
<div class="text-weak">图谱布局计算中…</div>
</div>
</div>
@@ -466,6 +495,10 @@
<div class="match-verdict" id="match-verdict"></div>
<div class="match-advice" id="match-advice"></div>
<canvas id="radar-canvas" width="340" height="280"></canvas>
<div class="radar-legend">
<span><i class="rl-line"></i>岗位基准(四维 100%)</span>
<span><i class="rl-line dashed"></i>候选人得分</span>
</div>
</div>
<div class="mrg-right">
<div class="hit-miss">
@@ -631,7 +664,7 @@
<button class="btn btn-text btn-sm" id="tour-exit">退出</button>
</div>
<script src="/static/app.js?v=20260819"></script>
<script src="/static/graph.js?v=20260819"></script>
<script src="/static/app.js?v=20260827"></script>
<script src="/static/graph.js?v=20260827"></script>
</body>
</html>
+131 -45
View File
@@ -4,9 +4,9 @@
============================================================ */
:root {
/* 品牌色 */
--primary: #2f54eb;
--primary-hover: #1d39c4;
--primary-light: #f0f5ff;
--primary: #1890ff;
--primary-hover: #096dd9;
--primary-light: #e6f7ff;
--success: #00b42a;
--success-light: #e8ffea;
--warning: #ff7d00;
@@ -41,6 +41,7 @@ html, body { height: 100%; }
body {
font-family: var(--font);
font-size: 14px;
line-height: 1.6;
color: var(--text-1);
background: var(--bg-page);
overflow: hidden;
@@ -80,7 +81,7 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width:
.brand { display: flex; align-items: center; gap: 8px; }
.brand-logo {
width: 30px; height: 30px; border-radius: 7px;
background: linear-gradient(135deg, #2f54eb, #13c2c2);
background: linear-gradient(135deg, #1890ff, #13c2c2);
display: inline-flex; align-items: center; justify-content: center;
}
.brand-logo svg { width: 19px; height: 19px; color: #fff; }
@@ -88,7 +89,7 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width:
.brand-name b { font-weight: 600; }
.page-title {
position: absolute; left: 50%; transform: translateX(-50%);
font-size: 15px; font-weight: 600; color: var(--text-1);
font-size: 20px; font-weight: 600; color: var(--text-1);
}
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 14px; }
.global-search {
@@ -134,7 +135,7 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width:
font-size: 12px; line-height: 20px; padding: 0 8px;
border-radius: var(--radius-sm); border: 1px solid transparent; white-space: nowrap;
}
.tag-blue { color: var(--primary); background: var(--primary-light); border-color: #d6e4ff; }
.tag-blue { color: var(--primary); background: var(--primary-light); border-color: #91d5ff; }
.tag-green { color: var(--success); background: var(--success-light); border-color: #b7eb8f; }
.tag-orange { color: var(--warning); background: var(--warning-light); border-color: #ffd591; }
.tag-red { color: var(--danger); background: var(--danger-light); border-color: #ffa39e; }
@@ -194,7 +195,7 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width:
display: flex; align-items: center; justify-content: space-between;
padding: 14px 16px; border-bottom: 1px solid var(--border);
}
.card-head h3 { font-size: 15px; font-weight: 600; }
.card-head h3 { font-size: 18px; font-weight: 600; }
.card-body { padding: 16px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px; }
@@ -225,7 +226,7 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width:
padding: 6px 10px; outline: none; background: #fff;
transition: border-color 0.15s;
}
.input:focus, .select:focus, .textarea:focus { border-color: var(--primary); box-shadow: 0 0 0 2px rgba(47, 84, 235, 0.1); }
.input:focus, .select:focus, .textarea:focus { border-color: var(--primary); box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1); }
.textarea { width: 100%; resize: vertical; line-height: 1.6; }
.select { height: 32px; }
.form-row { margin-bottom: 16px; }
@@ -246,30 +247,35 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width:
.empty svg { width: 44px; height: 44px; stroke-width: 1.2; margin-bottom: 10px; opacity: 0.5; }
.empty p { font-size: 13px; line-height: 1.8; }
.empty.small { padding: 20px; font-size: 13px; }
.skeleton-graph {
width: 240px; height: 240px; border-radius: 50%;
background: radial-gradient(circle, #e8ebf0 20%, transparent 21%),
conic-gradient(#e8ebf0, #f5f6f8, #e8ebf0);
animation: pulse 1.4s ease-in-out infinite;
.empty.small svg { width: 30px; height: 30px; }
/* 图谱加载骨架屏:节点/连线占位形状 + shimmer 扫光 */
.sk-graph { position: relative; width: 280px; height: 240px; overflow: hidden; border-radius: var(--radius); }
.sk-node { position: absolute; border-radius: 50%; background: #e8ebf0; }
.sk-node.sk-sq { border-radius: 5px; }
.sk-center { left: 126px; top: 100px; width: 28px; height: 28px; }
.sk-n1 { left: 30px; top: 30px; width: 18px; height: 18px; }
.sk-n2 { left: 216px; top: 24px; width: 18px; height: 18px; }
.sk-n3 { left: 24px; top: 178px; width: 18px; height: 18px; }
.sk-n4 { left: 222px; top: 184px; width: 18px; height: 18px; }
.sk-n5 { left: 80px; top: 196px; width: 16px; height: 16px; }
.sk-n6 { left: 186px; top: 78px; width: 16px; height: 16px; }
.sk-n7 { left: 60px; top: 92px; width: 16px; height: 16px; }
.sk-edge { position: absolute; height: 3px; border-radius: 2px; background: #eef0f4; transform-origin: left center; }
.sk-e1 { left: 46px; top: 42px; width: 100px; transform: rotate(38deg); }
.sk-e2 { left: 152px; top: 106px; width: 84px; transform: rotate(-32deg); }
.sk-e3 { left: 42px; top: 186px; width: 106px; transform: rotate(-38deg); }
.sk-e4 { left: 152px; top: 122px; width: 92px; transform: rotate(40deg); }
.sk-e5 { left: 96px; top: 200px; width: 56px; transform: rotate(-58deg); }
.sk-e6 { left: 76px; top: 96px; width: 56px; transform: rotate(6deg); }
.sk-graph::after {
content: ""; position: absolute; inset: 0;
background: linear-gradient(100deg, transparent 30%, rgba(255, 255, 255, 0.8) 50%, transparent 70%);
animation: sk-sweep 1.6s linear infinite;
}
@keyframes pulse { 50% { opacity: 0.5; } }
@keyframes sk-sweep { from { transform: translateX(-100%); } to { transform: translateX(100%); } }
/* ============ 数据驾驶舱 ============ */
.metric-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; }
.metric-card {
background: #fff; border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: var(--shadow-card); padding: 16px; cursor: pointer;
transition: all 0.15s; position: relative; overflow: hidden;
}
.metric-card:hover { border-color: var(--primary); box-shadow: var(--shadow-pop); transform: translateY(-2px); }
.metric-card .mc-label { font-size: 13px; color: var(--text-2); display: flex; align-items: center; gap: 6px; }
.metric-card .mc-label svg { width: 15px; height: 15px; color: var(--primary); }
.metric-card .mc-num { font-size: 26px; font-weight: 600; font-variant-numeric: tabular-nums; margin: 6px 0 4px; }
.metric-card .mc-delta { font-size: 12px; display: inline-flex; align-items: center; gap: 3px; }
.metric-card .mc-delta.up { color: var(--success); }
.metric-card .mc-delta.down { color: var(--danger); }
.metric-card .mc-delta.flat { color: var(--text-3); }
.metric-card canvas { position: absolute; right: 10px; bottom: 10px; opacity: 0.9; }
/* 首屏 Hero 指标卡样式见文末「2026-08 前端体验升级专项」.hero-kpis */
.feed { max-height: 320px; overflow-y: auto; }
.feed-item {
@@ -301,7 +307,7 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width:
padding: 12px 16px; margin-bottom: 12px;
}
.graph-title { display: flex; align-items: center; gap: 10px; }
.graph-title h2 { font-size: 18px; font-weight: 600; }
.graph-title h2 { font-size: 20px; font-weight: 600; }
.graph-actions { display: flex; gap: 8px; }
.graph-view-tabs { margin: 0 auto; }
@@ -404,7 +410,7 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width:
.gc-chips { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; max-height: 62px; overflow-y: auto; }
.chain-chip {
display: inline-flex; align-items: center; gap: 4px;
border: 1px solid #b7c6f5; background: var(--primary-light); color: var(--primary);
border: 1px solid #91d5ff; background: var(--primary-light); color: var(--primary);
font-size: 12px; border-radius: 12px; padding: 2px 10px; cursor: pointer;
}
.chain-chip:hover { background: var(--primary); color: #fff; }
@@ -450,7 +456,7 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width:
font-size: 13px; padding: 6px 8px; border-radius: var(--radius-sm);
cursor: pointer; border: 1px solid transparent;
}
.rel-item:hover { background: var(--primary-light); border-color: #d6e4ff; }
.rel-item:hover { background: var(--primary-light); border-color: #91d5ff; }
.rel-item .ri-kind { flex: none; }
.rel-item .ri-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rel-item .ri-extra { font-size: 12px; color: var(--text-3); flex: none; }
@@ -521,7 +527,7 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width:
.chip-editor { display: flex; flex-wrap: wrap; gap: 8px; min-height: 40px; border: 1px dashed var(--border-dark); border-radius: var(--radius); padding: 10px; }
.skill-chip {
display: inline-flex; align-items: center; gap: 5px;
background: var(--primary-light); color: var(--primary); border: 1px solid #d6e4ff;
background: var(--primary-light); color: var(--primary); border: 1px solid #91d5ff;
font-size: 13px; border-radius: var(--radius-sm); padding: 3px 6px 3px 10px;
}
.skill-chip button { border: none; background: none; color: inherit; font-size: 14px; line-height: 1; padding: 0 2px; }
@@ -733,7 +739,7 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width:
}
.toast.success { border-color: #b7eb8f; color: var(--success); }
.toast.error { border-color: #ffa39e; color: var(--danger); }
.toast.info { border-color: #d6e4ff; color: var(--primary); }
.toast.info { border-color: #91d5ff; color: var(--primary); }
@keyframes toast-in { from { opacity: 0; transform: translateY(-8px); } }
/* 滚动条 */
@@ -924,7 +930,7 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width:
/* 一键配置主按钮区 */
.setup-hero {
border: 1px solid #d6e4ff; background: var(--primary-light);
border: 1px solid #91d5ff; background: var(--primary-light);
border-radius: var(--radius); padding: 14px 16px; margin-bottom: 14px;
}
.setup-hero .btn { width: 100%; }
@@ -1074,17 +1080,15 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width:
/* ============ 2026Q3 改版:指挥台 / 审核台 / 证据地图 / 生命线 / 路线图 ============ */
/* 指挥台首屏 */
/* 指挥台首屏(弱化:结论行降级为普通卡片,主视觉让给 Hero 指标区与趋势图) */
.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%);
padding: 12px 20px; margin-bottom: 16px;
}
.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-main h2 { font-size: 20px; font-weight: 600; }
.cmd-sub { font-size: 13px; color: var(--text-2); margin-top: 4px; }
.cmd-scale { font-size: 12px; margin-top: 4px; }
.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; }
@@ -1128,7 +1132,7 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width:
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); } }
@keyframes cand-flash { 0%, 60% { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(24, 144, 255, 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; }
@@ -1210,8 +1214,17 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width:
}
.path-demo-banner .btn { margin-left: auto; }
/* 驾驶舱事件流主图限高 */
#trend-canvas { max-height: 260px; }
/* 驾驶舱趋势图:等高布局 + hover tooltip */
.cmd-main { align-items: stretch; }
.cmd-main .card { display: flex; flex-direction: column; }
.cmd-main .card-body { flex: 1; display: flex; flex-direction: column; }
.trend-wrap { position: relative; }
#trend-canvas { flex: 1; min-height: 240px; max-height: 300px; }
.trend-tip {
position: absolute; z-index: 5; pointer-events: none;
background: rgba(29, 33, 41, 0.92); color: #fff; font-size: 12px;
border-radius: 6px; padding: 6px 10px; line-height: 1.6; white-space: nowrap;
}
/* 演化页图表说明文字 */
.evo-chart-desc { font-size: 12px; color: var(--text-3); line-height: 1.7; margin: -2px 0 8px; }
@@ -1227,3 +1240,76 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width:
.scan-track { flex: 1; display: flex; height: 14px; background: var(--bg-page); border-radius: 3px; overflow: hidden; }
.scan-bar { display: block; height: 100%; }
.scan-num { flex: none; width: 34px; font-size: 12px; color: var(--text-2); font-variant-numeric: tabular-nums; }
/* ============ 2026-08 前端体验升级专项 ============ */
/* 驾驶舱 Hero 指标区:浅色渐变高亮卡 + count-up 数字 */
.hero-kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 14px; }
.hero-kpi {
background: linear-gradient(135deg, var(--primary-light) 0%, #ffffff 62%);
border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: var(--shadow-card); padding: 16px 18px;
transition: all 0.15s;
}
.hero-kpi.clickable { cursor: pointer; }
.hero-kpi.clickable:hover { border-color: var(--primary); box-shadow: var(--shadow-pop); transform: translateY(-2px); }
.hk-label { font-size: 13px; color: var(--text-2); display: flex; align-items: center; gap: 6px; }
.hk-label svg { width: 15px; height: 15px; color: var(--primary); }
.hk-num {
font-size: 30px; font-weight: 700; color: var(--primary);
font-variant-numeric: tabular-nums; margin: 6px 0 2px; line-height: 1.2;
}
.hk-sub { font-size: 12px; color: var(--text-3); }
/* 变化播报:卡片翻页轮播(淡入淡出,非滚动) */
.cmd-bcast-card { margin-top: 16px; }
.bcast-stage { position: relative; height: 68px; }
.bcast-card {
position: absolute; inset: 0;
opacity: 0; transition: opacity 0.45s ease;
pointer-events: none;
}
.bcast-card.on { opacity: 1; pointer-events: auto; }
.bcast-dots { display: flex; gap: 6px; justify-content: center; margin-top: 8px; }
.bcast-dots i {
width: 6px; height: 6px; border-radius: 50%;
background: var(--border-dark); cursor: pointer; transition: background 0.2s;
}
.bcast-dots i.on { background: var(--primary); }
/* 图谱节点右键菜单 */
.ctx-menu {
position: absolute; z-index: 40; min-width: 176px;
background: #fff; border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: var(--shadow-pop); padding: 4px;
}
.ctx-title {
font-size: 12px; color: var(--text-3); padding: 6px 10px 5px;
border-bottom: 1px dashed var(--border); margin-bottom: 4px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 220px;
}
.ctx-item {
display: block; width: 100%; text-align: left;
border: none; background: none; padding: 7px 10px;
font-size: 13px; color: var(--text-1); border-radius: var(--radius-sm); cursor: pointer;
}
.ctx-item:hover { background: var(--primary-light); color: var(--primary); }
.ctx-item.disabled, .ctx-item:disabled { color: var(--text-3); cursor: not-allowed; opacity: 0.6; }
.ctx-item.disabled:hover, .ctx-item:disabled:hover { background: none; color: var(--text-3); }
/* 匹配页双雷达图例 */
.radar-legend {
display: flex; gap: 18px; justify-content: center; margin-top: 6px;
font-size: 12px; color: var(--text-2);
}
.radar-legend span { display: inline-flex; align-items: center; gap: 6px; }
.rl-line { width: 18px; height: 0; display: inline-block; border-top: 2px solid var(--primary); }
.rl-line.dashed { border-top-style: dashed; border-top-color: var(--cyan); }
/* 缺失技能「去学习」:chips 可点击 + 学习路径页目标技能闪烁定位 */
.hm-chips .tag.miss-learn { cursor: pointer; }
.hm-chips .tag.miss-learn:hover { filter: brightness(0.94); box-shadow: 0 0 0 2px var(--primary-light); }
.pool-chip.flash { animation: cand-flash 1.6s ease; }
/* 驾驶舱更替卡紧凑化 */
.cmd-rp { grid-template-columns: 1fr 20px 1fr 20px 1fr; }