| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670 |
- /* 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 `<div class="nav-group">${esc(n.group)}</div>`;
- const cur = n.route === on ? ` class="on" aria-current="page"` : "";
- const ico = UI.icons[n.icon] || "";
- return `<a href="#/${n.route}"${cur}>${ico}<span>${esc(n.label)}</span><span class="cnt">${counts[n.route] || ""}</span></a>`;
- }).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 `<div class="view-head"><h1 class="skel skel-line" style="width:120px;height:24px"></h1></div>
- ${UI.skeleton("metrics")}<div style="height:28px"></div>${UI.skeleton("rows", 5)}`;
- }
- function errorHTML(msg, what) {
- return `<div class="view-head"><div><h1>${what ? esc(what) + "没有加载出来" : "数据没有取到"}</h1><p class="lede">页面不会用假数据顶替,也不会把"读取失败"伪装成"没有内容"。请确认网络与数据源配置后重试。</p></div></div>
- <div class="error-box"><p><b>读取 Parse 失败:</b>${esc(msg)}</p>
- <p style="margin-top:8px">检查 <code>js/config.js</code> 中的 <code>parseBase</code> 与 <code>appId</code>。</p>
- <p style="margin-top:14px"><button class="btn" data-act="refresh">${UI.icons.refresh} 重试</button></p></div>`;
- }
- 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 `<div style="max-width:980px">${window.KF_DETAIL.artifact(db, h.arg, { inline: true }).html}</div>`;
- 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 }) };
- })();
|