/* app.js —— 入口:首屏骨架 → 取数 → 构建模型 → 按 hash 渲染 → 事件委托 → 轮询。 * * 数据流:KF_DATA.load() → KF_MODEL.build() → 各 view 的 render(db, hashParams) * hash 是唯一的状态源:筛选 / 排序 / 抽屉 / 搜索都写进 hash,再由 hash 驱动渲染。 * 交互只改 hash,绝不直接改 DOM 状态,所以"复制 URL 新标签打开"与刷新一定一致。 * 渲染用 morph(同构时逐个替换子节点),玻璃背景层不闪、整页不重绘。 */ (function () { "use strict"; const CFG = window.KF_CONFIG; const R = window.KF_ROUTER; const D = window.KF_DATA; const Q = window.KF_QUERY; const UI = window.KF_UI; const U = window.KF_UTIL; const esc = U.esc; const $ = (s) => document.querySelector(s); const el = { view: $("#view"), nav: $("#nav"), crumb: $("#crumb"), search: $("#search"), clear: $(".search .clear"), sico: $(".search .s-ico"), menu: $(".menu-btn"), drawer: $("#drawer"), scrim: $("#drawer-scrim"), side: $("#side"), sideScrim: $("#scrim"), dot: $("#live-dot"), live: $("#live-text") }; const state = { db: null, // 构建后的模型 error: null, loading: false, everLoaded: false, hash: R.parseHash(), newIds: new Set(), scoreFlow: false // 评完一张 → 刷新后自动开下一张未评的卡 }; const NAV = [ { group: "工作台" }, { route: "overview", label: "工作台", icon: "grid" }, { route: "pool", label: "机会卡池", icon: "cards" }, { group: "观察" }, { route: "stages", label: "阶段", icon: "steps" }, { route: "members", label: "成员", icon: "users" }, { group: "成果与报告" }, { route: "insights", label: "归纳", icon: "spark" }, { route: "artifacts", label: "成果", icon: "doc" }, { route: "reportIndex", label: "报告", icon: "layers" }, { route: "projects", label: "项目", icon: "box" }, { group: "库" }, { route: "sources", label: "原材料", icon: "archive" }, { route: "record", label: "记录", icon: "archive" } ]; const TITLES = { overview: "工作台", pool: "机会卡池", stages: "阶段", members: "成员", insights: "归纳", artifacts: "成果", reportIndex: "报告", report: "报告", projects: "项目", sources: "原材料", record: "记录", project: "项目", member: "成员", stage: "阶段", artifact: "成果详情" }; const NAV_ROOT = { project: "projects", member: "members", stage: "stages", artifact: "artifacts", report: "reportIndex" }; /* ============================ 导航 & 顶栏 ============================ */ function routeKey(h) { return h.route === "stage" ? "stages" : h.route; } function navCounts() { const db = state.db; if (!db) return {}; const withReport = db.artifacts.filter((a) => a.report).length; return { pool: String(db.opportunities.length || ""), stages: String(db.members.length || ""), members: String(db.members.length || ""), insights: String(db.insights.length || ""), artifacts: String(db.artifacts.length || ""), reportIndex: withReport ? String(withReport) : "", projects: String(db.projects.length || ""), sources: String(db.sources.length || ""), record: String(db.artifacts.filter((a) => a.layer === "record").length || "") }; } function renderNav() { const on = routeKey(state.hash); const counts = navCounts(); el.nav.innerHTML = NAV.map((n) => { if (n.group) return ``; const cur = n.route === on ? ` class="on" aria-current="page"` : ""; const ico = UI.icons[n.icon] || ""; return `${ico}${esc(n.label)}${counts[n.route] || ""}`; }).join(""); } function setLive(kind, text) { el.dot.className = "dot " + kind; el.live.textContent = text; } function renderTopbar(h) { const root = NAV_ROOT[h.route]; let items; if (h.route === "report") { const db = state.db; const a = db && db.artifactById[h.arg]; items = [{ label: "报告", href: "#/reportIndex" }, { label: a ? a.shortTitle : "报告详情" }]; } else if (root) { items = [{ label: TITLES[root], href: `#/${root}` }, { label: TITLES[h.route] === TITLES[root] ? (h.arg || "详情") : TITLES[h.route] }]; } else { items = [{ label: TITLES[h.route] || "总览", href: `#/${h.route}` }]; } el.crumb.innerHTML = UI.crumb(items); if (el.menu.innerHTML === "") el.menu.innerHTML = UI.icons.menu; if (el.clear.innerHTML === "") el.clear.innerHTML = UI.icons.x; if (el.sico.innerHTML === "") el.sico.innerHTML = UI.icons.search; /* 搜索框与 hash 的 q 同步;有搜索词时提示匹配范围 */ const q = h.params.q || ""; if (document.activeElement !== el.search && el.search.value !== q) el.search.value = q; el.search.placeholder = q ? `在${TITLES[h.route] || "本页"}中筛选「${q}」` : "搜索成员 / 项目 / 结论 / 报告"; el.clear.hidden = !el.search.value; } /* ============================ 渲染 ============================ */ function loadingHTML() { return `

${UI.skeleton("metrics")}
${UI.skeleton("rows", 5)}`; } function errorHTML(msg, what) { return `

${what ? esc(what) + "没有加载出来" : "数据没有取到"}

页面不会用假数据顶替,也不会把"读取失败"伪装成"没有内容"。请确认网络与数据源配置后重试。

读取 Parse 失败:${esc(msg)}

检查 js/config.js 中的 parseBase 与 appId。

`; } function notFound(what, href) { return UI.empty({ title: `${what}不存在`, body: "链接里的编号在库里找不到。可能已被删除,或链接复制时被截断。", actions: [{ label: `返回${what}列表`, href }] }); } /* 当前路由 → 视图 HTML(不含外层 .view) */ function routeHTML() { if (state.error) { /* 报告页取数失败:保留标题与返回入口,明确告诉读者"这不是报告不存在,是没取到" */ const h0 = state.hash; if (h0.route === "report") return errorHTML(state.error, "报告"); return errorHTML(state.error); } if (!state.db) return loadingHTML(); const db = state.db; const h = state.hash; const c = Q.criteria(h.params); const A = window.KF_VIEW_ARTIFACTS; switch (h.route) { case "artifacts": return A.listView(db, c, { title: "成果", lede: "学员与分身产生的一切成果。每条都能看出它属于哪一层、谁在哪个阶段交的、有没有原文。", emptyTitle: "还没有归集的成果", emptyBody: "学员对话产生的成果经上传 skill 入库后,这里会实时出现。每条都带出身:谁、什么时候、哪个阶段、有没有原文。" }); case "record": return A.listView(db, c, { filterLayer: "record", layerLocked: true, sortLocked: false, defaultSort: "newest", title: "记录层", lede: "不合格、纯推测、重复的成果。只留档,不进知识库,也不参与归纳。", unit: "条记录", emptyTitle: "记录层是空的", emptyBody: "当上传的内容被判定为不合格、纯推测或重复时,会自动落到这里留档。它们不会进入归纳,也不会被回灌。" }); case "insights": return window.KF_VIEW_INSIGHTS.render(db, c); case "projects": return window.KF_VIEW_PROJECTS.listView(db, c); case "project": { const html = window.KF_VIEW_PROJECTS.detail(db, h.arg); return html || notFound("项目", "#/projects"); } case "members": return window.KF_VIEW_MEMBERS.listView(db, c); case "member": return window.KF_VIEW_MEMBERS.detail(db, h.arg) || notFound("成员", "#/members"); case "stages": return window.KF_VIEW_STAGES.render(db); case "stage": return window.KF_VIEW_STAGES.detail(db, h.arg) || notFound("阶段", "#/stages"); case "sources": return window.KF_VIEW_SOURCES.render(db, h.params); case "artifact": return `
${window.KF_DETAIL.artifact(db, h.arg, { inline: true }).html}
`; case "reportIndex": return window.KF_REPORT.index(db, c); case "report": { const html = window.KF_REPORT.view(db, h.arg, h.params); return html || notFound("报告", "#/reportIndex"); } case "overview": return window.KF_VIEW_OVERVIEW.render(db); case "pool": return window.KF_VIEW_POOL.render(db, h.params); default: return notFound("页面", "#/overview"); } } /* 同构替换:当前视图与目标视图子节点一一对应时逐个替换,否则整体替换。 * 注意:被替换掉的节点上会触发"移除"(不是 click),这里顺手把仍按下鼠标的主键复位, * 否则按钮 DOM 在按下与释放之间被换掉,下一次点击会被浏览器当成双击的第二段。 */ function morph(target, html) { const tpl = document.createElement("div"); tpl.innerHTML = html; const same = target.children.length && target.children.length === tpl.children.length && [...target.children].every((n, i) => n.tagName === tpl.children[i].tagName); if (same) { resetPointer(); [...tpl.children].forEach((n, i) => target.children[i].replaceWith(n)); } else { target.innerHTML = html; } } /* 筛选 chip 原地同步:值、计数、选中态、置灰态 */ function syncChips(fl, ff) { const map = new Map([...ff.querySelectorAll(".chip")].map((c) => [c.dataset.dim + "|" + c.dataset.value, c])); fl.querySelectorAll(".chip").forEach((c) => { const n = map.get(c.dataset.dim + "|" + c.dataset.value); if (!n) return; if (c.dataset.on !== n.dataset.on) c.dataset.on = n.dataset.on; const num = c.querySelector(".n"), num2 = n.querySelector(".n"); if (num && num2 && num.textContent !== num2.textContent) num.textContent = num2.textContent; if (c.dataset.zero !== n.dataset.zero) c.dataset.zero = n.dataset.zero; if (c.classList.contains("zero") !== n.classList.contains("zero")) c.classList.toggle("zero"); }); } /* 按下过的按钮如果已经在重绘中被替换,补发一次 pointerup,让浏览器的"按下次序"归位 */ let lastPress = null; document.addEventListener("pointerdown", (e) => { lastPress = e.timeStamp; }, true); function resetPointer() { if (!lastPress) return; lastPress = null; try { document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, cancelable: true })); document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true })); } catch (e) { /* 老浏览器忽略 */ } } /* ---------- 视图内更新 ---------- * 切换筛选 / 排序时整页不动,只按"数据声明"更新需要变的部分: * [data-filterbar] 筛选选项的条数与选中态(只改 aria-disabled 与计数,节点不换) * [data-active] 已选条件回显 * [data-list] 结果列表 / 空态 * 这样被点击的按钮不会被替换,"连续点两个条件"天然不会丢;同时也避免模糊层闪烁。 */ function updateView(html) { const tpl = document.createElement("div"); tpl.innerHTML = html; const fresh = (key) => tpl.querySelector(`[data-${key}]`); const live = (key) => el.view.querySelector(`[data-${key}]`); /* 筛选条:按 data-dim + data-value 对齐 */ const fl = live("filterbar"); const ff = fresh("filterbar"); if (fl && ff) { syncChips(fl, ff); /* 结果数 / 清除按钮 */ const rn = fl.querySelector("[data-rn]"); const rn2 = ff.querySelector("[data-rn]"); if (rn && rn2) rn.textContent = rn2.textContent; /* 清除按钮:无则加、有则原地换(换掉的不是 chip,不影响正在点的按钮) */ const ca = fl.querySelector(".clear-all"); const ca2 = ff.querySelector(".clear-all"); if (ca2 && ca) ca.replaceWith(ca2); else if (ca2) fl.appendChild(ca2); else if (ca) ca.remove(); } /* 已选条件、结果区:整体替换(这里没有正在按住的按钮) */ ["active", "list"].forEach((k) => { const l = live(k), f = fresh(k); if (l && f) l.replaceWith(f); else if (f) el.view.appendChild(f); else if (l) l.remove(); }); } function render(opts) { state.hash = R.parseHash(); const h = state.hash; document.title = `${TITLES[h.route] || "看板"} · 康师傅产品创新看板`; /* 任何一次渲染后都从真实 hash 重建筛选条件,杜绝"上次的筛选"渗进新页面 */ h.params = { ...R.parseHash().params }; const html = routeHTML(); /* 报告页可以长达万字:只有明确要求局部更新时才走局部,否则整块替换 */ const local = opts && opts.local && state.db && !state.error && h.route !== "report"; if (local) updateView(html); else morph(el.view, html); renderNav(); renderTopbar(h); syncDrawer(); syncReportDom(h); if (state.newIds.size) { markNew(); state.newIds = new Set(); } } /* ---------- 报告页的 DOM 副作用 ---------- * 报告正文不是拼完字符串就完事:它要"活着"——章节目录随滚动高亮、点目录平滑跳转、 * 带 ?ch= 进入时还原到那一章。具体逻辑在 views/report.js 的 bind() 里。 */ function syncReportDom(h) { if (h.route !== "report") { document.body.classList.remove("in-report"); return; } const root = el.view.querySelector("[data-report]"); if (!root) return; document.body.classList.add("in-report"); window.KF_REPORT.bind(root, h.arg); } /* 抽屉由 hash 驱动:?ins= / ?art= / ?src= / ?ev= * ?ev= 是报告里的"证据锚点":列出某个结论的全部支撑成果(证据链),不离开报告也能追到原文 */ let lastDrawerKey = ""; function syncDrawer() { const p = state.hash.params; const db = state.db; let d = null; if (db && p.opp) d = window.KF_OPP.drawer(db, p.opp); else if (db && p.ev) d = window.KF_REPORT.evidenceDrawer(db, p.ev, p.back && db.artifactById[p.back] ? db.artifactById[p.back] : null); else if (db && p.ins) d = window.KF_DETAIL.insight(db, p.ins); else if (db && p.art) d = window.KF_DETAIL.artifact(db, p.art); else if (db && p.src) d = window.KF_DETAIL.source(db, p.src); const open = Boolean(d && d.html); const key = open ? `${p.opp || ""}|${p.ev || ""}|${p.ins || ""}|${p.art || ""}|${p.src || ""}` : ""; if (open) { el.drawer.innerHTML = d.html; el.drawer.classList.add("open"); el.scrim.classList.add("open"); document.body.style.overflow = "hidden"; if (key !== lastDrawerKey) el.drawer.scrollTop = 0; if (p.opp) window.KF_OPP.bindDrawer(el.drawer); } else if (el.drawer.classList.contains("open")) { el.drawer.classList.remove("open"); el.scrim.classList.remove("open"); document.body.style.overflow = ""; } lastDrawerKey = key; } /* 轮询发现新记录:行上加一次性高亮(局部,不打断阅读) */ function markNew() { if (!state.newIds.size) return; el.view.querySelectorAll("tr[data-id]").forEach((tr) => { if (state.newIds.has(tr.dataset.id)) tr.classList.add("flash"); }); } /* ============================ 数据 ============================ */ let pendingQuiet = false; async function refresh(opts) { /* 正在刷新时又来一次(例如写库成功后的重读撞上 15s 轮询):记住,等这次结束后马上再拉一遍, * 不能直接丢掉——否则"写完看到旧值"。 */ if (state.loading) { if (opts && opts.quiet) pendingQuiet = true; return; } state.loading = true; const quiet = opts && opts.quiet; setLive("syncing", quiet ? "自动同步…" : "正在读取…"); try { const raw = await D.load(); const prev = state.db ? new Set(state.db.artifacts.map((a) => a.id)) : null; state.db = window.KF_MODEL.build(raw); if (prev) state.newIds = new Set(state.db.artifacts.map((a) => a.id).filter((id) => !prev.has(id))); state.error = null; state.everLoaded = true; setLive("live", `已同步 · ${U.fmtAgo(raw.loadedAt)}`); } catch (e) { state.error = e.message || String(e); setLive("err", "读取失败"); } state.loading = false; render(); if (state.scoreFlow) { state.scoreFlow = false; nextUnscored(); } if (pendingQuiet) { pendingQuiet = false; refresh({ quiet: true }); } } /* 评完一张卡就轮到下一张:分数落库、页面刷新之后,自动在池子里找第一张"我"还没评过的卡打开。 * 这样 30 多人连续点评不用每次自己找卡;最后一张评完就停在池子上,提示已经评完。 */ function nextUnscored() { const me = window.KF_OPP.getMe(); if (!me || !state.db || state.hash.route !== "pool") return; const next = window.KF_OPP.sortCards(state.db.opportunities.filter((o) => !o.stats.byUser[me]), "prio")[0]; if (next) { openDetail("opp", next.id); window.KF_OPP.toast(`${me} 还没评的下一张:${next.title}`, "ok"); } else { window.KF_OPP.toast(`${me} 已经评完池子里的卡了`, "ok"); } } /* ============================ 交互 ============================ */ function setParams(patch) { const p = { ...state.hash.params }; Object.entries(patch).forEach(([k, v]) => { if (v === "" || v == null) delete p[k]; else p[k] = v; }); /* 只由本函数负责重绘:写地址时不再广播 route:changed,否则会被监听器二次渲染 */ R.write(p, { silent: true }); state.hash = R.parseHash(); render({ local: true }); } function toggleFacet(dim, value, multi) { const cur = Q.parseList(state.hash.params[dim]); const next = multi ? (cur.includes(value) ? cur.filter((x) => x !== value) : [...cur, value]) : (cur.length === 1 && cur[0] === value ? [] : [value]); setParams({ [dim]: next.join(",") }); } const FILTER_DIMS = ["priority", "stage", "category", "evidence", "layer", "member"]; const SEARCHABLE = ["artifacts", "insights", "projects", "members", "record"]; let searchTimer = 0; function onSearchInput() { el.clear.hidden = !el.search.value; clearTimeout(searchTimer); searchTimer = setTimeout(() => { const q = el.search.value.trim(); if (q === (state.hash.params.q || "")) return; /* 已是当前条件,不必重绘 */ const route = state.hash.route; /* 不支持的页面就地搜索:自动落到成果页,并保留其余条件 */ if (q && !SEARCHABLE.includes(route)) { window.location.hash = R.buildHash("artifacts", null, { ...state.hash.params, q }); return; } setParams({ q }); }, 180); } function openDetail(kind, id) { const p = { ...state.hash.params, [kind]: id }; delete p.ev; delete p.back; if (kind !== "opp") delete p.opp; R.write(p, { silent: true }); state.hash = R.parseHash(); render(); el.drawer.focus({ preventScroll: true }); } function closeDetail() { setParams({ ins: "", art: "", src: "", ev: "", opp: "" }); } /* 只关抽屉(例如"跳去另一页"时顺手收起),不重绘 */ function closeDrawerOnly() { el.drawer.classList.remove("open"); el.scrim.classList.remove("open"); document.body.style.overflow = ""; const p = { ...state.hash.params }; delete p.ins; delete p.art; delete p.src; delete p.ev; delete p.opp; R.write(p, { silent: true }); state.hash = R.parseHash(); } /* 打开报告:清掉抽屉参数,避免进报告时还挂着一个抽屉;保留回到列表所需的筛选条件 */ function openReport(id) { const p = { ...state.hash.params }; delete p.ins; delete p.art; delete p.src; delete p.ev; delete p.opp; delete p.ch; el.drawer.classList.remove("open"); el.scrim.classList.remove("open"); document.body.style.overflow = ""; R.go(R.buildHash("report", id, p)); window.scrollTo(0, 0); } function openEvidence(id, back) { const p = { ...state.hash.params, ins: "", art: "", src: "", opp: "", ev: id }; if (back) p.back = back; else delete p.back; R.write(p, { silent: true }); state.hash = R.parseHash(); render(); el.drawer.focus({ preventScroll: true }); } /* 证据链抽屉里的"← 返回成果":先关抽屉,再一次性跳回那条成果的台账位置 */ function backFromEvidence(href) { closeDrawerOnly(); if (href) R.go(href); } /* 点击目标在按下与释放之间若被重绘替换,click 会落到外层容器上。 * 这里在 pointerdown 时记下元素;click 若从容器冒泡上来,按记录的元素处理。 */ let pressTarget = null; document.addEventListener("pointerdown", (e) => { pressTarget = e.target && e.target.closest ? e.target.closest("[data-act]") : null; }, true); document.addEventListener("click", (ev) => { const link = ev.target.closest("a[href]"); /* 纯链接(导航、外部链接)交给浏览器 */ if (link && !link.dataset.act) { if (el.side.classList.contains("open")) closeNav(); return; } let n = ev.target.closest("[data-act]"); /* 没命中具体元素时,用按下时的目标兜底 */ if (!n && pressTarget && document.contains(pressTarget)) n = pressTarget; pressTarget = null; /* 点机会卡空白处 = 打开卡片详情;点卡片里的按钮/链接时以按钮为准 */ if (!n && !link) { const card = ev.target.closest(".ocard"); if (card && card.dataset.opp) { ev.preventDefault(); openDetail("opp", card.dataset.opp); return; } } if (!n) return; /* 防重复处理:一次点击里,同一个元素只响应一次(兜底路径可能与真实冒泡是同一个元素) */ if (ev.__kfAct) return; ev.__kfAct = true; const d = n.dataset; switch (d.act) { case "facet": toggleFacet(d.dim, d.value, n.closest(".fgroup").classList.contains("multi")); break; case "remove-filter": setParams({ [d.dim]: Q.parseList(state.hash.params[d.dim]).filter((x) => x !== d.value).join(",") }); break; case "clear-filters": { const patch = {}; [...FILTER_DIMS, "q"].forEach((k) => { patch[k] = ""; }); el.search.value = ""; el.clear.hidden = true; setParams(patch); break; } case "clear-search": el.search.value = ""; el.clear.hidden = true; setParams({ q: "" }); break; case "open-report": ev.preventDefault(); openReport(d.id); break; case "clear-focus": ev.preventDefault(); setParams({ focus: "" }); break; case "open-evidence": ev.preventDefault(); openEvidence(d.id, d.back || ""); break; case "drawer-back": ev.preventDefault(); backFromEvidence(d.href); break; /* 归纳页:展开"归纳自哪几份报告"——本地切换,不改 hash,不打乱筛选 */ case "toggle-sources": { ev.preventDefault(); const box = document.getElementById("src-" + d.id); if (!box) break; const open = box.hidden; box.hidden = !open; n.setAttribute("aria-expanded", open ? "true" : "false"); n.classList.toggle("on", open); break; } case "sort": ev.preventDefault(); setParams({ sort: state.hash.params.sort === d.sort ? "" : d.sort }); break; /* ---------- 机会卡 ---------- */ case "open-opp": ev.preventDefault(); openDetail("opp", d.id); break; case "opp-facet": { ev.preventDefault(); const cur = Q.parseList(state.hash.params[d.dim]); const next = cur.includes(d.value) ? cur.filter((x) => x !== d.value) : [...cur, d.value]; setParams({ [d.dim]: next.join(",") }); break; } case "opp-clear": ev.preventDefault(); setParams({ priority: "", stage: "", category: "", status: "", member: "" }); break; case "opp-sort": ev.preventDefault(); setParams({ sort: d.sort === "prio" ? "" : d.sort }); break; case "opp-score": ev.preventDefault(); state.scoreFlow = state.hash.route === "pool"; window.KF_OPP.submitScore(el.drawer).then((ok) => { if (!ok) state.scoreFlow = false; }); break; case "opp-status": ev.preventDefault(); window.KF_OPP.submitStatus(el.drawer, d.id, d.status); break; case "me-select": /* select 的变更由 change 事件处理(见下),这里防止 click 冒泡重复 */ break; case "open-insight": ev.preventDefault(); openDetail("ins", d.id); break; case "open-artifact": ev.preventDefault(); openDetail("art", d.id); break; case "open-source": ev.preventDefault(); openDetail("src", d.id); break; case "close-drawer": ev.preventDefault(); closeDetail(); break; case "drawer-back": /* 抽屉里的"返回上一层":一次性同时关抽屉、清导航到目标页面,不经过两次渲染 */ ev.preventDefault(); if (el.drawer.classList.contains("open")) closeDrawerOnly(); R.go(d.href); break; case "nav": ev.preventDefault(); window.location.hash = d.href; closeNav(); break; case "refresh": ev.preventDefault(); refresh(); break; case "open-nav": ev.preventDefault(); openNav(); break; case "close-nav": ev.preventDefault(); closeNav(); break; default: break; } }); /* 身份选择:首页"给我的"下拉 / 卡片抽屉里的评分人下拉,两处共用同一个本地身份 */ document.addEventListener("change", (ev) => { const sel = ev.target.closest && ev.target.closest("select#me-select, select#o-who"); if (!sel) return; const v = sel.value.trim(); if (!v) return; window.KF_OPP.setMe(v); if (sel.id === "me-select") { /* 首页"给我的"换人:整页重绘一次(只影响这一块,别的视角数据不变) */ if (state.hash.route === "overview") render(); return; } /* 抽屉里的评分人:只更新抽屉里的"我的旧分"提示,不打断正在拖的评分滑杆 */ const o = state.db && state.db.opportunityById[state.hash.params.opp]; const mine = o && o.stats.byUser[v]; window.KF_OPP.toast(mine ? `${v} 之前给过 ${mine.score} 分,再交一次会覆盖` : `将以 ${v} 的身份评分`, "ok"); }); /* 键盘:卡片可用 Tab 聚焦,Enter / 空格打开 */ document.addEventListener("keydown", (ev) => { if (ev.key !== "Enter" && ev.key !== " ") return; const c = ev.target.closest && ev.target.closest(".ocard"); if (!c || ev.target !== c) return; ev.preventDefault(); openDetail("opp", c.dataset.opp); }, true); /* 卡片光斑跟随指针:只写两个 CSS 变量,不触发重排;离开时回左上光标位置 */ document.addEventListener("pointermove", (e) => { const c = e.target.closest && e.target.closest(".ocard"); if (!c) return; const r = c.getBoundingClientRect(); c.style.setProperty("--mx", ((e.clientX - r.left) / r.width * 100).toFixed(1) + "%"); c.style.setProperty("--my", ((e.clientY - r.top) / r.height * 100).toFixed(1) + "%"); }, { passive: true }); el.search.addEventListener("input", onSearchInput); el.search.addEventListener("keydown", (e) => { if (e.key === "Escape") { el.search.value = ""; el.clear.hidden = true; setParams({ q: "" }); el.search.blur(); } }); document.addEventListener("keydown", (e) => { if (e.key === "Escape") { if (el.drawer.classList.contains("open")) closeDetail(); else if (el.side.classList.contains("open")) closeNav(); } else if (e.key === "/" && !/^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement.tagName)) { e.preventDefault(); el.search.focus(); } }); function openNav() { el.side.classList.add("open"); el.sideScrim.classList.add("open"); } function closeNav() { el.side.classList.remove("open"); el.sideScrim.classList.remove("open"); } window.addEventListener("hashchange", () => { state.hash = R.parseHash(); render(); }); document.addEventListener("route:changed", () => { state.hash = R.parseHash(); render(); }); /* ============================ 轮询 ============================ */ /* 线下现场:每 refreshMs 静默同步一次;页面隐藏或抽屉打开时暂停,回来立刻补一次 */ function startPolling() { if (!CFG.refreshMs) return; setInterval(() => { if (document.hidden || el.drawer.classList.contains("open")) return; refresh({ quiet: true }); }, CFG.refreshMs); document.addEventListener("visibilitychange", () => { if (!document.hidden && state.everLoaded) refresh({ quiet: true }); }); } /* ============================ 启动 ============================ */ el.view.innerHTML = loadingHTML(); renderNav(); refresh().then(startPolling); /* 调试 / 测试入口 */ window.__KF__ = { state, render, refresh, closeDetail, closeDrawerOnly, openDetail, afterScore: () => refresh({ quiet: true }) }; })();