overview.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. /* views/overview.js —— 工作台首页(v4:替换 v3 的"总览仪表盘")
  2. *
  3. * 30–40 人共用的组织资产系统,每人进来先知道自己该干什么。三种视角同时在场(分区,不藏进 tab):
  4. * ① 给我的 当前身份:交了哪些、进了几张卡、还差哪个阶段
  5. * ② 全员的池子 池子规模、本周新增、最新进池 3 张(卡片形态)、评分最高
  6. * ③ 现场看板 五阶段 × 人数矩阵、谁卡住了(直接列名)、各板块产出
  7. * 外加一行「PPT 痛点 → 系统能力」对照:让客户一眼看出系统是照着他们的痛处做的。
  8. */
  9. (function () {
  10. "use strict";
  11. const T = window.KF_TAXONOMY;
  12. const U = window.KF_UTIL;
  13. const UI = window.KF_UI;
  14. const OPP = window.KF_OPP;
  15. const esc = U.esc;
  16. /* 五种成果类型 → 所属阶段口径(用于"还差哪个阶段") */
  17. const STAGE_PRODUCT = {
  18. S1: "品类市场分析", S2: "用户需求与痛点", S3: "机会点与机会卡", S4: "验证评分与结论", S5: "场景卖点与方案"
  19. };
  20. function weekAgo() { return Date.now() - 7 * 86400000; }
  21. /* ---------------- ① 给我的 ---------------- */
  22. function meOf(db) {
  23. const me = OPP.getMe();
  24. return me ? db.memberByName[me] || { name: me, artifactIds: [], stageObj: null, msgCount: 0, done: false, isGuest: true } : null;
  25. }
  26. function mine(db) {
  27. const me = meOf(db);
  28. const names = OPP.meCandidates(db);
  29. const who = `<div class="me-bar">
  30. <span class="avatar lg" aria-hidden="true">${esc(me ? U.initials(me.name) : "?")}</span>
  31. <div><div class="tb-label">当前身份</div>
  32. <label class="me-pick"><span class="sr-only">选择"我"是谁</span>
  33. <select id="me-select" data-act="me-select" aria-label="选择当前身份">
  34. <option value="">未选择(选一个,看属于你的视角)</option>
  35. ${names.map((n) => `<option value="${esc(n)}"${me && me.name === n ? " selected" : ""}>${esc(n)}${db.memberByName[n] ? "" : "(不在会话表)"}</option>`).join("")}
  36. </select>
  37. </label>
  38. </div></div>`;
  39. if (!me) {
  40. return `<section class="wb-sec" id="sec-me" aria-labelledby="h-me">
  41. <div class="sec"><h2 id="h-me">给我的</h2><span class="hint">现场每人选一次自己,之后刷新保持</span></div>
  42. <div class="wb-me">
  43. ${who}
  44. <p class="wb-empty-me">还没选身份。选完之后这里会显示:<b>你交了几份成果</b>、<b>有几张卡进了池子</b>、<b>还差哪个阶段没交</b>。</p>
  45. <div class="me-preview">${names.slice(0, 12).map((n) => `<span class="me-chip">${esc(n)}</span>`).join("")}${names.length > 12 ? `<span class="me-chip">…共 ${names.length} 人</span>` : ""}</div>
  46. </div>
  47. </section>`;
  48. }
  49. const arts = me.artifactIds.map((id) => db.artifactById[id]).filter(Boolean);
  50. const myOpps = db.opportunities.filter((o) => o.upload.by === me.name);
  51. const myScores = db.opportunities.filter((o) => o.stats.byUser[me.name]);
  52. /* 每个阶段是否交过:按成果的 stage 字段;S3 另外看有没有机会卡进池 */
  53. const stageRows = T.STAGES.map((s) => {
  54. const a = arts.filter((x) => x.stageObj && x.stageObj.no === s.no);
  55. const o = myOpps.filter((x) => x.stageObj && x.stageObj.no === s.no);
  56. const cur = me.stageObj && me.stageObj.no === s.no;
  57. const done = a.length > 0 || o.length > 0;
  58. const state = done ? "done" : cur ? "cur" : "todo";
  59. return `<tr class="me-stage ${state}">
  60. <td class="cell-title"><span class="ttl">${esc(s.label)}</span><span class="sub">${esc(STAGE_PRODUCT[s.code])}</span></td>
  61. <td data-l="状态"><span class="state"><span class="pdot ${done ? "done" : cur ? "active" : "idle"}"></span>${done ? "已交" : cur ? "进行中 · 未交" : "未到 / 未交"}</span></td>
  62. <td data-l="我的成果" class="num-cell">${a.length}</td>
  63. <td data-l="我的机会卡" class="num-cell">${o.length}</td>
  64. </tr>`;
  65. }).join("");
  66. const missing = T.STAGES.filter((s) => !arts.some((x) => x.stageObj && x.stageObj.no === s.no) && !myOpps.some((x) => x.stageObj && x.stageObj.no === s.no));
  67. const nextMissing = missing.find((s) => !me.stageObj || s.no >= me.stageObj.no) || missing[0];
  68. const recent = arts.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)).slice(0, 3);
  69. return `<section class="wb-sec" id="sec-me" aria-labelledby="h-me">
  70. <div class="sec"><h2 id="h-me">给我的</h2><span class="hint">${esc(me.name)}的贡献</span></div>
  71. <div class="wb-me">
  72. ${who}
  73. <div class="wb-me-stats">
  74. <div class="me-stat"><b class="num">${arts.length}</b><span>份成果已归集</span></div>
  75. <div class="me-stat"><b class="num">${myOpps.length}</b><span>张机会卡进了池子</span></div>
  76. <div class="me-stat"><b class="num">${myScores.length}</b><span>张卡由我评过分</span></div>
  77. <div class="me-stat ${nextMissing ? "warn" : ""}"><b class="num">${missing.length}</b><span>${missing.length ? `个阶段还没交${nextMissing ? `,下一个是 ${nextMissing.code}` : ""}` : "五个阶段都交过"}</span></div>
  78. </div>
  79. <div class="wb-me-body">
  80. <div class="panel tbl-wrap me-table"><table class="tbl"><thead><tr>
  81. <th>阶段</th><th>状态</th><th class="r">我的成果</th><th class="r">我的机会卡</th></tr></thead><tbody>${stageRows}</tbody></table></div>
  82. <div class="panel me-recent">
  83. <div class="panel-head"><h3>我最近交的</h3><a class="hint" href="#/member/${encodeURIComponent(me.name)}">我的成员页</a></div>
  84. ${recent.length ? recent.map((a) => `<button class="me-art" data-act="open-artifact" data-id="${esc(a.id)}">
  85. <span class="me-art-t">${esc(a.shortTitle)}</span>
  86. <span class="me-art-m">${esc(a.typeLabel)} · ${esc(a.stageObj ? a.stageObj.code : "—")} · ${esc(U.fmtAgo(a.createdAt))}</span></button>`).join("")
  87. : `<p class="tiny muted" style="padding:14px 22px">还没有成果。在分身上传后会出现在这里。</p>`}
  88. </div>
  89. </div>
  90. </div>
  91. </section>`;
  92. }
  93. /* ---------------- ② 全员的池子 ---------------- */
  94. function pool(db) {
  95. const all = db.opportunities;
  96. const fresh = all.filter((o) => o.upload.at && new Date(o.upload.at).getTime() >= weekAgo());
  97. const latest = OPP.sortCards(all, "new").slice(0, 3);
  98. const best = all.filter((o) => o.stats.n).sort((a, b) => b.stats.avg - a.stats.avg)[0];
  99. const byStatus = (k) => all.filter((o) => o.status === k).length;
  100. return `<section class="wb-sec" id="sec-pool" aria-labelledby="h-pool">
  101. <div class="sec"><h2 id="h-pool">全员的池子</h2><span class="hint">组织视角 · 机会储备</span><a class="more" href="#/pool">进入卡片池 →</a></div>
  102. <div class="pool-hero glass-3">
  103. <div class="pool-num"><b class="num glow">${all.length}</b><span>张机会卡</span>
  104. <em>${byStatus("candidate")} 候选 · ${byStatus("approved")} 立项 · ${byStatus("rejected")} 否决</em></div>
  105. <div class="pool-stat"><b class="num">${fresh.length}</b><span>本周新增</span></div>
  106. <div class="pool-stat"><b class="num">${new Set(all.map((o) => o.upload.by).filter(Boolean)).size}</b><span>进池的人</span></div>
  107. <div class="pool-stat"><b class="num">${new Set(all.map((o) => o.upload.agentName).filter(Boolean)).size}</b><span>参与上传的 Agent</span></div>
  108. <div class="pool-best">${best ? `<span>评分最高</span><b>${esc(best.title)}</b><em class="num">${best.stats.avg} 分 · ${best.stats.n} 人评</em>` : ""}</div>
  109. </div>
  110. <div class="sec sec-sub"><h3>最新进池的 3 张</h3><span class="hint">按上传时间</span></div>
  111. ${latest.length ? `<ul class="ogrid ogrid-3">${latest.map((o) => OPP.card(o)).join("")}</ul>`
  112. : `<div class="panel panel-pad tiny muted">池子还是空的:S3 阶段学员上传机会卡后会出现在这里。</div>`}
  113. </section>`;
  114. }
  115. /* ---------------- ③ 现场看板 ---------------- */
  116. function board(db) {
  117. const members = db.members;
  118. const total = members.length;
  119. /* 矩阵:行=阶段,列=S1..S5 到达过 / 当前停留;格内是人数,点开该阶段 */
  120. const reached = (s) => members.filter((m) => m.stageObj && m.stageObj.no >= s.no).length;
  121. const stay = (s) => members.filter((m) => m.stageObj && m.stageObj.no === s.no).length;
  122. const cellMax = Math.max(1, ...T.STAGES.map((s) => reached(s)));
  123. const matrix = `<table class="tbl matrix"><thead><tr>
  124. <th>阶段</th>${T.STAGES.map((s) => `<th class="r" title="${esc(s.name)}">${s.code}</th>`).join("")}<th class="matrix-l">到达进度</th></tr></thead><tbody>
  125. ${T.STAGES.map((s) => `<tr class="clickable" data-act="nav" data-href="#/stage/${s.code}">
  126. <td class="cell-title"><span class="ttl">${esc(s.label)}</span><span class="sub">停留 ${stay(s)} 人 · 产出 ${db.byStage[s.no - 1].artifacts.length} 条成果${db.byStage[s.no - 1].opportunities.length ? ` + ${db.byStage[s.no - 1].opportunities.length} 张卡` : ""}</span></td>
  127. ${T.STAGES.map((t) => t.no <= s.no
  128. ? `<td class="r mcell${t.no === s.no ? " cur" : ""}">${stay(t)}<i class="mbar" style="height:${Math.max(6, Math.round(reached(s) / cellMax * 100))}%" aria-hidden="true"></i></td>`
  129. : `<td class="r mcell off">·</td>`).join("")}
  130. <td class="matrix-l"><span class="mbar-h"><i style="width:${Math.round(reached(s) / Math.max(1, total) * 100)}%"></i></span><span class="num tiny">${reached(s)}/${total} 人</span></td>
  131. </tr>`).join("")}
  132. </tbody></table>`;
  133. const stuck = members.filter((m) => m.stuck).sort((a, b) => b.stuckHours - a.stuckHours);
  134. const idle = members.filter((m) => !m.stuck && !m.done);
  135. const stuckHtml = stuck.length
  136. ? `<div class="stuck-list">${stuck.map((m) => `<a class="stuck who" href="#/member/${encodeURIComponent(m.name)}">
  137. <span class="avatar" aria-hidden="true">${esc(U.initials(m.name))}</span>
  138. <span class="stuck-n">${esc(m.name)}</span>
  139. <span class="stuck-s">${esc(m.stageObj ? m.stageObj.code : "—")} · ${m.stuckHours} 小时没交</span>
  140. <span class="stuck-m">${m.msgCount} 条对话 · ${esc(m.groupId || "未分组")}</span></a>`).join("")}</div>`
  141. : `<div class="stuck-ok">${UI.icons.info}<span>现在没有卡住的人(在对话 ≥5 条、超过 ${db.stuckHours} 小时没新成果才算卡住)。</span></div>`;
  142. const idleLine = idle.length
  143. ? `<p class="tiny muted" style="margin-top:10px">另有 ${idle.length} 人未提交但还在活跃:${idle.map((m) => esc(m.name)).join("、")}</p>` : "";
  144. const done = members.filter((m) => m.done).length;
  145. return `<section class="wb-sec" id="sec-board" aria-labelledby="h-board">
  146. <div class="sec"><h2 id="h-board">现场看板</h2><span class="hint">讲师视角 · 30–40 人同场</span><a class="more" href="#/stages">阶段详情 →</a></div>
  147. <div class="board-strip">
  148. <div class="bs"><b class="num">${total}</b><span>在场成员</span></div>
  149. <div class="bs"><b class="num">${done}</b><span>已交成果</span></div>
  150. <div class="bs warn"><b class="num">${members.length - done - stuck.length}</b><span>在对话、未交</span></div>
  151. <div class="bs risk"><b class="num">${stuck.length}</b><span>卡住(&gt;${db.stuckHours}h)</span></div>
  152. </div>
  153. <div class="wb-board">
  154. <div class="panel tbl-wrap">${matrix}</div>
  155. <div class="panel panel-pad stuck-box">
  156. <div class="panel-head" style="padding:0 0 12px"><h3>谁卡住了</h3><span class="hint">按停留时长</span></div>
  157. ${stuckHtml}${idleLine}
  158. </div>
  159. </div>
  160. </section>`;
  161. }
  162. /* ---------------- 分板块产出 ---------------- */
  163. function boards(db) {
  164. const cards = db.byStage.map(({ stage, artifacts, opportunities }) => {
  165. const list = artifacts.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)).slice(0, 3);
  166. const people = [...new Set(artifacts.map((a) => a.ownerName).filter(Boolean))];
  167. const byAgent = U.countBy(artifacts, (a) => a.upload.agentName || "未记录 Agent");
  168. return `<a class="stage-board" href="#/stage/${stage.code}" aria-label="${esc(stage.label)}:${artifacts.length} 条成果">
  169. <div class="sb-top"><span class="sb-no">${stage.code}</span><h3>${esc(stage.label)}</h3>
  170. <span class="sb-n"><b class="num">${artifacts.length}</b><small>条成果</small>${opportunities.length ? `<em class="num">${opportunities.length} 张卡</em>` : ""}</span></div>
  171. <div class="sb-produces">产出:${esc(STAGE_PRODUCT[stage.code])}</div>
  172. <ol class="sb-list">${list.length ? list.map((a) => `<li>
  173. <span class="sb-t">${esc(a.shortTitle)}</span>
  174. <span class="sb-m">${esc(a.ownerName || "—")}<i aria-hidden="true">·</i>${esc(a.upload.agentName ? OPP.agentName(a.upload.agentName) : "Agent 未记录")}<i aria-hidden="true">·</i>${esc(U.fmtAgo(a.createdAt))}</span>
  175. </li>`).join("") : `<li class="none">还没有成果入库</li>`}</ol>
  176. <div class="sb-foot">${people.length} 人产出${byAgent.size ? ` · ${[...byAgent.keys()].map((k) => esc(OPP.agentName(k))).join("、")}` : ""}</div>
  177. </a>`;
  178. }).join("");
  179. return `<section class="wb-sec" id="sec-boards" aria-labelledby="h-boards">
  180. <div class="sec"><h2 id="h-boards">分板块产出</h2><span class="hint">五个阶段各自的沉淀 · 谁产的</span></div>
  181. <div class="sb-grid">${cards}</div>
  182. </section>`;
  183. }
  184. /* ---------------- 痛点 → 系统能力 ---------------- */
  185. const PAIN = [
  186. ["① 信息收集繁琐分散", "原材料库 / 调研入池:资讯归集到一处,Agent 自动入库", "#/sources"],
  187. ["② 机会识别凭经验", "机会卡片池:可评分、可排序、可决策", "#/pool"],
  188. ["③ 卖点提炼缺抓手", "痛点 ↔ 卖点:报告里的交叉表,由结论追到原文", "#/reportIndex"],
  189. ["④ 概念验证无量化", "评分模型:五维加权,卡片上直接有分", "#/pool"],
  190. ["⑤ 琐事挤占思考", "上传自动化:Agent 上传、带来源元数据,人只做判断", "#/stages"]
  191. ];
  192. function pains() {
  193. return `<section class="wb-sec" id="sec-pain" aria-labelledby="h-pain">
  194. <div class="sec"><h2 id="h-pain">痛点 → 系统能力</h2><span class="hint">PPT 里的五句话,在这个系统里各自落在哪</span></div>
  195. <ol class="pain-row">${PAIN.map(([p, cap, href], i) => `<li><a href="${href}">
  196. <span class="pain-no num">0${i + 1}</span>
  197. <span class="pain-p">${esc(p)}</span>
  198. <span class="pain-arrow" aria-hidden="true">→</span>
  199. <span class="pain-c">${esc(cap)}</span></a></li>`).join("")}</ol>
  200. </section>`;
  201. }
  202. function render(db) {
  203. return `<div class="view wb">
  204. <div class="view-head"><div>
  205. <h1>工作台</h1>
  206. <p class="lede">${db.members.length} 人共用一套数字分身。这一屏先回答三件事:<b>我要交什么</b>、<b>池子里有什么</b>、<b>现场谁卡住了</b>。</p>
  207. </div><div class="aside"><a class="btn btn-accent" href="#/pool">机会卡片池 ${UI.icons.arrow}</a></div></div>
  208. ${mine(db)}
  209. ${pool(db)}
  210. ${board(db)}
  211. ${boards(db)}
  212. ${pains()}
  213. </div>`;
  214. }
  215. window.KF_VIEW_OVERVIEW = { render };
  216. })();