/* 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 `
${esc(n.group)}
`;
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 }) };
})();