app.js 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670
  1. /* app.js —— 入口:首屏骨架 → 取数 → 构建模型 → 按 hash 渲染 → 事件委托 → 轮询。
  2. *
  3. * 数据流:KF_DATA.load() → KF_MODEL.build() → 各 view 的 render(db, hashParams)
  4. * hash 是唯一的状态源:筛选 / 排序 / 抽屉 / 搜索都写进 hash,再由 hash 驱动渲染。
  5. * 交互只改 hash,绝不直接改 DOM 状态,所以"复制 URL 新标签打开"与刷新一定一致。
  6. * 渲染用 morph(同构时逐个替换子节点),玻璃背景层不闪、整页不重绘。 */
  7. (function () {
  8. "use strict";
  9. const CFG = window.KF_CONFIG;
  10. const R = window.KF_ROUTER;
  11. const D = window.KF_DATA;
  12. const Q = window.KF_QUERY;
  13. const UI = window.KF_UI;
  14. const U = window.KF_UTIL;
  15. const esc = U.esc;
  16. const $ = (s) => document.querySelector(s);
  17. const el = {
  18. view: $("#view"), nav: $("#nav"), crumb: $("#crumb"), search: $("#search"),
  19. clear: $(".search .clear"), sico: $(".search .s-ico"), menu: $(".menu-btn"),
  20. drawer: $("#drawer"), scrim: $("#drawer-scrim"), side: $("#side"), sideScrim: $("#scrim"),
  21. dot: $("#live-dot"), live: $("#live-text")
  22. };
  23. const state = {
  24. db: null, // 构建后的模型
  25. error: null,
  26. loading: false,
  27. everLoaded: false,
  28. hash: R.parseHash(),
  29. newIds: new Set(),
  30. scoreFlow: false // 评完一张 → 刷新后自动开下一张未评的卡
  31. };
  32. const NAV = [
  33. { group: "工作台" },
  34. { route: "overview", label: "工作台", icon: "grid" },
  35. { route: "pool", label: "机会卡池", icon: "cards" },
  36. { group: "观察" },
  37. { route: "stages", label: "阶段", icon: "steps" },
  38. { route: "members", label: "成员", icon: "users" },
  39. { group: "成果与报告" },
  40. { route: "insights", label: "归纳", icon: "spark" },
  41. { route: "artifacts", label: "成果", icon: "doc" },
  42. { route: "reportIndex", label: "报告", icon: "layers" },
  43. { route: "projects", label: "项目", icon: "box" },
  44. { group: "库" },
  45. { route: "sources", label: "原材料", icon: "archive" },
  46. { route: "record", label: "记录", icon: "archive" }
  47. ];
  48. const TITLES = {
  49. overview: "工作台", pool: "机会卡池", stages: "阶段", members: "成员", insights: "归纳", artifacts: "成果",
  50. reportIndex: "报告", report: "报告", projects: "项目", sources: "原材料", record: "记录",
  51. project: "项目", member: "成员", stage: "阶段", artifact: "成果详情"
  52. };
  53. const NAV_ROOT = { project: "projects", member: "members", stage: "stages", artifact: "artifacts", report: "reportIndex" };
  54. /* ============================ 导航 & 顶栏 ============================ */
  55. function routeKey(h) { return h.route === "stage" ? "stages" : h.route; }
  56. function navCounts() {
  57. const db = state.db;
  58. if (!db) return {};
  59. const withReport = db.artifacts.filter((a) => a.report).length;
  60. return {
  61. pool: String(db.opportunities.length || ""),
  62. stages: String(db.members.length || ""),
  63. members: String(db.members.length || ""),
  64. insights: String(db.insights.length || ""),
  65. artifacts: String(db.artifacts.length || ""),
  66. reportIndex: withReport ? String(withReport) : "",
  67. projects: String(db.projects.length || ""),
  68. sources: String(db.sources.length || ""),
  69. record: String(db.artifacts.filter((a) => a.layer === "record").length || "")
  70. };
  71. }
  72. function renderNav() {
  73. const on = routeKey(state.hash);
  74. const counts = navCounts();
  75. el.nav.innerHTML = NAV.map((n) => {
  76. if (n.group) return `<div class="nav-group">${esc(n.group)}</div>`;
  77. const cur = n.route === on ? ` class="on" aria-current="page"` : "";
  78. const ico = UI.icons[n.icon] || "";
  79. return `<a href="#/${n.route}"${cur}>${ico}<span>${esc(n.label)}</span><span class="cnt">${counts[n.route] || ""}</span></a>`;
  80. }).join("");
  81. }
  82. function setLive(kind, text) {
  83. el.dot.className = "dot " + kind;
  84. el.live.textContent = text;
  85. }
  86. function renderTopbar(h) {
  87. const root = NAV_ROOT[h.route];
  88. let items;
  89. if (h.route === "report") {
  90. const db = state.db;
  91. const a = db && db.artifactById[h.arg];
  92. items = [{ label: "报告", href: "#/reportIndex" }, { label: a ? a.shortTitle : "报告详情" }];
  93. } else if (root) {
  94. items = [{ label: TITLES[root], href: `#/${root}` }, { label: TITLES[h.route] === TITLES[root] ? (h.arg || "详情") : TITLES[h.route] }];
  95. } else {
  96. items = [{ label: TITLES[h.route] || "总览", href: `#/${h.route}` }];
  97. }
  98. el.crumb.innerHTML = UI.crumb(items);
  99. if (el.menu.innerHTML === "") el.menu.innerHTML = UI.icons.menu;
  100. if (el.clear.innerHTML === "") el.clear.innerHTML = UI.icons.x;
  101. if (el.sico.innerHTML === "") el.sico.innerHTML = UI.icons.search;
  102. /* 搜索框与 hash 的 q 同步;有搜索词时提示匹配范围 */
  103. const q = h.params.q || "";
  104. if (document.activeElement !== el.search && el.search.value !== q) el.search.value = q;
  105. el.search.placeholder = q ? `在${TITLES[h.route] || "本页"}中筛选「${q}」` : "搜索成员 / 项目 / 结论 / 报告";
  106. el.clear.hidden = !el.search.value;
  107. }
  108. /* ============================ 渲染 ============================ */
  109. function loadingHTML() {
  110. return `<div class="view-head"><h1 class="skel skel-line" style="width:120px;height:24px"></h1></div>
  111. ${UI.skeleton("metrics")}<div style="height:28px"></div>${UI.skeleton("rows", 5)}`;
  112. }
  113. function errorHTML(msg, what) {
  114. return `<div class="view-head"><div><h1>${what ? esc(what) + "没有加载出来" : "数据没有取到"}</h1><p class="lede">页面不会用假数据顶替,也不会把"读取失败"伪装成"没有内容"。请确认网络与数据源配置后重试。</p></div></div>
  115. <div class="error-box"><p><b>读取 Parse 失败:</b>${esc(msg)}</p>
  116. <p style="margin-top:8px">检查 <code>js/config.js</code> 中的 <code>parseBase</code> 与 <code>appId</code>。</p>
  117. <p style="margin-top:14px"><button class="btn" data-act="refresh">${UI.icons.refresh} 重试</button></p></div>`;
  118. }
  119. function notFound(what, href) {
  120. return UI.empty({
  121. title: `${what}不存在`,
  122. body: "链接里的编号在库里找不到。可能已被删除,或链接复制时被截断。",
  123. actions: [{ label: `返回${what}列表`, href }]
  124. });
  125. }
  126. /* 当前路由 → 视图 HTML(不含外层 .view) */
  127. function routeHTML() {
  128. if (state.error) {
  129. /* 报告页取数失败:保留标题与返回入口,明确告诉读者"这不是报告不存在,是没取到" */
  130. const h0 = state.hash;
  131. if (h0.route === "report") return errorHTML(state.error, "报告");
  132. return errorHTML(state.error);
  133. }
  134. if (!state.db) return loadingHTML();
  135. const db = state.db;
  136. const h = state.hash;
  137. const c = Q.criteria(h.params);
  138. const A = window.KF_VIEW_ARTIFACTS;
  139. switch (h.route) {
  140. case "artifacts":
  141. return A.listView(db, c, {
  142. title: "成果",
  143. lede: "学员与分身产生的一切成果。每条都能看出它属于哪一层、谁在哪个阶段交的、有没有原文。",
  144. emptyTitle: "还没有归集的成果",
  145. emptyBody: "学员对话产生的成果经上传 skill 入库后,这里会实时出现。每条都带出身:谁、什么时候、哪个阶段、有没有原文。"
  146. });
  147. case "record":
  148. return A.listView(db, c, {
  149. filterLayer: "record", layerLocked: true, sortLocked: false, defaultSort: "newest",
  150. title: "记录层",
  151. lede: "不合格、纯推测、重复的成果。只留档,不进知识库,也不参与归纳。",
  152. unit: "条记录",
  153. emptyTitle: "记录层是空的",
  154. emptyBody: "当上传的内容被判定为不合格、纯推测或重复时,会自动落到这里留档。它们不会进入归纳,也不会被回灌。"
  155. });
  156. case "insights":
  157. return window.KF_VIEW_INSIGHTS.render(db, c);
  158. case "projects":
  159. return window.KF_VIEW_PROJECTS.listView(db, c);
  160. case "project": {
  161. const html = window.KF_VIEW_PROJECTS.detail(db, h.arg);
  162. return html || notFound("项目", "#/projects");
  163. }
  164. case "members":
  165. return window.KF_VIEW_MEMBERS.listView(db, c);
  166. case "member":
  167. return window.KF_VIEW_MEMBERS.detail(db, h.arg) || notFound("成员", "#/members");
  168. case "stages":
  169. return window.KF_VIEW_STAGES.render(db);
  170. case "stage":
  171. return window.KF_VIEW_STAGES.detail(db, h.arg) || notFound("阶段", "#/stages");
  172. case "sources":
  173. return window.KF_VIEW_SOURCES.render(db, h.params);
  174. case "artifact":
  175. return `<div style="max-width:980px">${window.KF_DETAIL.artifact(db, h.arg, { inline: true }).html}</div>`;
  176. case "reportIndex":
  177. return window.KF_REPORT.index(db, c);
  178. case "report": {
  179. const html = window.KF_REPORT.view(db, h.arg, h.params);
  180. return html || notFound("报告", "#/reportIndex");
  181. }
  182. case "overview":
  183. return window.KF_VIEW_OVERVIEW.render(db);
  184. case "pool":
  185. return window.KF_VIEW_POOL.render(db, h.params);
  186. default:
  187. return notFound("页面", "#/overview");
  188. }
  189. }
  190. /* 同构替换:当前视图与目标视图子节点一一对应时逐个替换,否则整体替换。
  191. * 注意:被替换掉的节点上会触发"移除"(不是 click),这里顺手把仍按下鼠标的主键复位,
  192. * 否则按钮 DOM 在按下与释放之间被换掉,下一次点击会被浏览器当成双击的第二段。 */
  193. function morph(target, html) {
  194. const tpl = document.createElement("div");
  195. tpl.innerHTML = html;
  196. const same = target.children.length && target.children.length === tpl.children.length &&
  197. [...target.children].every((n, i) => n.tagName === tpl.children[i].tagName);
  198. if (same) {
  199. resetPointer();
  200. [...tpl.children].forEach((n, i) => target.children[i].replaceWith(n));
  201. } else {
  202. target.innerHTML = html;
  203. }
  204. }
  205. /* 筛选 chip 原地同步:值、计数、选中态、置灰态 */
  206. function syncChips(fl, ff) {
  207. const map = new Map([...ff.querySelectorAll(".chip")].map((c) => [c.dataset.dim + "|" + c.dataset.value, c]));
  208. fl.querySelectorAll(".chip").forEach((c) => {
  209. const n = map.get(c.dataset.dim + "|" + c.dataset.value);
  210. if (!n) return;
  211. if (c.dataset.on !== n.dataset.on) c.dataset.on = n.dataset.on;
  212. const num = c.querySelector(".n"), num2 = n.querySelector(".n");
  213. if (num && num2 && num.textContent !== num2.textContent) num.textContent = num2.textContent;
  214. if (c.dataset.zero !== n.dataset.zero) c.dataset.zero = n.dataset.zero;
  215. if (c.classList.contains("zero") !== n.classList.contains("zero")) c.classList.toggle("zero");
  216. });
  217. }
  218. /* 按下过的按钮如果已经在重绘中被替换,补发一次 pointerup,让浏览器的"按下次序"归位 */
  219. let lastPress = null;
  220. document.addEventListener("pointerdown", (e) => { lastPress = e.timeStamp; }, true);
  221. function resetPointer() {
  222. if (!lastPress) return;
  223. lastPress = null;
  224. try {
  225. document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, cancelable: true }));
  226. document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true }));
  227. } catch (e) { /* 老浏览器忽略 */ }
  228. }
  229. /* ---------- 视图内更新 ----------
  230. * 切换筛选 / 排序时整页不动,只按"数据声明"更新需要变的部分:
  231. * [data-filterbar] 筛选选项的条数与选中态(只改 aria-disabled 与计数,节点不换)
  232. * [data-active] 已选条件回显
  233. * [data-list] 结果列表 / 空态
  234. * 这样被点击的按钮不会被替换,"连续点两个条件"天然不会丢;同时也避免模糊层闪烁。 */
  235. function updateView(html) {
  236. const tpl = document.createElement("div");
  237. tpl.innerHTML = html;
  238. const fresh = (key) => tpl.querySelector(`[data-${key}]`);
  239. const live = (key) => el.view.querySelector(`[data-${key}]`);
  240. /* 筛选条:按 data-dim + data-value 对齐 */
  241. const fl = live("filterbar");
  242. const ff = fresh("filterbar");
  243. if (fl && ff) {
  244. syncChips(fl, ff);
  245. /* 结果数 / 清除按钮 */
  246. const rn = fl.querySelector("[data-rn]");
  247. const rn2 = ff.querySelector("[data-rn]");
  248. if (rn && rn2) rn.textContent = rn2.textContent;
  249. /* 清除按钮:无则加、有则原地换(换掉的不是 chip,不影响正在点的按钮) */
  250. const ca = fl.querySelector(".clear-all");
  251. const ca2 = ff.querySelector(".clear-all");
  252. if (ca2 && ca) ca.replaceWith(ca2);
  253. else if (ca2) fl.appendChild(ca2);
  254. else if (ca) ca.remove();
  255. }
  256. /* 已选条件、结果区:整体替换(这里没有正在按住的按钮) */
  257. ["active", "list"].forEach((k) => {
  258. const l = live(k), f = fresh(k);
  259. if (l && f) l.replaceWith(f);
  260. else if (f) el.view.appendChild(f);
  261. else if (l) l.remove();
  262. });
  263. }
  264. function render(opts) {
  265. state.hash = R.parseHash();
  266. const h = state.hash;
  267. document.title = `${TITLES[h.route] || "看板"} · 康师傅产品创新看板`;
  268. /* 任何一次渲染后都从真实 hash 重建筛选条件,杜绝"上次的筛选"渗进新页面 */
  269. h.params = { ...R.parseHash().params };
  270. const html = routeHTML();
  271. /* 报告页可以长达万字:只有明确要求局部更新时才走局部,否则整块替换 */
  272. const local = opts && opts.local && state.db && !state.error && h.route !== "report";
  273. if (local) updateView(html);
  274. else morph(el.view, html);
  275. renderNav();
  276. renderTopbar(h);
  277. syncDrawer();
  278. syncReportDom(h);
  279. if (state.newIds.size) { markNew(); state.newIds = new Set(); }
  280. }
  281. /* ---------- 报告页的 DOM 副作用 ----------
  282. * 报告正文不是拼完字符串就完事:它要"活着"——章节目录随滚动高亮、点目录平滑跳转、
  283. * 带 ?ch= 进入时还原到那一章。具体逻辑在 views/report.js 的 bind() 里。 */
  284. function syncReportDom(h) {
  285. if (h.route !== "report") { document.body.classList.remove("in-report"); return; }
  286. const root = el.view.querySelector("[data-report]");
  287. if (!root) return;
  288. document.body.classList.add("in-report");
  289. window.KF_REPORT.bind(root, h.arg);
  290. }
  291. /* 抽屉由 hash 驱动:?ins= / ?art= / ?src= / ?ev=
  292. * ?ev= 是报告里的"证据锚点":列出某个结论的全部支撑成果(证据链),不离开报告也能追到原文 */
  293. let lastDrawerKey = "";
  294. function syncDrawer() {
  295. const p = state.hash.params;
  296. const db = state.db;
  297. let d = null;
  298. if (db && p.opp) d = window.KF_OPP.drawer(db, p.opp);
  299. else if (db && p.ev) d = window.KF_REPORT.evidenceDrawer(db, p.ev, p.back && db.artifactById[p.back] ? db.artifactById[p.back] : null);
  300. else if (db && p.ins) d = window.KF_DETAIL.insight(db, p.ins);
  301. else if (db && p.art) d = window.KF_DETAIL.artifact(db, p.art);
  302. else if (db && p.src) d = window.KF_DETAIL.source(db, p.src);
  303. const open = Boolean(d && d.html);
  304. const key = open ? `${p.opp || ""}|${p.ev || ""}|${p.ins || ""}|${p.art || ""}|${p.src || ""}` : "";
  305. if (open) {
  306. el.drawer.innerHTML = d.html;
  307. el.drawer.classList.add("open");
  308. el.scrim.classList.add("open");
  309. document.body.style.overflow = "hidden";
  310. if (key !== lastDrawerKey) el.drawer.scrollTop = 0;
  311. if (p.opp) window.KF_OPP.bindDrawer(el.drawer);
  312. } else if (el.drawer.classList.contains("open")) {
  313. el.drawer.classList.remove("open");
  314. el.scrim.classList.remove("open");
  315. document.body.style.overflow = "";
  316. }
  317. lastDrawerKey = key;
  318. }
  319. /* 轮询发现新记录:行上加一次性高亮(局部,不打断阅读) */
  320. function markNew() {
  321. if (!state.newIds.size) return;
  322. el.view.querySelectorAll("tr[data-id]").forEach((tr) => {
  323. if (state.newIds.has(tr.dataset.id)) tr.classList.add("flash");
  324. });
  325. }
  326. /* ============================ 数据 ============================ */
  327. let pendingQuiet = false;
  328. async function refresh(opts) {
  329. /* 正在刷新时又来一次(例如写库成功后的重读撞上 15s 轮询):记住,等这次结束后马上再拉一遍,
  330. * 不能直接丢掉——否则"写完看到旧值"。 */
  331. if (state.loading) { if (opts && opts.quiet) pendingQuiet = true; return; }
  332. state.loading = true;
  333. const quiet = opts && opts.quiet;
  334. setLive("syncing", quiet ? "自动同步…" : "正在读取…");
  335. try {
  336. const raw = await D.load();
  337. const prev = state.db ? new Set(state.db.artifacts.map((a) => a.id)) : null;
  338. state.db = window.KF_MODEL.build(raw);
  339. if (prev) state.newIds = new Set(state.db.artifacts.map((a) => a.id).filter((id) => !prev.has(id)));
  340. state.error = null;
  341. state.everLoaded = true;
  342. setLive("live", `已同步 · ${U.fmtAgo(raw.loadedAt)}`);
  343. } catch (e) {
  344. state.error = e.message || String(e);
  345. setLive("err", "读取失败");
  346. }
  347. state.loading = false;
  348. render();
  349. if (state.scoreFlow) { state.scoreFlow = false; nextUnscored(); }
  350. if (pendingQuiet) { pendingQuiet = false; refresh({ quiet: true }); }
  351. }
  352. /* 评完一张卡就轮到下一张:分数落库、页面刷新之后,自动在池子里找第一张"我"还没评过的卡打开。
  353. * 这样 30 多人连续点评不用每次自己找卡;最后一张评完就停在池子上,提示已经评完。 */
  354. function nextUnscored() {
  355. const me = window.KF_OPP.getMe();
  356. if (!me || !state.db || state.hash.route !== "pool") return;
  357. const next = window.KF_OPP.sortCards(state.db.opportunities.filter((o) => !o.stats.byUser[me]), "prio")[0];
  358. if (next) {
  359. openDetail("opp", next.id);
  360. window.KF_OPP.toast(`${me} 还没评的下一张:${next.title}`, "ok");
  361. } else {
  362. window.KF_OPP.toast(`${me} 已经评完池子里的卡了`, "ok");
  363. }
  364. }
  365. /* ============================ 交互 ============================ */
  366. function setParams(patch) {
  367. const p = { ...state.hash.params };
  368. Object.entries(patch).forEach(([k, v]) => {
  369. if (v === "" || v == null) delete p[k]; else p[k] = v;
  370. });
  371. /* 只由本函数负责重绘:写地址时不再广播 route:changed,否则会被监听器二次渲染 */
  372. R.write(p, { silent: true });
  373. state.hash = R.parseHash();
  374. render({ local: true });
  375. }
  376. function toggleFacet(dim, value, multi) {
  377. const cur = Q.parseList(state.hash.params[dim]);
  378. const next = multi
  379. ? (cur.includes(value) ? cur.filter((x) => x !== value) : [...cur, value])
  380. : (cur.length === 1 && cur[0] === value ? [] : [value]);
  381. setParams({ [dim]: next.join(",") });
  382. }
  383. const FILTER_DIMS = ["priority", "stage", "category", "evidence", "layer", "member"];
  384. const SEARCHABLE = ["artifacts", "insights", "projects", "members", "record"];
  385. let searchTimer = 0;
  386. function onSearchInput() {
  387. el.clear.hidden = !el.search.value;
  388. clearTimeout(searchTimer);
  389. searchTimer = setTimeout(() => {
  390. const q = el.search.value.trim();
  391. if (q === (state.hash.params.q || "")) return; /* 已是当前条件,不必重绘 */
  392. const route = state.hash.route;
  393. /* 不支持的页面就地搜索:自动落到成果页,并保留其余条件 */
  394. if (q && !SEARCHABLE.includes(route)) {
  395. window.location.hash = R.buildHash("artifacts", null, { ...state.hash.params, q });
  396. return;
  397. }
  398. setParams({ q });
  399. }, 180);
  400. }
  401. function openDetail(kind, id) {
  402. const p = { ...state.hash.params, [kind]: id };
  403. delete p.ev; delete p.back;
  404. if (kind !== "opp") delete p.opp;
  405. R.write(p, { silent: true });
  406. state.hash = R.parseHash();
  407. render();
  408. el.drawer.focus({ preventScroll: true });
  409. }
  410. function closeDetail() {
  411. setParams({ ins: "", art: "", src: "", ev: "", opp: "" });
  412. }
  413. /* 只关抽屉(例如"跳去另一页"时顺手收起),不重绘 */
  414. function closeDrawerOnly() {
  415. el.drawer.classList.remove("open");
  416. el.scrim.classList.remove("open");
  417. document.body.style.overflow = "";
  418. const p = { ...state.hash.params };
  419. delete p.ins; delete p.art; delete p.src; delete p.ev; delete p.opp;
  420. R.write(p, { silent: true });
  421. state.hash = R.parseHash();
  422. }
  423. /* 打开报告:清掉抽屉参数,避免进报告时还挂着一个抽屉;保留回到列表所需的筛选条件 */
  424. function openReport(id) {
  425. const p = { ...state.hash.params };
  426. delete p.ins; delete p.art; delete p.src; delete p.ev; delete p.opp; delete p.ch;
  427. el.drawer.classList.remove("open");
  428. el.scrim.classList.remove("open");
  429. document.body.style.overflow = "";
  430. R.go(R.buildHash("report", id, p));
  431. window.scrollTo(0, 0);
  432. }
  433. function openEvidence(id, back) {
  434. const p = { ...state.hash.params, ins: "", art: "", src: "", opp: "", ev: id };
  435. if (back) p.back = back; else delete p.back;
  436. R.write(p, { silent: true });
  437. state.hash = R.parseHash();
  438. render();
  439. el.drawer.focus({ preventScroll: true });
  440. }
  441. /* 证据链抽屉里的"← 返回成果":先关抽屉,再一次性跳回那条成果的台账位置 */
  442. function backFromEvidence(href) {
  443. closeDrawerOnly();
  444. if (href) R.go(href);
  445. }
  446. /* 点击目标在按下与释放之间若被重绘替换,click 会落到外层容器上。
  447. * 这里在 pointerdown 时记下元素;click 若从容器冒泡上来,按记录的元素处理。 */
  448. let pressTarget = null;
  449. document.addEventListener("pointerdown", (e) => {
  450. pressTarget = e.target && e.target.closest ? e.target.closest("[data-act]") : null;
  451. }, true);
  452. document.addEventListener("click", (ev) => {
  453. const link = ev.target.closest("a[href]");
  454. /* 纯链接(导航、外部链接)交给浏览器 */
  455. if (link && !link.dataset.act) {
  456. if (el.side.classList.contains("open")) closeNav();
  457. return;
  458. }
  459. let n = ev.target.closest("[data-act]");
  460. /* 没命中具体元素时,用按下时的目标兜底 */
  461. if (!n && pressTarget && document.contains(pressTarget)) n = pressTarget;
  462. pressTarget = null;
  463. /* 点机会卡空白处 = 打开卡片详情;点卡片里的按钮/链接时以按钮为准 */
  464. if (!n && !link) {
  465. const card = ev.target.closest(".ocard");
  466. if (card && card.dataset.opp) { ev.preventDefault(); openDetail("opp", card.dataset.opp); return; }
  467. }
  468. if (!n) return;
  469. /* 防重复处理:一次点击里,同一个元素只响应一次(兜底路径可能与真实冒泡是同一个元素) */
  470. if (ev.__kfAct) return;
  471. ev.__kfAct = true;
  472. const d = n.dataset;
  473. switch (d.act) {
  474. case "facet":
  475. toggleFacet(d.dim, d.value, n.closest(".fgroup").classList.contains("multi"));
  476. break;
  477. case "remove-filter":
  478. setParams({ [d.dim]: Q.parseList(state.hash.params[d.dim]).filter((x) => x !== d.value).join(",") });
  479. break;
  480. case "clear-filters": {
  481. const patch = {};
  482. [...FILTER_DIMS, "q"].forEach((k) => { patch[k] = ""; });
  483. el.search.value = "";
  484. el.clear.hidden = true;
  485. setParams(patch);
  486. break;
  487. }
  488. case "clear-search":
  489. el.search.value = "";
  490. el.clear.hidden = true;
  491. setParams({ q: "" });
  492. break;
  493. case "open-report": ev.preventDefault(); openReport(d.id); break;
  494. case "clear-focus": ev.preventDefault(); setParams({ focus: "" }); break;
  495. case "open-evidence": ev.preventDefault(); openEvidence(d.id, d.back || ""); break;
  496. case "drawer-back": ev.preventDefault(); backFromEvidence(d.href); break;
  497. /* 归纳页:展开"归纳自哪几份报告"——本地切换,不改 hash,不打乱筛选 */
  498. case "toggle-sources": {
  499. ev.preventDefault();
  500. const box = document.getElementById("src-" + d.id);
  501. if (!box) break;
  502. const open = box.hidden;
  503. box.hidden = !open;
  504. n.setAttribute("aria-expanded", open ? "true" : "false");
  505. n.classList.toggle("on", open);
  506. break;
  507. }
  508. case "sort": ev.preventDefault(); setParams({ sort: state.hash.params.sort === d.sort ? "" : d.sort }); break;
  509. /* ---------- 机会卡 ---------- */
  510. case "open-opp": ev.preventDefault(); openDetail("opp", d.id); break;
  511. case "opp-facet": {
  512. ev.preventDefault();
  513. const cur = Q.parseList(state.hash.params[d.dim]);
  514. const next = cur.includes(d.value) ? cur.filter((x) => x !== d.value) : [...cur, d.value];
  515. setParams({ [d.dim]: next.join(",") });
  516. break;
  517. }
  518. case "opp-clear": ev.preventDefault(); setParams({ priority: "", stage: "", category: "", status: "", member: "" }); break;
  519. case "opp-sort": ev.preventDefault(); setParams({ sort: d.sort === "prio" ? "" : d.sort }); break;
  520. case "opp-score":
  521. ev.preventDefault();
  522. state.scoreFlow = state.hash.route === "pool";
  523. window.KF_OPP.submitScore(el.drawer).then((ok) => { if (!ok) state.scoreFlow = false; });
  524. break;
  525. case "opp-status":
  526. ev.preventDefault();
  527. window.KF_OPP.submitStatus(el.drawer, d.id, d.status);
  528. break;
  529. case "me-select":
  530. /* select 的变更由 change 事件处理(见下),这里防止 click 冒泡重复 */
  531. break;
  532. case "open-insight": ev.preventDefault(); openDetail("ins", d.id); break;
  533. case "open-artifact": ev.preventDefault(); openDetail("art", d.id); break;
  534. case "open-source": ev.preventDefault(); openDetail("src", d.id); break;
  535. case "close-drawer": ev.preventDefault(); closeDetail(); break;
  536. case "drawer-back":
  537. /* 抽屉里的"返回上一层":一次性同时关抽屉、清导航到目标页面,不经过两次渲染 */
  538. ev.preventDefault();
  539. if (el.drawer.classList.contains("open")) closeDrawerOnly();
  540. R.go(d.href);
  541. break;
  542. case "nav": ev.preventDefault(); window.location.hash = d.href; closeNav(); break;
  543. case "refresh": ev.preventDefault(); refresh(); break;
  544. case "open-nav": ev.preventDefault(); openNav(); break;
  545. case "close-nav": ev.preventDefault(); closeNav(); break;
  546. default: break;
  547. }
  548. });
  549. /* 身份选择:首页"给我的"下拉 / 卡片抽屉里的评分人下拉,两处共用同一个本地身份 */
  550. document.addEventListener("change", (ev) => {
  551. const sel = ev.target.closest && ev.target.closest("select#me-select, select#o-who");
  552. if (!sel) return;
  553. const v = sel.value.trim();
  554. if (!v) return;
  555. window.KF_OPP.setMe(v);
  556. if (sel.id === "me-select") {
  557. /* 首页"给我的"换人:整页重绘一次(只影响这一块,别的视角数据不变) */
  558. if (state.hash.route === "overview") render();
  559. return;
  560. }
  561. /* 抽屉里的评分人:只更新抽屉里的"我的旧分"提示,不打断正在拖的评分滑杆 */
  562. const o = state.db && state.db.opportunityById[state.hash.params.opp];
  563. const mine = o && o.stats.byUser[v];
  564. window.KF_OPP.toast(mine ? `${v} 之前给过 ${mine.score} 分,再交一次会覆盖` : `将以 ${v} 的身份评分`, "ok");
  565. });
  566. /* 键盘:卡片可用 Tab 聚焦,Enter / 空格打开 */
  567. document.addEventListener("keydown", (ev) => {
  568. if (ev.key !== "Enter" && ev.key !== " ") return;
  569. const c = ev.target.closest && ev.target.closest(".ocard");
  570. if (!c || ev.target !== c) return;
  571. ev.preventDefault();
  572. openDetail("opp", c.dataset.opp);
  573. }, true);
  574. /* 卡片光斑跟随指针:只写两个 CSS 变量,不触发重排;离开时回左上光标位置 */
  575. document.addEventListener("pointermove", (e) => {
  576. const c = e.target.closest && e.target.closest(".ocard");
  577. if (!c) return;
  578. const r = c.getBoundingClientRect();
  579. c.style.setProperty("--mx", ((e.clientX - r.left) / r.width * 100).toFixed(1) + "%");
  580. c.style.setProperty("--my", ((e.clientY - r.top) / r.height * 100).toFixed(1) + "%");
  581. }, { passive: true });
  582. el.search.addEventListener("input", onSearchInput);
  583. el.search.addEventListener("keydown", (e) => {
  584. if (e.key === "Escape") {
  585. el.search.value = ""; el.clear.hidden = true; setParams({ q: "" }); el.search.blur();
  586. }
  587. });
  588. document.addEventListener("keydown", (e) => {
  589. if (e.key === "Escape") {
  590. if (el.drawer.classList.contains("open")) closeDetail();
  591. else if (el.side.classList.contains("open")) closeNav();
  592. } else if (e.key === "/" && !/^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement.tagName)) {
  593. e.preventDefault(); el.search.focus();
  594. }
  595. });
  596. function openNav() { el.side.classList.add("open"); el.sideScrim.classList.add("open"); }
  597. function closeNav() { el.side.classList.remove("open"); el.sideScrim.classList.remove("open"); }
  598. window.addEventListener("hashchange", () => { state.hash = R.parseHash(); render(); });
  599. document.addEventListener("route:changed", () => { state.hash = R.parseHash(); render(); });
  600. /* ============================ 轮询 ============================ */
  601. /* 线下现场:每 refreshMs 静默同步一次;页面隐藏或抽屉打开时暂停,回来立刻补一次 */
  602. function startPolling() {
  603. if (!CFG.refreshMs) return;
  604. setInterval(() => {
  605. if (document.hidden || el.drawer.classList.contains("open")) return;
  606. refresh({ quiet: true });
  607. }, CFG.refreshMs);
  608. document.addEventListener("visibilitychange", () => {
  609. if (!document.hidden && state.everLoaded) refresh({ quiet: true });
  610. });
  611. }
  612. /* ============================ 启动 ============================ */
  613. el.view.innerHTML = loadingHTML();
  614. renderNav();
  615. refresh().then(startPolling);
  616. /* 调试 / 测试入口 */
  617. window.__KF__ = { state, render, refresh, closeDetail, closeDrawerOnly, openDetail,
  618. afterScore: () => refresh({ quiet: true }) };
  619. })();