projects.js 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  1. /* views/projects.js —— 项目池与项目详情。 */
  2. (function () {
  3. "use strict";
  4. const T = window.KF_TAXONOMY;
  5. const U = window.KF_UTIL;
  6. const Q = window.KF_QUERY;
  7. const UI = window.KF_UI;
  8. const esc = U.esc;
  9. const SORTS = {
  10. newest: { label: "最近活动", fn: (a, b) => new Date(b.lastActiveAt || 0) - new Date(a.lastActiveAt || 0) },
  11. score: { label: "评分", fn: (a, b) => U.cmpNum(a.score, b.score, -1) },
  12. priority: { label: "优先级", fn: (a, b) => {
  13. const r = (p) => (p === "S" ? 0 : p === "A" ? 1 : p === "B" ? 2 : 3);
  14. return r(a.priority) - r(b.priority) || U.cmpNum(a.score, b.score, -1);
  15. } },
  16. artifactCount: { label: "成果数", fn: (a, b) => U.cmpNum(a.artifactCount, b.artifactCount, -1) }
  17. };
  18. /* 项目层的优先级/品类可能为空(早期数据):用"未定"作为明确的筛选值,计数里列出来,
  19. * 而不是让它们从筛选条里悄悄消失。 */
  20. const UNSET = "未定";
  21. function rowOf(p) {
  22. return {
  23. ...p,
  24. evidenceKey: "none",
  25. ownerName: p.ownerName,
  26. priority: T.PRIORITIES.includes(p.priority) ? p.priority : UNSET,
  27. category: p.category || UNSET
  28. };
  29. }
  30. function facet(rows, c, key, keyFn) {
  31. const m = new Map();
  32. for (const r of rows) {
  33. if (!Q.test(r, { ...c, [key]: [] })) continue;
  34. const k = keyFn(r);
  35. if (k != null) m.set(k, (m.get(k) || 0) + 1);
  36. }
  37. return m;
  38. }
  39. function row(p) {
  40. return `<tr class="clickable" data-act="nav" data-href="#/project/${encodeURIComponent(p.id)}">
  41. <td class="cell-title"><span class="ttl">${esc(p.title)}</span><span class="sub">${esc(p.category || "品类未定")}${p.summary ? " · " + esc(p.summary) : ""}</span></td>
  42. <td data-l="成员">${UI.person(p.ownerName)}</td>
  43. <td data-l="阶段">${UI.stageCell(p.stageObj, p.stage)}</td>
  44. <td data-l="优先级">${UI.priorityBadge(p.priority)}</td>
  45. <td data-l="状态">${UI.stateBadge(p.status, p.statusLabel)}</td>
  46. <td data-l="评分" class="num-cell">${p.score == null ? "—" : p.score.toFixed(1)}</td>
  47. <td data-l="成果" class="num-cell">${p.artifactCount}</td>
  48. <td data-l="最近活动" class="when">${esc(U.fmtAgo(p.lastActiveAt))}</td>
  49. </tr>`;
  50. }
  51. function listView(db, c) {
  52. const all = db.projects.map(rowOf);
  53. const rows = all.filter((r) => Q.test(r, c));
  54. const sortKey = SORTS[c.sort] ? c.sort : "newest";
  55. const sorted = rows.slice().sort(SORTS[sortKey].fn);
  56. const pc = facet(all, c, "priority", (r) => r.priority);
  57. const sc = facet(all, c, "stage", (r) => (r.stageObj ? r.stageObj.code : null));
  58. const cats = [...new Set(all.map((r) => r.category).filter(Boolean))].sort((a, b) => a.localeCompare(b, "zh"));
  59. const cc = facet(all, c, "category", (r) => r.category);
  60. const mc = facet(all, c, "member", (r) => r.ownerName);
  61. const dims = [
  62. { key: "priority", label: "优先级", options: T.PRIORITIES.map((v) => ({ value: v, label: v, count: pc.get(v) || 0 })) },
  63. { key: "stage", label: "阶段", options: T.STAGES.map((s) => ({ value: s.code, label: s.code, count: sc.get(s.code) || 0 })) },
  64. { key: "category", label: "品类", options: cats.map((v) => ({ value: v, label: v, count: cc.get(v) || 0 })) },
  65. { key: "member", label: "成员", multi: true, options: [...mc.entries()].map(([v, n]) => ({ value: v, label: v, count: n })) }
  66. ];
  67. dims.forEach((d) => { d.active = c[d.key] || []; });
  68. let body;
  69. if (!all.length) {
  70. body = UI.empty({ title: "项目池是空的", body: "学员在 S3 以后从机会卡立项,KfProject 会出现新项目;这里会显示它的阶段、优先级、评分和成果数。" });
  71. } else if (!sorted.length) {
  72. body = UI.empty({ title: "当前条件下没有项目", body: `项目池有 ${all.length} 个,但没有同时满足全部条件的。`, actions: [{ label: "清除全部筛选", act: "clear-filters" }] });
  73. } else {
  74. body = `<div class="panel tbl-wrap"><table class="tbl"><thead><tr>
  75. <th>项目</th><th>负责人</th><th>阶段</th><th>优先级</th><th>状态</th><th class="r">评分</th><th class="r">成果</th><th>最近活动</th>
  76. </tr></thead><tbody>${sorted.map(row).join("")}</tbody></table></div>`;
  77. }
  78. return `<div class="view-head"><div>
  79. <h1>项目</h1><p class="lede">每个项目是一个学员从机会卡推进出的提案。评分右上对齐,方便横向比较。</p>
  80. </div>
  81. <div class="aside"><div class="seg" role="group" aria-label="排序">
  82. ${Object.entries(SORTS).map(([k, s]) => `<button class="${k === sortKey ? "on" : ""}" data-act="sort" data-sort="${k}">${esc(s.label)}</button>`).join("")}
  83. </div></div>
  84. </div>
  85. <div data-filterbar>${UI.filterBar(dims, c, rows.length, "个项目")}</div>
  86. <div data-active>${UI.activeLine(c)}</div>
  87. <div data-list>${body}</div>`;
  88. }
  89. /* ---------- 详情 ---------- */
  90. function detail(db, id) {
  91. const p = db.projectById[id];
  92. if (!p) return null;
  93. const arts = p.artifactIds.map((x) => db.artifactById[x]).sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
  94. const withQuote = arts.filter((a) => a.quoteCount > 0).length;
  95. const withOwner = arts.filter((a) => a.ownerKey).length;
  96. const citedInsights = db.insights.filter((i) => i.artifactIds.some((x) => p.artifactIds.includes(x)));
  97. const head = `<div class="flat detail-head">
  98. <div>
  99. <div class="kicker">${UI.stateBadge(p.status, p.statusLabel)}<span>创建于 ${esc(U.fmtDate(p.createdAt, true))}</span></div>
  100. <h1>${esc(p.title)}</h1>
  101. <div class="kv">
  102. <div>负责人<b>${esc(p.ownerName || "—")}</b></div>
  103. <div>当前阶段<b>${esc(p.stage)}</b></div>
  104. <div>优先级<b>${esc(p.priority || "—")}</b></div>
  105. <div>评分<b class="num">${p.score == null ? "—" : p.score.toFixed(1)}</b></div>
  106. <div>品类<b>${esc(p.category || "待定")}</b></div>
  107. </div>
  108. </div>
  109. <div class="integrity" aria-label="出身完整性">
  110. <div class="row"><span>成果数</span><span>${arts.length}</span></div>
  111. <div class="row ${withOwner < arts.length ? "bad" : ""}"><span>带出身(谁/会话)</span><span>${withOwner} / ${arts.length}</span></div>
  112. <div class="row ${withQuote < arts.length ? "bad" : ""}"><span>带原文出处</span><span>${withQuote} / ${arts.length}</span></div>
  113. <div class="row"><span>被归纳引用</span><span>${citedInsights.length} 条</span></div>
  114. </div>
  115. </div>`;
  116. const list = arts.length ? `<div class="panel tbl-wrap"><table class="tbl"><thead><tr>
  117. <th>成果</th><th>阶段</th><th>层</th><th>证据</th><th>提交时间</th></tr></thead><tbody>
  118. ${arts.map((a) => `<tr class="clickable" data-act="open-artifact" data-id="${esc(a.id)}">
  119. <td class="cell-title"><span class="ttl">${esc(a.title)}</span><span class="sub">${esc(a.typeLabel)}</span></td>
  120. <td data-l="阶段">${UI.stageCell(a.stageObj, a.stage)}</td>
  121. <td data-l="层">${UI.layerBadge(a.layer)}</td>
  122. <td data-l="证据">${UI.evidenceBadge(a)}</td>
  123. <td data-l="提交" class="when">${esc(U.fmtDate(a.createdAt, true))}</td></tr>`).join("")}
  124. </tbody></table></div>`
  125. : UI.empty({ sm: true, title: "这个项目还没有成果", body: "学员在这个项目下的对话产出会在这里依次出现,每一条都带阶段和证据情况。" });
  126. const ins = citedInsights.length ? `<div class="panel">${citedInsights.map((i) => `
  127. <div class="insight" style="grid-template-columns:minmax(0,1fr)">
  128. <div class="insight-top"><h4 data-act="open-insight" data-id="${esc(i.id)}">${esc(i.title)}</h4></div>
  129. <div class="meta"><span>${i.ownerCount} 人提到</span><span>${i.quoteCount} 条原文</span><span>引用本项 ${i.artifactIds.filter((x) => p.artifactIds.includes(x)).length} 条记录</span></div>
  130. </div>`).join("")}</div>`
  131. : UI.empty({ sm: true, title: "尚未被归纳引用", body: "当本项目的成果足够支撑某个结论时,归纳区会指向这里的记录。" });
  132. return `<div>
  133. <div style="margin-bottom:18px">${UI.crumb([{ label: "项目", href: "#/projects" }, { label: p.title }])}</div>
  134. ${head}
  135. <div class="sec"><h2>成果时间线</h2><span class="hint">${arts.length} 条</span></div>
  136. ${list}
  137. <div class="sec"><h2>相关归纳</h2></div>
  138. ${ins}
  139. ${p.summary ? `<div class="sec"><h2>项目摘要</h2></div><div class="flat panel-pad prose">${esc(p.summary)}</div>` : ""}
  140. </div>`;
  141. }
  142. window.KF_VIEW_PROJECTS = { listView, detail, SORTS };
  143. })();