diff --git a/app/api/routes.py b/app/api/routes.py index 145cb39..bc4f8c4 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -93,6 +93,7 @@ def health() -> dict: "llm_ready": ready, "offline_fallback": not ready, "embed": semantic.status(_cfg), "guided": bool(_cfg.get("guided")), + "demo_mode": bool(_cfg.get("demo_mode", True)), "mode": "online" if ready else "offline"}) @@ -221,6 +222,7 @@ class ConfigBody(BaseModel): embed_api_base_url: str | None = None embed_api_model: str | None = None guided: bool | None = None # 初始设置引导完成标记 + demo_mode: bool | None = None # 竞赛演示模式开关 mode: str | None = None # demo | live @@ -243,6 +245,7 @@ def _public_config() -> dict: }, "embed": semantic.status(_cfg), "mode": str(_cfg.get("mode", "demo")), + "demo_mode": bool(_cfg.get("demo_mode", True)), "offline_fallback": not ready, } @@ -290,15 +293,17 @@ def save_config(body: ConfigBody) -> dict: updates["embed_api_model"] = body.embed_api_model.strip() if body.guided is not None: updates["guided"] = bool(body.guided) + if body.demo_mode is not None: + updates["demo_mode"] = bool(body.demo_mode) save_runtime_settings(updates) # 热生效:令牌、模式与 LLM/语义模型配置立即更新到运行配置(下次调用即读取) for k in ("access_token", "mode", "llm_api_key", "llm_base_url", "llm_model", "embed_model_path", "embed_provider", "embed_api_key", - "embed_api_base_url", "embed_api_model", "guided"): + "embed_api_base_url", "embed_api_model", "guided", "demo_mode"): if k in updates: - if updates[k]: - _cfg[k] = updates[k] + if updates[k] or isinstance(updates[k], bool): + _cfg[k] = updates[k] # 布尔 False 也要写入(如关闭演示模式) else: _cfg.pop(k, None) logger.info("运行时设置已保存到 config.json: %s", sorted(updates)) diff --git a/app/config.py b/app/config.py index 8ad23d6..13df7ec 100644 --- a/app/config.py +++ b/app/config.py @@ -17,11 +17,12 @@ RUNTIME_KEYS = { "llm_api_key", "llm_base_url", "llm_model", "llm_timeout", "embed_model_path", "embed_provider", "embed_api_key", "embed_api_base_url", "embed_api_model", - "guided", "jd_extra_dirs", "passive_sources", + "guided", "jd_extra_dirs", "passive_sources", "demo_mode", } DEFAULTS: dict = { "mode": "demo", # demo | live + "demo_mode": True, # 竞赛演示模式:不弹初始设置、警告降级为状态点 "host": "127.0.0.1", "port": 9008, "data_dir": "data", diff --git a/webui/app.js b/webui/app.js index c9d4b90..e685f7a 100644 --- a/webui/app.js +++ b/webui/app.js @@ -255,7 +255,7 @@ async function loadDashboard() { CN.graphData = graph; const s = health.graph || {}; updateBanners(health); - $("#data-version").textContent = `数据版本 v${(graph.events.at(-1) || {}).window || "demo"} · ${s.nodes ?? 0} 节点`; + $("#data-version").textContent = `数据更新至 ${(graph.events.at(-1) || {}).window || "2026Q3"}(模拟)`; /* 指标卡 */ const series = quarterlyCounts(graph.events || []); @@ -1421,21 +1421,31 @@ $("#btn-export-path").onclick = () => { toast("已导出文本", "success"); }; -/* ================= 系统设置 / 状态横幅 ================= */ -let lastHealth = null; // 最近一次 health 响应(横幅状态源) +/* ================= 系统设置 / 状态横幅 / 演示模式 ================= */ +let lastHealth = null; // 最近一次 health 响应(横幅与状态徽标的数据源) function updateBanners(health) { if (health) lastHealth = health; const h = lastHealth || {}; - const showOffline = !!h.offline_fallback - && sessionStorage.getItem("cn_banner_dismissed") !== "1"; + const demo = h.demo_mode !== false; const embed = h.embed || {}; - const showEmbed = !embed.ready + const degraded = !!h.offline_fallback || !embed.ready; + // 演示模式:警告横幅降级为顶栏状态徽标(首屏不出现负面字眼) + const showOffline = !demo && !!h.offline_fallback + && sessionStorage.getItem("cn_banner_dismissed") !== "1"; + const showEmbed = !demo && !embed.ready && sessionStorage.getItem("cn_banner2_dismissed") !== "1"; $("#offline-banner").classList.toggle("hidden", !showOffline); $("#embed-banner").classList.toggle("hidden", !showEmbed); const n = (showOffline ? 1 : 0) + (showEmbed ? 1 : 0); document.body.classList.toggle("has-banner", n === 1); document.body.classList.toggle("has-banner2", n === 2); + // 顶栏状态徽标:绿=全就绪,黄=离线回退/语义未就绪 + const badge = $("#demo-badge"); + badge.classList.toggle("warn", degraded); + $("#demo-badge-text").textContent = degraded ? "离线演示" : (demo ? "演示模式" : "在线"); + badge.title = degraded + ? "离线回退中(LLM/语义模型未配置),点击查看设置" + : "全部通道就绪,点击查看设置"; } function dismissBanner(id, key) { sessionStorage.setItem(key, "1"); @@ -1445,6 +1455,44 @@ $("#banner-close").onclick = () => dismissBanner("offline-banner", "cn_banner_di $("#embed-banner-close").onclick = () => dismissBanner("embed-banner", "cn_banner2_dismissed"); $("#banner-config").onclick = () => openConfigModal(false); $("#embed-banner-config").onclick = () => openConfigModal(false); +$("#demo-badge").onclick = () => openConfigModal(false); + +/* ================= 演示导览(预置路线,一键串起完整闭环) ================= */ +const DEMO_STEPS = [ + { title: "① 数据驾驶舱:多源数据总览", desc: "JD 规模、岗位/技能/事件总量、演化趋势、清洗与审核状态", + go: () => switchView("dashboard") }, + { title: "② 新岗位发现:候选→审核→入图", desc: "图谱定位「大模型应用工程师」:四指标证据、人工确认、证据 JD 回查", + go: () => { switchView("graph"); if (CN.gotoNode) CN.gotoNode("job_大模型应用工程师"); } }, + { title: "③ 既有岗位演化:Java 编年史", desc: "时点对比、变更堆叠图、生命周期甘特、事件证据链", + go: () => { + switchView("evolution"); + $("#evo-job-select").value = "job_Java开发工程师"; + $("#evo-job-select").dispatchEvent(new Event("change")); + } }, + { title: "④ 人岗匹配诊断 + 学习路径", desc: "载入示例简历 → 确认解析 → 总分/四维/差距清单 → 一键生成学习路径", + go: () => { + switchView("match"); + $$(".match-tab").forEach((x) => x.classList.toggle("active", x.dataset.mtab === "single")); + $("#match-single").classList.remove("hidden"); + $("#match-batch").classList.add("hidden"); + $("#btn-load-sample").click(); + } }, +]; +$("#btn-demo-tour").onclick = () => { + openModal("演示导览(预置路线)", ` +
按预置路线依次演示完整闭环,每步点击「前往」自动跳转到位:
+ ${DEMO_STEPS.map((s, i) => ` +
+
+
${esc(s.title)}
+
${esc(s.desc)}
+
+ +
`).join("")}`); + $$("#modal-body [data-step]").forEach((b) => { + b.onclick = () => { closeModal(); DEMO_STEPS[+b.dataset.step].go(); }; + }); +}; function embedStatusHtml(emb) { if (emb.ready) { @@ -1506,6 +1554,13 @@ async function openConfigModal(needToken, firstRun) { +
+ +
+ + +
+
LLM API(星火 / OpenAI 兼容,保存即热生效)
@@ -1584,7 +1639,8 @@ async function openConfigModal(needToken, firstRun) {
- `, + +
如遇问题,请联系 support@rrrosmontis.icu
`, `${firstRun ? `` : ""} `); @@ -1598,6 +1654,14 @@ async function openConfigModal(needToken, firstRun) { }; }); let embedProvider = cfg.embed.provider === "api" ? "api" : "local"; + let demoMode = cfg.demo_mode !== false; + $$("#cfg-demo .seg-item").forEach((b) => { + b.onclick = () => { + $$("#cfg-demo .seg-item").forEach((x) => x.classList.remove("active")); + b.classList.add("active"); + demoMode = b.dataset.demo === "1"; + }; + }); $$("#cfg-embed-provider .seg-item").forEach((b) => { b.onclick = () => { $$("#cfg-embed-provider .seg-item").forEach((x) => x.classList.remove("active")); @@ -1629,6 +1693,7 @@ async function openConfigModal(needToken, firstRun) { embed_model_path: $("#cfg-embed-path").value.trim(), embed_api_base_url: $("#cfg-embed-base").value.trim(), embed_api_model: $("#cfg-embed-model").value.trim(), + demo_mode: demoMode, guided: true, // 保存即视为完成初始设置 }; const tok = $("#cfg-token").value; @@ -1643,7 +1708,7 @@ async function openConfigModal(needToken, firstRun) { else localStorage.removeItem("cn_access_token"); } lastHealth = Object.assign({}, lastHealth, { - offline_fallback: saved.offline_fallback, embed: saved.embed, + offline_fallback: saved.offline_fallback, embed: saved.embed, demo_mode: saved.demo_mode, }); updateBanners(); return saved; @@ -1716,6 +1781,8 @@ window.addEventListener("resize", () => { CN.jobs = await api("/api/jobs"); const opts = CN.jobs.map((j) => ``).join(""); ["#evo-job-select", "#match-job-select", "#path-job-select", "#batch-job-select"].forEach((s) => { $(s).innerHTML = opts; }); + $("#evo-job-select").value = "job_Java开发工程师"; // 演化页默认锁定最强样例 + $("#match-job-select").value = "job_Java开发工程师"; await loadDashboard(); // 先加载图谱缓存(演化页甘特图依赖) await loadEvolution(); renderEvoHeatmap(); @@ -1723,8 +1790,10 @@ window.addEventListener("resize", () => { const hash = new URLSearchParams(location.hash.slice(1)); if (hash.get("view")) switchView(hash.get("view")); if (hash.get("node") && CN.gotoNode) CN.gotoNode(hash.get("node")); - /* 首次使用:引导完成基础设置(完成标记记录在服务端 config.json) */ - if (!lastHealth || !lastHealth.guided) openConfigModal(false, true); + /* 首次使用:演示模式下不弹初始设置(评审口径),非演示模式才引导 */ + if ((!lastHealth || !lastHealth.guided) && lastHealth && lastHealth.demo_mode === false) { + openConfigModal(false, true); + } } catch (e) { toast("后端连接失败:" + e.message, "error"); } diff --git a/webui/graph.js b/webui/graph.js index 0ab3882..f2f4a4d 100644 --- a/webui/graph.js +++ b/webui/graph.js @@ -10,8 +10,8 @@ const G = { nodes: [], edges: [], events: [], idx: {}, cam: { x: 0, y: 0, k: 1 }, // 相机:世界 -> 屏幕 screen = (world - cam.pan) * k ... 使用 cam.x/y 为平移(屏幕像素) - showLabels: true, - filters: { types: { Job: true, Skill: true, Industry: true, Scenario: true }, category: "", level: "", + showLabels: false, // 默认关闭:悬停/选中/缩放时才显示 + filters: { types: { Job: true, Skill: true, Industry: false, Scenario: false }, category: "", level: "", edges: { required: true, preferred: true, applies: true, belongs: true } }, selected: null, // 选中节点 id soloJob: null, // 岗位子图模式:只看该岗位及其一跳邻居 @@ -444,7 +444,7 @@ $("#fp-category").onchange = (e) => { G.filters.category = e.target.value; requestDraw(); drawMinimap(); }; $("#fp-level").onchange = (e) => { G.filters.level = e.target.value; requestDraw(); drawMinimap(); }; $("#fp-reset").onclick = () => { - G.filters = { types: { Job: true, Skill: true, Industry: true, Scenario: true }, category: "", level: "", + G.filters = { types: { Job: true, Skill: true, Industry: false, Scenario: false }, category: "", level: "", edges: { required: true, preferred: true, applies: true, belongs: true } }; $$("#graph-filter-panel input[data-ftype]").forEach((cb) => { cb.checked = true; }); $$("#graph-filter-panel input[data-etype]").forEach((cb) => { cb.checked = true; }); @@ -487,7 +487,7 @@ if (!v) return false; G.cam = v.cam; G.filters = Object.assign( - { types: { Job: true, Skill: true, Industry: true, Scenario: true }, category: "", level: "", + { types: { Job: true, Skill: true, Industry: false, Scenario: false }, category: "", level: "", edges: { required: true, preferred: true, applies: true, belongs: true } }, v.filters); G.filters.edges = Object.assign({ required: true, preferred: true, applies: true, belongs: true }, diff --git a/webui/index.html b/webui/index.html index f12c94d..384b9f8 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1,517 +1,521 @@ - - - - - - CareerNexus 职联 · 岗位与能力图谱平台 - - - - -
-
- - CareerNexus 职联 -
-
数据驾驶舱
-
- - 数据版本 — - - 演 -
-
- - - - - - - - - -
- -
- - -
-
-
-
-
-

近 12 个季度演化事件趋势

- 按事件窗口统计 -
-
-
-
-
-

任务与审核状态

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

动态消息流

点击可跳转
-
-
-
-

待审核新岗位候选

0 待办
-
-
-
-
- - -
-
-
-

岗位能力图谱工作台

- 演示模式 - 技能点级 - 审核已开启 -
-
- - - -
-
-
-
- - - - - - -
- - - - - - - -
- - - - - -
- -
- 岗位 - 技能 - 场景 - 待审核 - 必备 - 加分 -
-
- - -
-
- - 智能建议 - -
-
-
- - -
-
演化逻辑链 选择岗位节点后展示能力演化路径
-
—
-
- -
-
-
图谱布局计算中…
-
-
- - - -
-
- - -
-
-
-

岗位能力演化分析

-
- -
- - - -
-
-
-
- -
全部岗位演化热力图(每格 = 该季度演化事件数,点击行切换岗位)
-
加载中…
- - -
- - -
-
- 能力画像时点对比 -
- - → - - -
-
-
-
- - -
变更类型构成(每季度 新增/增强/减弱/退出)
-
-
- - -
技能生命周期(横条 = 技能在画像中的生效区间,圆点 = 变更事件)
-
- - -
技能热度走势(活跃技能关系数)
-
- - -
演化事件时间轴(点击查看证据 JD)
-
-
-
-
- - -
-
-
-
- - -
-
-
-
1简历录入
-
2解析确认
-
3诊断结果
-
- - -
-
- - -
-
- - -
-
- - - - 支持 .txt / .docx / .pdf,≤5MB - -
-
- - - - - - -
- - - -
-
-
- - -
-
- -
-

路径配置

-
-
- - -
-
- -
-
选择岗位后加载技能清单…
-
-
-
- -
- - - -
-
-
-
已掌握0 项
-
待补齐—
-
-
覆盖率 0%
-
- -
-
- -
-
-

分阶段学习路线

- -
-
- -
-
- -

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

-
-
-
-
-
-
- - -
-
- - - -
- - - - - + + + + + + CareerNexus 职联 · 岗位与能力图谱平台 + + + + +
+
+ + CareerNexus 职联 +
+
数据驾驶舱
+
+ + + 演示模式 + 数据版本 — + + 演 +
+
+ + + + + + + + + +
+ +
+ + +
+
+
+
+
+

近 12 个季度演化事件趋势

+ 按事件窗口统计 +
+
+
+
+
+

任务与审核状态

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

动态消息流

点击可跳转
+
+
+
+

待审核新岗位候选

0 待办
+
+
+
+
+ + +
+
+
+

岗位能力图谱工作台

+ 演示模式 + 技能点级 + 审核已开启 +
+
+ + + +
+
+
+
+ + + + + + +
+ + + + + + + +
+ + + + + +
+ +
+ 岗位 + 技能 + 场景 + 待审核 + 必备 + 加分 +
+
+ + +
+
+ + 智能建议 + +
+
+
+ + +
+
演化逻辑链 选择岗位节点后展示能力演化路径
+
—
+
+ +
+
+
图谱布局计算中…
+
+
+ + + +
+
+ + +
+
+
+

岗位能力演化分析

+
+ +
+ + + +
+
+
+
+ +
全部岗位演化热力图(每格 = 该季度演化事件数,点击行切换岗位)
+
加载中…
+ + +
+ + +
+
+ 能力画像时点对比 +
+ + → + + +
+
+
+
+ + +
变更类型构成(每季度 新增/增强/减弱/退出)
+
+
+ + +
技能生命周期(横条 = 技能在画像中的生效区间,圆点 = 变更事件)
+
+ + +
技能热度走势(活跃技能关系数)
+
+ + +
演化事件时间轴(点击查看证据 JD)
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
1简历录入
+
2解析确认
+
3诊断结果
+
+ + +
+
+ + +
+
+ + +
+
+ + + + 支持 .txt / .docx / .pdf,≤5MB + +
+
+ + + + + + +
+ + + +
+
+
+ + +
+
+ +
+

路径配置

+
+
+ + +
+
+ +
+
选择岗位后加载技能清单…
+
+
+
+ +
+ + + +
+
+
+
已掌握0 项
+
待补齐—
+
+
覆盖率 0%
+
+ +
+
+ +
+
+

分阶段学习路线

+ +
+
+ +
+
+ +

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

+
+
+
+
+
+
+ + +
+
+ + + +
+ + + + + diff --git a/webui/style.css b/webui/style.css index dd9e006..25f04aa 100644 --- a/webui/style.css +++ b/webui/style.css @@ -924,3 +924,26 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: width: 18px; height: 18px; border-radius: 50%; cursor: pointer; line-height: 1; } .solo-badge button:hover { background: rgba(255,255,255,.45); } + +/* 演示模式状态徽标 / 演示导览 */ +.demo-badge { + display: inline-flex; align-items: center; gap: 6px; cursor: pointer; + font-size: 12px; padding: 3px 10px; border-radius: 999px; + background: var(--success-light); color: var(--success); border: 1px solid #b7eb8f; + white-space: nowrap; +} +.demo-badge .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--success); } +.demo-badge.warn { background: var(--warning-light); color: #ad4e00; border-color: #ffd591; } +.demo-badge.warn .dot { background: var(--warning); } +#btn-demo-tour svg { width: 13px; height: 13px; fill: currentColor; } +.tour-intro { font-size: 13px; color: var(--text-2); margin-bottom: 12px; } +.tour-step { + display: flex; align-items: center; gap: 12px; + border: 1px solid var(--border); border-radius: var(--radius); + padding: 12px 14px; margin-bottom: 10px; +} +.tour-step:hover { border-color: var(--primary); } +.tour-step-body { flex: 1; } +.tour-step-title { font-size: 13px; font-weight: 600; color: var(--text-1); margin-bottom: 2px; } +.tour-step-desc { font-size: 12px; color: var(--text-3); } +.cfg-foot-note { text-align: center; font-size: 12px; color: var(--text-3); padding: 4px 0 2px; }