opportunity.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  1. /* views/opportunity.js —— 机会卡:统一卡片渲染 + 评分/改状态/决策的写回。
  2. *
  3. * 卡片是一屏内读完的五要素(客户要求):
  4. * ① 关键信息:优先级 / 品类 / 阶段 → 机会名 → 一句话
  5. * ② 概览:综合评分(平均)/ 评分人数 / 证据强度 / 引用数
  6. * ③ 决策:结论 + 拍板人 + 时间(可展开理由)
  7. * ④ 来源:谁 · 哪个 Agent · 什么方式 · 什么时候
  8. * ⑤ 归属:哪个阶段板块、关联哪几条成果(可点开)
  9. *
  10. * 写回:前端只带公开的 App ID 直接写 Parse(库已允许)。改动先在本地应用(乐观),
  11. * 成功后用服务端返回的 updatedAt 回填;失败则回滚并提示,不假装成功。
  12. * 同一张卡的写操作串行,避免两次评分互相覆盖。
  13. */
  14. (function () {
  15. "use strict";
  16. const T = window.KF_TAXONOMY;
  17. const U = window.KF_UTIL;
  18. const UI = window.KF_UI;
  19. const CFG = window.KF_CONFIG;
  20. const esc = U.esc;
  21. /* ---------------- 身份:这台设备上"我"是谁 ---------------- */
  22. const ME_KEY = "kf.me";
  23. function getMe() {
  24. try { return localStorage.getItem(ME_KEY) || ""; } catch (e) { return ""; }
  25. }
  26. function setMe(name) {
  27. try { localStorage.setItem(ME_KEY, name); } catch (e) { /* 隐私模式:仅本次会话 */ }
  28. me = name || "";
  29. }
  30. let me = getMe();
  31. function meCandidates(db) {
  32. const names = new Set(db.members.map((m) => m.name).filter(Boolean));
  33. db.opportunities.forEach((o) => { if (o.upload.by) names.add(o.upload.by); });
  34. return [...names].sort((a, b) => a.localeCompare(b, "zh"));
  35. }
  36. /* ---------------- 写回:Parse REST,只带 App ID ---------------- */
  37. const H = () => ({ "X-Parse-Application-Id": CFG.appId, "Content-Type": "application/json" });
  38. const queues = new Map();
  39. /* Parse 的 REST 更新是 HTTP PUT(部分部署允许 POST + _method=PUT,这个服务器不允许,实测 404)。
  40. * 返回体不是 JSON(网关错误页)时也要能报出可读原因,不能把 HTML 当 JSON 解析。 */
  41. function write(cls, id, patch) {
  42. const u = CFG.parseBase + "/classes/" + cls + "/" + encodeURIComponent(id);
  43. const prev = queues.get(id) || Promise.resolve();
  44. const next = prev.catch(() => {}).then(() =>
  45. fetch(u, { method: "PUT", headers: H(), body: JSON.stringify(patch) })
  46. .then((r) => r.text().then((t) => {
  47. let j = null;
  48. try { j = t ? JSON.parse(t) : {}; } catch (e) { j = null; }
  49. if (!r.ok) throw new Error((j && j.error) || ("HTTP " + r.status));
  50. return j || {};
  51. }))
  52. );
  53. queues.set(id, next);
  54. return next;
  55. }
  56. function toast(msg, kind) {
  57. let box = document.getElementById("kf-toast");
  58. if (!box) {
  59. box = document.createElement("div");
  60. box.id = "kf-toast";
  61. box.className = "toast";
  62. box.setAttribute("role", "status");
  63. box.setAttribute("aria-live", "polite");
  64. document.body.appendChild(box);
  65. }
  66. box.textContent = msg;
  67. box.dataset.kind = kind || "ok";
  68. box.classList.add("on");
  69. clearTimeout(box._t);
  70. box._t = setTimeout(() => box.classList.remove("on"), 2600);
  71. }
  72. const normNum = (v) => (typeof v === "number" && v >= 0 && v <= 100 ? v : null);
  73. const toArr = (v) => (Array.isArray(v) ? v : []);
  74. /* 评分:同一人再打一次是"改分",不是"再加一票"。平均分由全部评分现算。 */
  75. function score(id, user, value, comment) {
  76. const db = window.__KF__.state.db;
  77. const o = db && db.opportunityById[id];
  78. const s = normNum(value);
  79. if (!o || !user || s == null) return Promise.reject(new Error("评分无效"));
  80. const before = toArr(o._raw.scores);
  81. const entry = { user, score: s, comment: comment || "", at: new Date().toISOString() };
  82. /* 只替换这个人的旧分,其他人的分原样保留 */
  83. const next = before.filter((x) => x.user !== user).concat(entry);
  84. /* 乐观更新:先只算"平均分"这一类派生值。scores 明细仍以服务端为准——
  85. * 服务端回包后 refresh 会从库里重读,明细里带有真实的 at,不会出现本地/服务端两套时间。 */
  86. const n = next.length;
  87. const avg = Math.round((next.reduce((t, x) => t + x.score, 0) / n) * 10) / 10;
  88. o.stats = { ...o.stats, n, avg };
  89. return write(CFG.classes.opportunity, id, { scores: next })
  90. .then(() => window.__KF__.refresh({ quiet: true }))
  91. .catch((e) => {
  92. o.stats = localStats(o.scores); /* 本地没有变过明细,回滚派生值即可 */
  93. throw e;
  94. });
  95. }
  96. function localStats(scores) {
  97. if (!scores.length) return { n: 0, avg: null };
  98. const sum = scores.reduce((n, x) => n + x.score, 0);
  99. return { n: scores.length, avg: Math.round((sum / scores.length) * 10) / 10 };
  100. }
  101. /* 改状态:写 status,同时记一条决策(谁、什么时候、为什么)到 decisionLog,
  102. * 这样"候选→立项"不是一次静默修改,而是池子里有据可查的一次拍板。 */
  103. function setStatus(id, next, by, reason) {
  104. const db = window.__KF__.state.db;
  105. const o = db && db.opportunityById[id];
  106. if (!o || !T.OPP_STATUS_KEYS.includes(next)) return Promise.reject(new Error("状态无效"));
  107. const before = { status: o._raw.status, decision: o._raw.decision, decisionLog: o._raw.decisionLog };
  108. const type = T.OPP_STATUS.find((s) => s.key === next);
  109. const dec = {
  110. type: next === "approved" ? "approve" : next === "rejected" ? "reject" : "candidate",
  111. label: next === "candidate" ? "退回候选" : type.label,
  112. reason: reason || "",
  113. by: by || "",
  114. at: new Date().toISOString(),
  115. via: "ui"
  116. };
  117. const log = toArr(o._raw.decisionLog).concat(dec);
  118. const patch = { status: next, decision: dec, decisionLog: log };
  119. o._raw.status = next; o.status = next;
  120. /* 等库里重读后再提示成功:本页的自动刷新会被"正在刷新"挡掉时,也要从当前库里拿到最新值 */
  121. return write(CFG.classes.opportunity, id, patch)
  122. .then(() => window.__KF__.refresh({ quiet: true }))
  123. .catch((e) => { o._raw.status = before.status; o.status = before.status; throw e; });
  124. }
  125. /* ---------------- 卡片片段 ---------------- */
  126. /* 已选身份(「我」是谁)。前端没有登录体系,身份存在本机 localStorage,
  127. * 评分/决策记的就是这个名字——和现场"每人一台设备、自己选自己"的使用方式一致。 */
  128. function currentUser() { return getMe(); }
  129. function requireUser() {
  130. const me = getMe();
  131. if (!me) toast("先选一下「我是谁」:首页「给我的」或卡片里的评分人", "err");
  132. return me;
  133. }
  134. /* 评分:从抽屉表单读出五维加权分,写库 */
  135. function submitScore(root) {
  136. const f = readScoreForm(root);
  137. if (!f.user) { toast("先选评分人", "err"); return Promise.resolve(false); }
  138. if (f.value == null) { toast("评分无效", "err"); return Promise.resolve(false); }
  139. setMe(f.user);
  140. const id = root.querySelector("[data-act='opp-score']").dataset.id;
  141. const next = window.__KF_ && window.__KF__.afterScore;
  142. return score(id, f.user, f.value, f.comment).then(() => {
  143. toast(`已记入 ${f.user} 的评分(综合 ${f.value})`, "ok");
  144. if (next) next(id);
  145. return true;
  146. }).catch((e) => {
  147. toast("评分没有保存:" + (e.message || e), "err");
  148. return false;
  149. });
  150. }
  151. /* 改状态:谁在改(当前身份)+ 理由(可选),都写进决策日志 */
  152. function submitStatus(root, id, next) {
  153. const who = requireUser();
  154. if (!who) return Promise.resolve(false);
  155. const reason = ((root.querySelector("#o-reason") || {}).value || "").trim();
  156. return setStatus(id, next, who, reason).then(() => {
  157. const fresh = window.__KF__ && window.__KF__.state.db && window.__KF__.state.db.opportunityById[id];
  158. const ok = fresh ? fresh.status === next : true;
  159. toast(ok ? "状态已更新为「" + (T.oppStatusOf(next) || {}).label + "」" : "状态已写入,页面刷新后生效", ok ? "ok" : "err");
  160. return ok;
  161. }).catch((e) => {
  162. toast("状态没有保存:" + (e.message || e), "err");
  163. return false;
  164. });
  165. }
  166. function statusPill(key) {
  167. const s = T.oppStatusOf(key);
  168. return `<span class="o-status s-${esc(key)}" title="${esc(s ? s.desc : "")}">${esc(s ? s.label : key)}</span>`;
  169. }
  170. function prio(key) {
  171. if (!key) return `<span class="o-prio none" title="未定优先级">未定</span>`;
  172. return `<span class="o-prio p-${esc(key)}" title="${esc(key)} 级优先">${esc(key)} 级</span>`;
  173. }
  174. function scoreBlock(o, compact) {
  175. const n = o.stats.n;
  176. const avg = o.stats.avg;
  177. const capped = avg != null ? Math.max(0, Math.min(100, Math.round(avg))) : 0;
  178. const label = n ? (n >= 1000 ? "avg" : "均") : "—";
  179. /* 评分条:纯装饰(aria-hidden),数字才是数据 */
  180. const bar = n ? `<span class="o-gauge" aria-hidden="true"><i style="width:${capped}%"></i></span>` : "";
  181. return `<div class="o-score${n ? "" : " empty"}${compact ? " sm" : ""}">
  182. <div class="o-score-v"><b class="num">${n ? esc(String(avg)) : "—"}</b><span class="o-score-l">${label}</span></div>
  183. <div class="o-score-m">${n ? `<span>${n} 人评</span>` : `<span>暂无人评</span>`}${bar}</div>
  184. </div>`;
  185. }
  186. function stat(k, v, cls) {
  187. return `<span class="o-stat${cls ? " " + cls : ""}"><b class="num">${esc(String(v))}</b><span>${esc(k)}</span></span>`;
  188. }
  189. /* Agent 名:库里是全名(「康师傅产品创新助手」),卡片上给短代号,悬停看全名 */
  190. function agentName(name) {
  191. if (!name) return "未知 Agent";
  192. return String(name).replace(/康师傅产品创新助手/, "主分身").replace(/\s*Agent$/, "");
  193. }
  194. function agentChip(name) {
  195. return `<span class="o-agent" title="Agent:${esc(name || "未记录")}">${esc(agentName(name))}</span>`;
  196. }
  197. function methodChip(m) {
  198. const label = T.UPLOAD_METHODS[m];
  199. if (!label) return `<span class="o-method none" title="上传方式未记录">方式未记录</span>`;
  200. return `<span class="o-method m-${esc(m)}" title="上传方式:${esc(label)}">${esc(label)}</span>`;
  201. }
  202. function sourceLine(o, opts) {
  203. const u = o.upload;
  204. const by = u.by || "上传人未记录";
  205. const when = u.at ? U.fmtDate(u.at, true) : "时间未记录";
  206. return `<div class="o-source${u.complete ? "" : " partial"}" title="${u.complete ? "上传元数据完整" : "上传元数据不完整:缺失字段如实显示"}">
  207. ${UI.person(by)}
  208. <span class="o-arrow" aria-hidden="true"></span>
  209. ${agentChip(u.agentName)}
  210. ${methodChip(u.method)}
  211. <span class="o-when num">${esc(when)}</span>
  212. ${!opts || !opts.noDetail ? "" : ""}
  213. </div>`;
  214. }
  215. function decisionLine(o) {
  216. if (!o.decision) {
  217. return `<div class="o-dec none">🔹 <b>决策</b> 尚未决策 · 等评满 3 人后进入决策</div>`;
  218. }
  219. const d = o.decision;
  220. return `<div class="o-dec d-${esc(d.type)}"><b>决策</b> <span class="o-dec-l">${esc(d.label)}</span>
  221. <span class="o-dec-by">${esc(d.by || "—")} · ${esc(U.fmtDate(d.at, false))}</span></div>`;
  222. }
  223. /* 卡片主体。opts.wrap: "li" | "div";opts.actions: 是否显示评分/状态操作(列表里只对当前卡显示) */
  224. function card(o, opts) {
  225. const o2 = opts || {};
  226. const tag = o2.tag || "li";
  227. const stage = o.stageObj ? o.stageObj.code : "—";
  228. const linked = o.linkedArtifacts.filter((a) => !a.missing).length;
  229. const decReason = o.decision && o.decision.reason
  230. ? `<details class="o-why"><summary>理由</summary><p>${esc(o.decision.reason)}</p></details>` : "";
  231. return `<${tag} class="ocard st-${esc(o.status)}${o2.cls ? " " + o2.cls : ""}" data-opp="${esc(o.id)}" data-stage="${esc(stage)}" data-prio="${esc(o.priority || "")}" data-cat="${esc(o.category)}" tabindex="0" role="button"
  232. aria-label="机会卡:${esc(o.title)},${esc(o.statusObj.label)},平均分 ${o.stats.n ? o.stats.avg : "暂无"}">
  233. <span class="ocard-sheen" aria-hidden="true"></span>
  234. <div class="o-top">
  235. ${prio(o.priority)}
  236. ${o.category ? `<span class="o-cat">${esc(o.category)}</span>` : ""}
  237. ${o.stageObj ? `<span class="o-stage" title="${esc(o.stage)}">${esc(stage)} ${esc(o.stageObj.name)}</span>` : ""}
  238. <span class="o-top-r">${statusPill(o.status)}</span>
  239. </div>
  240. <h3 class="o-title">${esc(o.title)}</h3>
  241. ${o.oneLiner ? `<p class="o-line">${esc(o.oneLiner)}</p>` : `<p class="o-line none">(缺一句话描述)</p>`}
  242. <div class="o-overview">
  243. ${scoreBlock(o)}
  244. <div class="o-stats">
  245. ${stat("证据强度", o.evidenceLabel, "ev-" + o.evidenceKey)}
  246. ${stat("引用", o.quoteCount + " 条")}
  247. ${stat("关联成果", linked)}
  248. </div>
  249. </div>
  250. ${decisionLine(o)}${decReason}
  251. ${sourceLine(o)}
  252. </${tag}>`;
  253. }
  254. /* ---------------- 卡片详情抽屉(浮层:第三层透明度) ---------------- */
  255. function drawer(db, id) {
  256. const o = db.opportunityById[id];
  257. if (!o) return null;
  258. const u = o.upload;
  259. const myScore = me ? o.stats.byUser[me] : null;
  260. /* 有"我"时,评分人默认就是"我"(连续点评时不用每次重选) */
  261. const scoreWho = myScore ? myScore.user : (me || "");
  262. /* 评分人下拉:不写 selected,由 bindDrawer 在插入 DOM 后设 value + 触发一次联动 */
  263. const meOpts = meCandidates(db).map((n) => `<option value="${esc(n)}">${esc(n)}</option>`).join("");
  264. const dims = (CFG.scoreDims || []).map((d) => `<div class="o-dim" data-dim="${esc(d.key)}">
  265. <span class="o-dim-l">${esc(d.label)}<em>${d.weight}%</em></span>
  266. <input type="range" min="0" max="100" step="1" value="${myScore ? myScore.score : o.stats.avg != null ? Math.round(o.stats.avg) : 70}" aria-label="${esc(d.label)}">
  267. <output class="num" data-out>${myScore ? myScore.score : o.stats.avg != null ? Math.round(o.stats.avg) : 70}</output>
  268. </div>`).join("");
  269. const scoreList = o.scores.length
  270. ? `<div class="o-scorelist">${o.scores.slice().reverse().map((s) => `
  271. <div class="o-srow"><span class="o-su">${esc(s.user)}</span><span class="o-sv num">${s.score}</span>
  272. <span class="o-sc">${esc(s.comment || "")}</span><span class="o-st num">${esc(U.fmtAgo(s.at))}</span></div>`).join("")}</div>`
  273. : `<p class="tiny muted">还没有人评分。第一位评分人的分数就是当前平均分。</p>`;
  274. const statusBtns = T.OPP_STATUS.map((s) => `<button type="button" class="seg-btn${s.key === o.status ? " on" : ""}" data-act="opp-status" data-id="${esc(o.id)}" data-status="${s.key}"
  275. aria-pressed="${s.key === o.status}" title="${esc(s.desc)}">${esc(s.label)}</button>`).join("");
  276. const log = o.decisionLog.length
  277. ? `<ol class="o-dlog">${o.decisionLog.slice().reverse().map((d) => `<li>
  278. <span class="o-dlog-t">${esc(d.label || T.DECISIONS[d.type] || d.type || "决策")}</span>
  279. <span class="o-dlog-m">${esc(d.by || "—")} · ${esc(U.fmtDate(d.at, true))}${d.via === "ui" ? " · 前端操作" : ""}</span>
  280. ${d.reason ? `<p>${esc(d.reason)}</p>` : ""}</li>`).join("")}</ol>`
  281. : `<p class="tiny muted">没有决策记录。</p>`;
  282. const arts = o.linkedArtifacts.length
  283. ? `<div class="trace-list">${o.linkedArtifacts.map((a) => a.missing
  284. ? `<div class="trace-item"><div class="idx">!</div><div class="t">关联成果 ${esc(a.id)} 在库里已不存在</div></div>`
  285. : `<div class="trace-item"><div class="idx">${esc(a.stageObj ? a.stageObj.code : "—")}</div>
  286. <div><button class="t" data-act="open-artifact" data-id="${esc(a.id)}">${esc(a.shortTitle)}</button>
  287. <div class="m">${UI.person(a.ownerName)}<span>${esc(a.typeLabel)}</span><span>${esc(U.fmtDate(a.createdAt, true))}</span></div></div>
  288. <div>${a.report ? `<button class="trace-btn" data-act="open-report" data-id="${esc(a.id)}">报告 ${UI.icons.arrow}</button>` : ""}</div></div>`).join("")}</div>`
  289. : `<p class="tiny muted">这张卡没有关联成果。</p>`;
  290. const html = `<div class="drawer-h">
  291. <div><div class="kicker">${prio(o.priority)}<span class="o-cat">${esc(o.category || "—")}</span>${statusPill(o.status)}<span>${esc(o.stage)}</span></div>
  292. <h3>${esc(o.title)}</h3></div>
  293. <button type="button" class="x" data-act="close-drawer" aria-label="关闭">${UI.icons.x}</button>
  294. </div>
  295. <div class="drawer-b">
  296. <p class="o-drawer-line">${esc(o.oneLiner || "(缺一句话描述)")}</p>
  297. <div class="o-evidence-big">
  298. ${scoreBlock(o)}
  299. <div class="o-stats">
  300. ${stat("证据强度", o.evidenceLabel, "ev-" + o.evidenceKey)}
  301. ${stat("引用", o.quoteCount + " 条")}
  302. ${stat("被引用", o.citeCount + " 次")}
  303. ${stat("关联成果", o.linkedArtifacts.filter((a) => !a.missing).length)}
  304. </div>
  305. </div>
  306. <div class="block">
  307. <div class="bl">决策 <span class="n">谁拍的板,为什么</span></div>
  308. <div class="o-drawer-dec">${decisionLine(o)}</div>
  309. ${o.decision && o.decision.reason ? `<p class="o-reason">${esc(o.decision.reason)}</p>` : ""}
  310. <div class="o-drawer-actions">
  311. <span class="tiny muted">改状态</span>
  312. <div class="seg-btn-group" role="group" aria-label="改状态">${statusBtns}</div>
  313. </div>
  314. <input class="o-input" id="o-reason" type="text" placeholder="改状态的理由(可选,会记进决策日志)" maxlength="200">
  315. ${log}
  316. </div>
  317. <div class="block">
  318. <div class="bl">来源 <span class="n">谁 · 哪个 Agent · 什么方式 · 什么时候</span></div>
  319. <div class="kv-grid o-source-grid">
  320. <div><div class="k">上传人</div><div class="v">${UI.person(u.by)}${u.byKey ? `<span class="tiny muted"> · ${esc(u.byKey)}</span>` : ""}</div></div>
  321. <div><div class="k">Agent</div><div class="v">${esc(u.agentName || "未记录")}${u.agentId ? `<span class="tiny muted"> · ${esc(u.agentId)}</span>` : ""}</div></div>
  322. <div><div class="k">上传方式</div><div class="v">${T.UPLOAD_METHODS[u.method] ? esc(T.UPLOAD_METHODS[u.method]) : "未记录"}</div></div>
  323. <div><div class="k">上传时间</div><div class="v num">${esc(u.at ? U.fmtDate(u.at, true) : "未记录")}</div></div>
  324. </div>
  325. ${u.complete ? "" : `<div class="note" style="margin-top:10px">${UI.icons.info}<span>这条上传记录缺字段。看板不补造:缺的显示"未记录"。</span></div>`}
  326. </div>
  327. <div class="block">
  328. <div class="bl">评分 <span class="n">多人评分,平均分现算</span></div>
  329. <p class="tiny muted" style="margin:-4px 0 10px">按评分模型的五个维度打分(加权后为综合分)。同一人再打一次是改分。</p>
  330. <div class="o-dims">${dims}</div>
  331. <div class="o-scoreform">
  332. <label class="tiny muted">评分人
  333. <select class="o-select" id="o-who"><option value="">选择成员</option>${meOpts}</select>
  334. </label>
  335. <span class="o-computed">综合 <b class="num" id="o-computed">—</b></span>
  336. <input class="o-input" id="o-comment" type="text" placeholder="一句话评语(可选)" maxlength="120" value="${esc(myScore ? myScore.comment : "")}">
  337. <button class="btn btn-accent" type="button" data-act="opp-score" data-id="${esc(o.id)}">提交评分</button>
  338. </div>
  339. ${scoreList}
  340. </div>
  341. <div class="block">
  342. <div class="bl">关联成果 <span class="n">这张卡从哪些成果来的</span></div>
  343. ${o.linkedProject ? `<p class="tiny" style="margin-bottom:8px">关联项目:<a href="#/project/${encodeURIComponent(o.linkedProject.id)}">${esc(o.linkedProject.title)}</a></p>` : ""}
  344. ${arts}
  345. </div>
  346. </div>
  347. <div class="drawer-f">
  348. <button type="button" class="btn" data-act="close-drawer">关闭</button>
  349. ${o.linkedProject ? `<a class="btn btn-accent" href="#/project/${encodeURIComponent(o.linkedProject.id)}" data-act="nav" data-href="#/project/${encodeURIComponent(o.linkedProject.id)}">看项目 ${UI.icons.arrow}</a>` : ""}
  350. </div>`;
  351. return { html, opp: o };
  352. }
  353. /* 抽屉打开后:滑杆联动综合分;"我"默认选中最近确定性身份 */
  354. function bindDrawer(root) {
  355. if (!root) return;
  356. const outs = [...root.querySelectorAll(".o-dim")];
  357. const comp = root.querySelector("#o-computed");
  358. const calc = () => {
  359. let sum = 0, w = 0;
  360. outs.forEach((d) => {
  361. const dim = (CFG.scoreDims || []).find((x) => x.key === d.dataset.dim);
  362. const v = Number(d.querySelector("input").value) || 0;
  363. /* 维度名来自元素本身,权重来自 config;两者对不上就按等权算,不静默给 0 分 */
  364. sum += v * ((dim && dim.weight) || 0); w += (dim && dim.weight) || 0;
  365. d.querySelector("[data-out]").textContent = v;
  366. });
  367. if (comp) comp.textContent = w ? Math.round(sum / w) : "—";
  368. };
  369. outs.forEach((d) => d.querySelector("input").addEventListener("input", calc));
  370. const who = root.querySelector("#o-who");
  371. if (who && !who.value && me) who.value = me;
  372. calc();
  373. }
  374. function readScoreForm(root) {
  375. const who = root.querySelector("#o-who");
  376. const vals = [...root.querySelectorAll(".o-dim")];
  377. let sum = 0, w = 0;
  378. vals.forEach((d) => {
  379. const dim = (CFG.scoreDims || []).find((x) => x.key === d.dataset.dim);
  380. sum += (Number(d.querySelector("input").value) || 0) * ((dim && dim.weight) || 0);
  381. w += (dim && dim.weight) || 0;
  382. });
  383. return {
  384. user: who ? who.value.trim() : "",
  385. value: w ? Math.round(sum / w) : null,
  386. comment: (root.querySelector("#o-comment") || {}).value || ""
  387. };
  388. }
  389. /* 卡片网格 + 排序 */
  390. const SORTS = {
  391. prio: { label: "按优先级", fn: (a, b) => (rank(a.priority) - rank(b.priority)) || ((b.stats.avg || 0) - (a.stats.avg || 0)) || (new Date(b.upload.at || 0) - new Date(a.upload.at || 0)) },
  392. score: { label: "按评分", fn: (a, b) => ((b.stats.avg == null ? -1 : b.stats.avg) - (a.stats.avg == null ? -1 : a.stats.avg)) || (rank(a.priority) - rank(b.priority)) },
  393. new: { label: "最新进池", fn: (a, b) => new Date(b.upload.at || b.createdAt || 0) - new Date(a.upload.at || a.createdAt || 0) }
  394. };
  395. function rank(p) { return p === "S" ? 0 : p === "A" ? 1 : p === "B" ? 2 : 3; }
  396. function sortCards(list, key) {
  397. const s = SORTS[key] || SORTS.prio;
  398. return list.slice().sort(s.fn);
  399. }
  400. function sortSeg(cur, dim) {
  401. return `<div class="seg" role="group" aria-label="排序">${Object.entries(SORTS).map(([k, s]) =>
  402. `<button class="${k === cur ? "on" : ""}" data-act="opp-sort" data-sort="${k}" ${dim === k ? 'aria-pressed="true"' : 'aria-pressed="false"'}>${esc(s.label)}</button>`).join("")}</div>`;
  403. }
  404. window.KF_OPP = {
  405. card, drawer, bindDrawer, readScoreForm, sortCards, sortSeg, SORTS, statusPill, prio, scoreBlock, sourceLine,
  406. getMe, setMe, meCandidates, score, setStatus, toast, write, agentChip, agentName,
  407. submitScore, submitStatus, currentUser, requireUser
  408. };
  409. })();