| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232 |
- /* views/overview.js —— 工作台首页(v4:替换 v3 的"总览仪表盘")
- *
- * 30–40 人共用的组织资产系统,每人进来先知道自己该干什么。三种视角同时在场(分区,不藏进 tab):
- * ① 给我的 当前身份:交了哪些、进了几张卡、还差哪个阶段
- * ② 全员的池子 池子规模、本周新增、最新进池 3 张(卡片形态)、评分最高
- * ③ 现场看板 五阶段 × 人数矩阵、谁卡住了(直接列名)、各板块产出
- * 外加一行「PPT 痛点 → 系统能力」对照:让客户一眼看出系统是照着他们的痛处做的。
- */
- (function () {
- "use strict";
- const T = window.KF_TAXONOMY;
- const U = window.KF_UTIL;
- const UI = window.KF_UI;
- const OPP = window.KF_OPP;
- const esc = U.esc;
- /* 五种成果类型 → 所属阶段口径(用于"还差哪个阶段") */
- const STAGE_PRODUCT = {
- S1: "品类市场分析", S2: "用户需求与痛点", S3: "机会点与机会卡", S4: "验证评分与结论", S5: "场景卖点与方案"
- };
- function weekAgo() { return Date.now() - 7 * 86400000; }
- /* ---------------- ① 给我的 ---------------- */
- function meOf(db) {
- const me = OPP.getMe();
- return me ? db.memberByName[me] || { name: me, artifactIds: [], stageObj: null, msgCount: 0, done: false, isGuest: true } : null;
- }
- function mine(db) {
- const me = meOf(db);
- const names = OPP.meCandidates(db);
- const who = `<div class="me-bar">
- <span class="avatar lg" aria-hidden="true">${esc(me ? U.initials(me.name) : "?")}</span>
- <div><div class="tb-label">当前身份</div>
- <label class="me-pick"><span class="sr-only">选择"我"是谁</span>
- <select id="me-select" data-act="me-select" aria-label="选择当前身份">
- <option value="">未选择(选一个,看属于你的视角)</option>
- ${names.map((n) => `<option value="${esc(n)}"${me && me.name === n ? " selected" : ""}>${esc(n)}${db.memberByName[n] ? "" : "(不在会话表)"}</option>`).join("")}
- </select>
- </label>
- </div></div>`;
- if (!me) {
- return `<section class="wb-sec" id="sec-me" aria-labelledby="h-me">
- <div class="sec"><h2 id="h-me">给我的</h2><span class="hint">现场每人选一次自己,之后刷新保持</span></div>
- <div class="wb-me">
- ${who}
- <p class="wb-empty-me">还没选身份。选完之后这里会显示:<b>你交了几份成果</b>、<b>有几张卡进了池子</b>、<b>还差哪个阶段没交</b>。</p>
- <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>
- </div>
- </section>`;
- }
- const arts = me.artifactIds.map((id) => db.artifactById[id]).filter(Boolean);
- const myOpps = db.opportunities.filter((o) => o.upload.by === me.name);
- const myScores = db.opportunities.filter((o) => o.stats.byUser[me.name]);
- /* 每个阶段是否交过:按成果的 stage 字段;S3 另外看有没有机会卡进池 */
- const stageRows = T.STAGES.map((s) => {
- const a = arts.filter((x) => x.stageObj && x.stageObj.no === s.no);
- const o = myOpps.filter((x) => x.stageObj && x.stageObj.no === s.no);
- const cur = me.stageObj && me.stageObj.no === s.no;
- const done = a.length > 0 || o.length > 0;
- const state = done ? "done" : cur ? "cur" : "todo";
- return `<tr class="me-stage ${state}">
- <td class="cell-title"><span class="ttl">${esc(s.label)}</span><span class="sub">${esc(STAGE_PRODUCT[s.code])}</span></td>
- <td data-l="状态"><span class="state"><span class="pdot ${done ? "done" : cur ? "active" : "idle"}"></span>${done ? "已交" : cur ? "进行中 · 未交" : "未到 / 未交"}</span></td>
- <td data-l="我的成果" class="num-cell">${a.length}</td>
- <td data-l="我的机会卡" class="num-cell">${o.length}</td>
- </tr>`;
- }).join("");
- 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));
- const nextMissing = missing.find((s) => !me.stageObj || s.no >= me.stageObj.no) || missing[0];
- const recent = arts.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)).slice(0, 3);
- return `<section class="wb-sec" id="sec-me" aria-labelledby="h-me">
- <div class="sec"><h2 id="h-me">给我的</h2><span class="hint">${esc(me.name)}的贡献</span></div>
- <div class="wb-me">
- ${who}
- <div class="wb-me-stats">
- <div class="me-stat"><b class="num">${arts.length}</b><span>份成果已归集</span></div>
- <div class="me-stat"><b class="num">${myOpps.length}</b><span>张机会卡进了池子</span></div>
- <div class="me-stat"><b class="num">${myScores.length}</b><span>张卡由我评过分</span></div>
- <div class="me-stat ${nextMissing ? "warn" : ""}"><b class="num">${missing.length}</b><span>${missing.length ? `个阶段还没交${nextMissing ? `,下一个是 ${nextMissing.code}` : ""}` : "五个阶段都交过"}</span></div>
- </div>
- <div class="wb-me-body">
- <div class="panel tbl-wrap me-table"><table class="tbl"><thead><tr>
- <th>阶段</th><th>状态</th><th class="r">我的成果</th><th class="r">我的机会卡</th></tr></thead><tbody>${stageRows}</tbody></table></div>
- <div class="panel me-recent">
- <div class="panel-head"><h3>我最近交的</h3><a class="hint" href="#/member/${encodeURIComponent(me.name)}">我的成员页</a></div>
- ${recent.length ? recent.map((a) => `<button class="me-art" data-act="open-artifact" data-id="${esc(a.id)}">
- <span class="me-art-t">${esc(a.shortTitle)}</span>
- <span class="me-art-m">${esc(a.typeLabel)} · ${esc(a.stageObj ? a.stageObj.code : "—")} · ${esc(U.fmtAgo(a.createdAt))}</span></button>`).join("")
- : `<p class="tiny muted" style="padding:14px 22px">还没有成果。在分身上传后会出现在这里。</p>`}
- </div>
- </div>
- </div>
- </section>`;
- }
- /* ---------------- ② 全员的池子 ---------------- */
- function pool(db) {
- const all = db.opportunities;
- const fresh = all.filter((o) => o.upload.at && new Date(o.upload.at).getTime() >= weekAgo());
- const latest = OPP.sortCards(all, "new").slice(0, 3);
- const best = all.filter((o) => o.stats.n).sort((a, b) => b.stats.avg - a.stats.avg)[0];
- const byStatus = (k) => all.filter((o) => o.status === k).length;
- return `<section class="wb-sec" id="sec-pool" aria-labelledby="h-pool">
- <div class="sec"><h2 id="h-pool">全员的池子</h2><span class="hint">组织视角 · 机会储备</span><a class="more" href="#/pool">进入卡片池 →</a></div>
- <div class="pool-hero glass-3">
- <div class="pool-num"><b class="num glow">${all.length}</b><span>张机会卡</span>
- <em>${byStatus("candidate")} 候选 · ${byStatus("approved")} 立项 · ${byStatus("rejected")} 否决</em></div>
- <div class="pool-stat"><b class="num">${fresh.length}</b><span>本周新增</span></div>
- <div class="pool-stat"><b class="num">${new Set(all.map((o) => o.upload.by).filter(Boolean)).size}</b><span>进池的人</span></div>
- <div class="pool-stat"><b class="num">${new Set(all.map((o) => o.upload.agentName).filter(Boolean)).size}</b><span>参与上传的 Agent</span></div>
- <div class="pool-best">${best ? `<span>评分最高</span><b>${esc(best.title)}</b><em class="num">${best.stats.avg} 分 · ${best.stats.n} 人评</em>` : ""}</div>
- </div>
- <div class="sec sec-sub"><h3>最新进池的 3 张</h3><span class="hint">按上传时间</span></div>
- ${latest.length ? `<ul class="ogrid ogrid-3">${latest.map((o) => OPP.card(o)).join("")}</ul>`
- : `<div class="panel panel-pad tiny muted">池子还是空的:S3 阶段学员上传机会卡后会出现在这里。</div>`}
- </section>`;
- }
- /* ---------------- ③ 现场看板 ---------------- */
- function board(db) {
- const members = db.members;
- const total = members.length;
- /* 矩阵:行=阶段,列=S1..S5 到达过 / 当前停留;格内是人数,点开该阶段 */
- const reached = (s) => members.filter((m) => m.stageObj && m.stageObj.no >= s.no).length;
- const stay = (s) => members.filter((m) => m.stageObj && m.stageObj.no === s.no).length;
- const cellMax = Math.max(1, ...T.STAGES.map((s) => reached(s)));
- const matrix = `<table class="tbl matrix"><thead><tr>
- <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>
- ${T.STAGES.map((s) => `<tr class="clickable" data-act="nav" data-href="#/stage/${s.code}">
- <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>
- ${T.STAGES.map((t) => t.no <= s.no
- ? `<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>`
- : `<td class="r mcell off">·</td>`).join("")}
- <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>
- </tr>`).join("")}
- </tbody></table>`;
- const stuck = members.filter((m) => m.stuck).sort((a, b) => b.stuckHours - a.stuckHours);
- const idle = members.filter((m) => !m.stuck && !m.done);
- const stuckHtml = stuck.length
- ? `<div class="stuck-list">${stuck.map((m) => `<a class="stuck who" href="#/member/${encodeURIComponent(m.name)}">
- <span class="avatar" aria-hidden="true">${esc(U.initials(m.name))}</span>
- <span class="stuck-n">${esc(m.name)}</span>
- <span class="stuck-s">${esc(m.stageObj ? m.stageObj.code : "—")} · ${m.stuckHours} 小时没交</span>
- <span class="stuck-m">${m.msgCount} 条对话 · ${esc(m.groupId || "未分组")}</span></a>`).join("")}</div>`
- : `<div class="stuck-ok">${UI.icons.info}<span>现在没有卡住的人(在对话 ≥5 条、超过 ${db.stuckHours} 小时没新成果才算卡住)。</span></div>`;
- const idleLine = idle.length
- ? `<p class="tiny muted" style="margin-top:10px">另有 ${idle.length} 人未提交但还在活跃:${idle.map((m) => esc(m.name)).join("、")}</p>` : "";
- const done = members.filter((m) => m.done).length;
- return `<section class="wb-sec" id="sec-board" aria-labelledby="h-board">
- <div class="sec"><h2 id="h-board">现场看板</h2><span class="hint">讲师视角 · 30–40 人同场</span><a class="more" href="#/stages">阶段详情 →</a></div>
- <div class="board-strip">
- <div class="bs"><b class="num">${total}</b><span>在场成员</span></div>
- <div class="bs"><b class="num">${done}</b><span>已交成果</span></div>
- <div class="bs warn"><b class="num">${members.length - done - stuck.length}</b><span>在对话、未交</span></div>
- <div class="bs risk"><b class="num">${stuck.length}</b><span>卡住(>${db.stuckHours}h)</span></div>
- </div>
- <div class="wb-board">
- <div class="panel tbl-wrap">${matrix}</div>
- <div class="panel panel-pad stuck-box">
- <div class="panel-head" style="padding:0 0 12px"><h3>谁卡住了</h3><span class="hint">按停留时长</span></div>
- ${stuckHtml}${idleLine}
- </div>
- </div>
- </section>`;
- }
- /* ---------------- 分板块产出 ---------------- */
- function boards(db) {
- const cards = db.byStage.map(({ stage, artifacts, opportunities }) => {
- const list = artifacts.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)).slice(0, 3);
- const people = [...new Set(artifacts.map((a) => a.ownerName).filter(Boolean))];
- const byAgent = U.countBy(artifacts, (a) => a.upload.agentName || "未记录 Agent");
- return `<a class="stage-board" href="#/stage/${stage.code}" aria-label="${esc(stage.label)}:${artifacts.length} 条成果">
- <div class="sb-top"><span class="sb-no">${stage.code}</span><h3>${esc(stage.label)}</h3>
- <span class="sb-n"><b class="num">${artifacts.length}</b><small>条成果</small>${opportunities.length ? `<em class="num">${opportunities.length} 张卡</em>` : ""}</span></div>
- <div class="sb-produces">产出:${esc(STAGE_PRODUCT[stage.code])}</div>
- <ol class="sb-list">${list.length ? list.map((a) => `<li>
- <span class="sb-t">${esc(a.shortTitle)}</span>
- <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>
- </li>`).join("") : `<li class="none">还没有成果入库</li>`}</ol>
- <div class="sb-foot">${people.length} 人产出${byAgent.size ? ` · ${[...byAgent.keys()].map((k) => esc(OPP.agentName(k))).join("、")}` : ""}</div>
- </a>`;
- }).join("");
- return `<section class="wb-sec" id="sec-boards" aria-labelledby="h-boards">
- <div class="sec"><h2 id="h-boards">分板块产出</h2><span class="hint">五个阶段各自的沉淀 · 谁产的</span></div>
- <div class="sb-grid">${cards}</div>
- </section>`;
- }
- /* ---------------- 痛点 → 系统能力 ---------------- */
- const PAIN = [
- ["① 信息收集繁琐分散", "原材料库 / 调研入池:资讯归集到一处,Agent 自动入库", "#/sources"],
- ["② 机会识别凭经验", "机会卡片池:可评分、可排序、可决策", "#/pool"],
- ["③ 卖点提炼缺抓手", "痛点 ↔ 卖点:报告里的交叉表,由结论追到原文", "#/reportIndex"],
- ["④ 概念验证无量化", "评分模型:五维加权,卡片上直接有分", "#/pool"],
- ["⑤ 琐事挤占思考", "上传自动化:Agent 上传、带来源元数据,人只做判断", "#/stages"]
- ];
- function pains() {
- return `<section class="wb-sec" id="sec-pain" aria-labelledby="h-pain">
- <div class="sec"><h2 id="h-pain">痛点 → 系统能力</h2><span class="hint">PPT 里的五句话,在这个系统里各自落在哪</span></div>
- <ol class="pain-row">${PAIN.map(([p, cap, href], i) => `<li><a href="${href}">
- <span class="pain-no num">0${i + 1}</span>
- <span class="pain-p">${esc(p)}</span>
- <span class="pain-arrow" aria-hidden="true">→</span>
- <span class="pain-c">${esc(cap)}</span></a></li>`).join("")}</ol>
- </section>`;
- }
- function render(db) {
- return `<div class="view wb">
- <div class="view-head"><div>
- <h1>工作台</h1>
- <p class="lede">${db.members.length} 人共用一套数字分身。这一屏先回答三件事:<b>我要交什么</b>、<b>池子里有什么</b>、<b>现场谁卡住了</b>。</p>
- </div><div class="aside"><a class="btn btn-accent" href="#/pool">机会卡片池 ${UI.icons.arrow}</a></div></div>
- ${mine(db)}
- ${pool(db)}
- ${board(db)}
- ${boards(db)}
- ${pains()}
- </div>`;
- }
- window.KF_VIEW_OVERVIEW = { render };
- })();
|