pool.js 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
  1. /* views/pool.js —— 机会卡片池(组织资产)。
  2. *
  3. * 这一页回答:"研发中心现在手上有哪些机会、值不值得做、谁在什么时候用什么方式送上来的。"
  4. * 卡片形态(不是列表行);可筛(优先级/阶段/品类/状态/上传人)、可排(优先级/评分/最新)、
  5. * 可点评分(多人平均)、可改状态(候选/立项/否决,落库后刷新保持)。
  6. * 筛选/排序/打开的卡都写进 URL;评分/状态落库,不做仅本地的假操作。
  7. */
  8. (function () {
  9. "use strict";
  10. const T = window.KF_TAXONOMY;
  11. const U = window.KF_UTIL;
  12. const UI = window.KF_UI;
  13. const OPP = window.KF_OPP;
  14. const esc = U.esc;
  15. const STATUS_FILTER = ["candidate", "approved", "rejected"];
  16. /* 数组.contains 有坑:CSS 里 .o-cat/.o-stage 是类名,这里绝不能把数组当字符串用。
  17. * 统一用 Set,筛选是精确命中,不做子串匹配。 */
  18. const has = (arr, v) => new Set(arr).has(v);
  19. function matches(o, p) {
  20. if (p.priority && p.priority.length && !has(p.priority, o.priority)) return false;
  21. if (p.stage && p.stage.length && !(o.stageObj && has(p.stage, o.stageObj.code))) return false;
  22. if (p.category && p.category.length && !has(p.category, o.category)) return false;
  23. if (p.status && p.status.length && !has(p.status, o.status)) return false;
  24. if (p.member && p.member.length && !has(p.member, o.upload.by)) return false;
  25. return true;
  26. }
  27. const list = (v) => (v ? String(v).split(",").filter(Boolean) : []);
  28. /* 概览条:池子总量 + 三个状态 + 平均分(给判断,不只给数字) */
  29. function summary(all) {
  30. const n = (k) => all.filter((o) => o.status === k).length;
  31. const scored = all.filter((o) => o.stats.n);
  32. const avg = scored.length ? Math.round(scored.reduce((s, o) => s + o.stats.avg, 0) / scored.length * 10) / 10 : null;
  33. const top = OPP.sortCards(all, "score")[0];
  34. return `<div class="metrics pool-metrics">
  35. <div class="metric"><div class="k">池中机会卡</div><div class="v">${all.length}<small>张</small></div>
  36. <div class="foot"><b>${n("candidate")}</b> 候选 · <em>${n("approved")}</em> 已立项 · ${n("rejected")} 已否决</div></div>
  37. <div class="metric"><div class="k">池均综合分</div><div class="v">${avg == null ? "—" : avg}</div>
  38. <div class="foot">${scored.length} / ${all.length} 张已有人评分</div></div>
  39. <div class="metric"><div class="k">最高分机会</div><div class="v" style="font-size:20px;letter-spacing:0;line-height:1.3">${top ? esc(top.title) : "—"}</div>
  40. <div class="foot">${top ? `综合 <b>${top.stats.avg}</b> · ${top.stats.n} 人评 · ${esc(top.stage)}` : ""}</div></div>
  41. <div class="metric"><div class="k">进池的人</div><div class="v">${new Set(all.map((o) => o.upload.by).filter(Boolean)).size}<small>人</small></div>
  42. <div class="foot">${new Set(all.map((o) => o.upload.agentName).filter(Boolean)).size} 个 Agent 参与上传</div></div>
  43. </div>`;
  44. }
  45. function facet(label, dim, values, active, counts, multi) {
  46. if (!values.length) return "";
  47. const chips = values.map((v) => {
  48. const on = active.includes(v.value);
  49. const zero = !counts.get(v.value) && !on;
  50. return `<button class="chip${zero ? " zero" : ""}" data-act="opp-facet" data-dim="${dim}" data-value="${esc(v.value)}"
  51. data-on="${on ? "1" : "0"}" data-zero="${zero ? "1" : "0"}">${esc(v.label)}<span class="n">${counts.get(v.value) || 0}</span></button>`;
  52. }).join("");
  53. return `<div class="fgroup ${multi ? "multi" : ""}" role="group" aria-label="${esc(label)}"><span class="fl">${esc(label)}</span>${chips}</div>`;
  54. }
  55. function filterBar(all, c, n) {
  56. const rest = (dim) => all.filter((o) => matches(o, { ...c, [dim]: [] }));
  57. const count = (rows, fn) => U.countBy(rows, fn);
  58. const pc = count(rest("priority"), (o) => o.priority);
  59. const sc = count(rest("stage"), (o) => (o.stageObj ? o.stageObj.code : ""));
  60. const cc = count(rest("category"), (o) => o.category);
  61. const stc = count(rest("status"), (o) => o.status);
  62. const mc = count(rest("member"), (o) => o.upload.by);
  63. const cats = [...new Set(all.map((o) => o.category).filter(Boolean))].sort((a, b) => a.localeCompare(b, "zh"));
  64. const uploaders = [...mc.entries()].sort((a, b) => b[1] - a[1]).map(([v, k]) => ({ value: v, label: v }));
  65. const any = ["priority", "stage", "category", "status", "member"].some((k) => c[k].length);
  66. return `<div class="filters" id="filterbar">
  67. ${facet("优先级", "priority", T.PRIORITIES.map((v) => ({ value: v, label: v })), c.priority, pc)}
  68. ${facet("阶段", "stage", T.STAGES.map((s) => ({ value: s.code, label: s.code })), c.stage, sc)}
  69. ${facet("品类", "category", cats.map((v) => ({ value: v, label: v })), c.category, cc)}
  70. ${facet("状态", "status", T.OPP_STATUS.map((s) => ({ value: s.key, label: s.label })), c.status, stc)}
  71. ${facet("上传人", "member", uploaders, c.member, mc, true)}
  72. <div class="spacer"></div>
  73. <span class="result-note">符合当前条件 <b data-rn>${n}</b> 张卡</span>
  74. ${any ? `<button class="clear-all" data-act="opp-clear">清除全部</button>` : ""}
  75. </div>`;
  76. }
  77. function render(db, params) {
  78. const all = db.opportunities;
  79. const c = {
  80. priority: list(params.priority), stage: list(params.stage), category: list(params.category),
  81. status: list(params.status).filter((s) => STATUS_FILTER.includes(s)), member: list(params.member)
  82. };
  83. const sortKey = OPP.SORTS[params.sort] ? params.sort : "prio";
  84. const rows = OPP.sortCards(all.filter((o) => matches(o, c)), sortKey);
  85. const head = `<div class="view-head"><div>
  86. <h1>机会卡片池</h1>
  87. <p class="lede">全员机会卡汇总入库:每张卡带评分、优先级与决策记录,构成研发中心的机会储备。卡片上的每一步都能追到「谁 · 哪个 Agent · 什么方式 · 什么时候」上传。</p>
  88. </div><div class="aside">${OPP.sortSeg(sortKey, params.sort)}</div></div>`;
  89. const body = !all.length
  90. ? UI.empty({
  91. title: "池子里还没有机会卡",
  92. body: "学员在 S3 机会识别阶段用主分身上传机会卡后,会自动进池。这里会按卡片形态排开,带评分、状态和决策记录。",
  93. actions: [{ label: "看阶段板块", href: "#/stages" }]
  94. })
  95. : !rows.length
  96. ? UI.empty({ title: "当前条件下没有卡", body: `池中 ${all.length} 张卡,没有同时满足全部条件的。单独去掉某一项后会有卡出现。`, actions: [{ label: "清除全部筛选", act: "opp-clear" }] })
  97. : `<ul class="ogrid">${rows.map((o) => OPP.card(o)).join("")}</ul>`;
  98. return `${head}
  99. ${summary(all)}
  100. <div data-filterbar>${filterBar(all, c, rows.length)}</div>
  101. <div data-active></div>
  102. <div data-list>${body}</div>
  103. <p class="tiny muted pool-foot">共 ${all.length} 张 · ${rows.length} 张符合当前条件 · 点卡片看详情、评分、改状态;平均分由全部评分现算,改状态会记进决策日志。</p>`;
  104. }
  105. window.KF_VIEW_POOL = { render, matches };
  106. })();