| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447 |
- /* views/opportunity.js —— 机会卡:统一卡片渲染 + 评分/改状态/决策的写回。
- *
- * 卡片是一屏内读完的五要素(客户要求):
- * ① 关键信息:优先级 / 品类 / 阶段 → 机会名 → 一句话
- * ② 概览:综合评分(平均)/ 评分人数 / 证据强度 / 引用数
- * ③ 决策:结论 + 拍板人 + 时间(可展开理由)
- * ④ 来源:谁 · 哪个 Agent · 什么方式 · 什么时候
- * ⑤ 归属:哪个阶段板块、关联哪几条成果(可点开)
- *
- * 写回:前端只带公开的 App ID 直接写 Parse(库已允许)。改动先在本地应用(乐观),
- * 成功后用服务端返回的 updatedAt 回填;失败则回滚并提示,不假装成功。
- * 同一张卡的写操作串行,避免两次评分互相覆盖。
- */
- (function () {
- "use strict";
- const T = window.KF_TAXONOMY;
- const U = window.KF_UTIL;
- const UI = window.KF_UI;
- const CFG = window.KF_CONFIG;
- const esc = U.esc;
- /* ---------------- 身份:这台设备上"我"是谁 ---------------- */
- const ME_KEY = "kf.me";
- function getMe() {
- try { return localStorage.getItem(ME_KEY) || ""; } catch (e) { return ""; }
- }
- function setMe(name) {
- try { localStorage.setItem(ME_KEY, name); } catch (e) { /* 隐私模式:仅本次会话 */ }
- me = name || "";
- }
- let me = getMe();
- function meCandidates(db) {
- const names = new Set(db.members.map((m) => m.name).filter(Boolean));
- db.opportunities.forEach((o) => { if (o.upload.by) names.add(o.upload.by); });
- return [...names].sort((a, b) => a.localeCompare(b, "zh"));
- }
- /* ---------------- 写回:Parse REST,只带 App ID ---------------- */
- const H = () => ({ "X-Parse-Application-Id": CFG.appId, "Content-Type": "application/json" });
- const queues = new Map();
- /* Parse 的 REST 更新是 HTTP PUT(部分部署允许 POST + _method=PUT,这个服务器不允许,实测 404)。
- * 返回体不是 JSON(网关错误页)时也要能报出可读原因,不能把 HTML 当 JSON 解析。 */
- function write(cls, id, patch) {
- const u = CFG.parseBase + "/classes/" + cls + "/" + encodeURIComponent(id);
- const prev = queues.get(id) || Promise.resolve();
- const next = prev.catch(() => {}).then(() =>
- fetch(u, { method: "PUT", headers: H(), body: JSON.stringify(patch) })
- .then((r) => r.text().then((t) => {
- let j = null;
- try { j = t ? JSON.parse(t) : {}; } catch (e) { j = null; }
- if (!r.ok) throw new Error((j && j.error) || ("HTTP " + r.status));
- return j || {};
- }))
- );
- queues.set(id, next);
- return next;
- }
- function toast(msg, kind) {
- let box = document.getElementById("kf-toast");
- if (!box) {
- box = document.createElement("div");
- box.id = "kf-toast";
- box.className = "toast";
- box.setAttribute("role", "status");
- box.setAttribute("aria-live", "polite");
- document.body.appendChild(box);
- }
- box.textContent = msg;
- box.dataset.kind = kind || "ok";
- box.classList.add("on");
- clearTimeout(box._t);
- box._t = setTimeout(() => box.classList.remove("on"), 2600);
- }
- const normNum = (v) => (typeof v === "number" && v >= 0 && v <= 100 ? v : null);
- const toArr = (v) => (Array.isArray(v) ? v : []);
- /* 评分:同一人再打一次是"改分",不是"再加一票"。平均分由全部评分现算。 */
- function score(id, user, value, comment) {
- const db = window.__KF__.state.db;
- const o = db && db.opportunityById[id];
- const s = normNum(value);
- if (!o || !user || s == null) return Promise.reject(new Error("评分无效"));
- const before = toArr(o._raw.scores);
- const entry = { user, score: s, comment: comment || "", at: new Date().toISOString() };
- /* 只替换这个人的旧分,其他人的分原样保留 */
- const next = before.filter((x) => x.user !== user).concat(entry);
- /* 乐观更新:先只算"平均分"这一类派生值。scores 明细仍以服务端为准——
- * 服务端回包后 refresh 会从库里重读,明细里带有真实的 at,不会出现本地/服务端两套时间。 */
- const n = next.length;
- const avg = Math.round((next.reduce((t, x) => t + x.score, 0) / n) * 10) / 10;
- o.stats = { ...o.stats, n, avg };
- return write(CFG.classes.opportunity, id, { scores: next })
- .then(() => window.__KF__.refresh({ quiet: true }))
- .catch((e) => {
- o.stats = localStats(o.scores); /* 本地没有变过明细,回滚派生值即可 */
- throw e;
- });
- }
- function localStats(scores) {
- if (!scores.length) return { n: 0, avg: null };
- const sum = scores.reduce((n, x) => n + x.score, 0);
- return { n: scores.length, avg: Math.round((sum / scores.length) * 10) / 10 };
- }
- /* 改状态:写 status,同时记一条决策(谁、什么时候、为什么)到 decisionLog,
- * 这样"候选→立项"不是一次静默修改,而是池子里有据可查的一次拍板。 */
- function setStatus(id, next, by, reason) {
- const db = window.__KF__.state.db;
- const o = db && db.opportunityById[id];
- if (!o || !T.OPP_STATUS_KEYS.includes(next)) return Promise.reject(new Error("状态无效"));
- const before = { status: o._raw.status, decision: o._raw.decision, decisionLog: o._raw.decisionLog };
- const type = T.OPP_STATUS.find((s) => s.key === next);
- const dec = {
- type: next === "approved" ? "approve" : next === "rejected" ? "reject" : "candidate",
- label: next === "candidate" ? "退回候选" : type.label,
- reason: reason || "",
- by: by || "",
- at: new Date().toISOString(),
- via: "ui"
- };
- const log = toArr(o._raw.decisionLog).concat(dec);
- const patch = { status: next, decision: dec, decisionLog: log };
- o._raw.status = next; o.status = next;
- /* 等库里重读后再提示成功:本页的自动刷新会被"正在刷新"挡掉时,也要从当前库里拿到最新值 */
- return write(CFG.classes.opportunity, id, patch)
- .then(() => window.__KF__.refresh({ quiet: true }))
- .catch((e) => { o._raw.status = before.status; o.status = before.status; throw e; });
- }
- /* ---------------- 卡片片段 ---------------- */
- /* 已选身份(「我」是谁)。前端没有登录体系,身份存在本机 localStorage,
- * 评分/决策记的就是这个名字——和现场"每人一台设备、自己选自己"的使用方式一致。 */
- function currentUser() { return getMe(); }
- function requireUser() {
- const me = getMe();
- if (!me) toast("先选一下「我是谁」:首页「给我的」或卡片里的评分人", "err");
- return me;
- }
- /* 评分:从抽屉表单读出五维加权分,写库 */
- function submitScore(root) {
- const f = readScoreForm(root);
- if (!f.user) { toast("先选评分人", "err"); return Promise.resolve(false); }
- if (f.value == null) { toast("评分无效", "err"); return Promise.resolve(false); }
- setMe(f.user);
- const id = root.querySelector("[data-act='opp-score']").dataset.id;
- const next = window.__KF_ && window.__KF__.afterScore;
- return score(id, f.user, f.value, f.comment).then(() => {
- toast(`已记入 ${f.user} 的评分(综合 ${f.value})`, "ok");
- if (next) next(id);
- return true;
- }).catch((e) => {
- toast("评分没有保存:" + (e.message || e), "err");
- return false;
- });
- }
- /* 改状态:谁在改(当前身份)+ 理由(可选),都写进决策日志 */
- function submitStatus(root, id, next) {
- const who = requireUser();
- if (!who) return Promise.resolve(false);
- const reason = ((root.querySelector("#o-reason") || {}).value || "").trim();
- return setStatus(id, next, who, reason).then(() => {
- const fresh = window.__KF__ && window.__KF__.state.db && window.__KF__.state.db.opportunityById[id];
- const ok = fresh ? fresh.status === next : true;
- toast(ok ? "状态已更新为「" + (T.oppStatusOf(next) || {}).label + "」" : "状态已写入,页面刷新后生效", ok ? "ok" : "err");
- return ok;
- }).catch((e) => {
- toast("状态没有保存:" + (e.message || e), "err");
- return false;
- });
- }
- function statusPill(key) {
- const s = T.oppStatusOf(key);
- return `<span class="o-status s-${esc(key)}" title="${esc(s ? s.desc : "")}">${esc(s ? s.label : key)}</span>`;
- }
- function prio(key) {
- if (!key) return `<span class="o-prio none" title="未定优先级">未定</span>`;
- return `<span class="o-prio p-${esc(key)}" title="${esc(key)} 级优先">${esc(key)} 级</span>`;
- }
- function scoreBlock(o, compact) {
- const n = o.stats.n;
- const avg = o.stats.avg;
- const capped = avg != null ? Math.max(0, Math.min(100, Math.round(avg))) : 0;
- const label = n ? (n >= 1000 ? "avg" : "均") : "—";
- /* 评分条:纯装饰(aria-hidden),数字才是数据 */
- const bar = n ? `<span class="o-gauge" aria-hidden="true"><i style="width:${capped}%"></i></span>` : "";
- return `<div class="o-score${n ? "" : " empty"}${compact ? " sm" : ""}">
- <div class="o-score-v"><b class="num">${n ? esc(String(avg)) : "—"}</b><span class="o-score-l">${label}</span></div>
- <div class="o-score-m">${n ? `<span>${n} 人评</span>` : `<span>暂无人评</span>`}${bar}</div>
- </div>`;
- }
- function stat(k, v, cls) {
- return `<span class="o-stat${cls ? " " + cls : ""}"><b class="num">${esc(String(v))}</b><span>${esc(k)}</span></span>`;
- }
- /* Agent 名:库里是全名(「康师傅产品创新助手」),卡片上给短代号,悬停看全名 */
- function agentName(name) {
- if (!name) return "未知 Agent";
- return String(name).replace(/康师傅产品创新助手/, "主分身").replace(/\s*Agent$/, "");
- }
- function agentChip(name) {
- return `<span class="o-agent" title="Agent:${esc(name || "未记录")}">${esc(agentName(name))}</span>`;
- }
- function methodChip(m) {
- const label = T.UPLOAD_METHODS[m];
- if (!label) return `<span class="o-method none" title="上传方式未记录">方式未记录</span>`;
- return `<span class="o-method m-${esc(m)}" title="上传方式:${esc(label)}">${esc(label)}</span>`;
- }
- function sourceLine(o, opts) {
- const u = o.upload;
- const by = u.by || "上传人未记录";
- const when = u.at ? U.fmtDate(u.at, true) : "时间未记录";
- return `<div class="o-source${u.complete ? "" : " partial"}" title="${u.complete ? "上传元数据完整" : "上传元数据不完整:缺失字段如实显示"}">
- ${UI.person(by)}
- <span class="o-arrow" aria-hidden="true"></span>
- ${agentChip(u.agentName)}
- ${methodChip(u.method)}
- <span class="o-when num">${esc(when)}</span>
- ${!opts || !opts.noDetail ? "" : ""}
- </div>`;
- }
- function decisionLine(o) {
- if (!o.decision) {
- return `<div class="o-dec none">🔹 <b>决策</b> 尚未决策 · 等评满 3 人后进入决策</div>`;
- }
- const d = o.decision;
- return `<div class="o-dec d-${esc(d.type)}"><b>决策</b> <span class="o-dec-l">${esc(d.label)}</span>
- <span class="o-dec-by">${esc(d.by || "—")} · ${esc(U.fmtDate(d.at, false))}</span></div>`;
- }
- /* 卡片主体。opts.wrap: "li" | "div";opts.actions: 是否显示评分/状态操作(列表里只对当前卡显示) */
- function card(o, opts) {
- const o2 = opts || {};
- const tag = o2.tag || "li";
- const stage = o.stageObj ? o.stageObj.code : "—";
- const linked = o.linkedArtifacts.filter((a) => !a.missing).length;
- const decReason = o.decision && o.decision.reason
- ? `<details class="o-why"><summary>理由</summary><p>${esc(o.decision.reason)}</p></details>` : "";
- 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"
- aria-label="机会卡:${esc(o.title)},${esc(o.statusObj.label)},平均分 ${o.stats.n ? o.stats.avg : "暂无"}">
- <span class="ocard-sheen" aria-hidden="true"></span>
- <div class="o-top">
- ${prio(o.priority)}
- ${o.category ? `<span class="o-cat">${esc(o.category)}</span>` : ""}
- ${o.stageObj ? `<span class="o-stage" title="${esc(o.stage)}">${esc(stage)} ${esc(o.stageObj.name)}</span>` : ""}
- <span class="o-top-r">${statusPill(o.status)}</span>
- </div>
- <h3 class="o-title">${esc(o.title)}</h3>
- ${o.oneLiner ? `<p class="o-line">${esc(o.oneLiner)}</p>` : `<p class="o-line none">(缺一句话描述)</p>`}
- <div class="o-overview">
- ${scoreBlock(o)}
- <div class="o-stats">
- ${stat("证据强度", o.evidenceLabel, "ev-" + o.evidenceKey)}
- ${stat("引用", o.quoteCount + " 条")}
- ${stat("关联成果", linked)}
- </div>
- </div>
- ${decisionLine(o)}${decReason}
- ${sourceLine(o)}
- </${tag}>`;
- }
- /* ---------------- 卡片详情抽屉(浮层:第三层透明度) ---------------- */
- function drawer(db, id) {
- const o = db.opportunityById[id];
- if (!o) return null;
- const u = o.upload;
- const myScore = me ? o.stats.byUser[me] : null;
- /* 有"我"时,评分人默认就是"我"(连续点评时不用每次重选) */
- const scoreWho = myScore ? myScore.user : (me || "");
- /* 评分人下拉:不写 selected,由 bindDrawer 在插入 DOM 后设 value + 触发一次联动 */
- const meOpts = meCandidates(db).map((n) => `<option value="${esc(n)}">${esc(n)}</option>`).join("");
- const dims = (CFG.scoreDims || []).map((d) => `<div class="o-dim" data-dim="${esc(d.key)}">
- <span class="o-dim-l">${esc(d.label)}<em>${d.weight}%</em></span>
- <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)}">
- <output class="num" data-out>${myScore ? myScore.score : o.stats.avg != null ? Math.round(o.stats.avg) : 70}</output>
- </div>`).join("");
- const scoreList = o.scores.length
- ? `<div class="o-scorelist">${o.scores.slice().reverse().map((s) => `
- <div class="o-srow"><span class="o-su">${esc(s.user)}</span><span class="o-sv num">${s.score}</span>
- <span class="o-sc">${esc(s.comment || "")}</span><span class="o-st num">${esc(U.fmtAgo(s.at))}</span></div>`).join("")}</div>`
- : `<p class="tiny muted">还没有人评分。第一位评分人的分数就是当前平均分。</p>`;
- 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}"
- aria-pressed="${s.key === o.status}" title="${esc(s.desc)}">${esc(s.label)}</button>`).join("");
- const log = o.decisionLog.length
- ? `<ol class="o-dlog">${o.decisionLog.slice().reverse().map((d) => `<li>
- <span class="o-dlog-t">${esc(d.label || T.DECISIONS[d.type] || d.type || "决策")}</span>
- <span class="o-dlog-m">${esc(d.by || "—")} · ${esc(U.fmtDate(d.at, true))}${d.via === "ui" ? " · 前端操作" : ""}</span>
- ${d.reason ? `<p>${esc(d.reason)}</p>` : ""}</li>`).join("")}</ol>`
- : `<p class="tiny muted">没有决策记录。</p>`;
- const arts = o.linkedArtifacts.length
- ? `<div class="trace-list">${o.linkedArtifacts.map((a) => a.missing
- ? `<div class="trace-item"><div class="idx">!</div><div class="t">关联成果 ${esc(a.id)} 在库里已不存在</div></div>`
- : `<div class="trace-item"><div class="idx">${esc(a.stageObj ? a.stageObj.code : "—")}</div>
- <div><button class="t" data-act="open-artifact" data-id="${esc(a.id)}">${esc(a.shortTitle)}</button>
- <div class="m">${UI.person(a.ownerName)}<span>${esc(a.typeLabel)}</span><span>${esc(U.fmtDate(a.createdAt, true))}</span></div></div>
- <div>${a.report ? `<button class="trace-btn" data-act="open-report" data-id="${esc(a.id)}">报告 ${UI.icons.arrow}</button>` : ""}</div></div>`).join("")}</div>`
- : `<p class="tiny muted">这张卡没有关联成果。</p>`;
- const html = `<div class="drawer-h">
- <div><div class="kicker">${prio(o.priority)}<span class="o-cat">${esc(o.category || "—")}</span>${statusPill(o.status)}<span>${esc(o.stage)}</span></div>
- <h3>${esc(o.title)}</h3></div>
- <button type="button" class="x" data-act="close-drawer" aria-label="关闭">${UI.icons.x}</button>
- </div>
- <div class="drawer-b">
- <p class="o-drawer-line">${esc(o.oneLiner || "(缺一句话描述)")}</p>
- <div class="o-evidence-big">
- ${scoreBlock(o)}
- <div class="o-stats">
- ${stat("证据强度", o.evidenceLabel, "ev-" + o.evidenceKey)}
- ${stat("引用", o.quoteCount + " 条")}
- ${stat("被引用", o.citeCount + " 次")}
- ${stat("关联成果", o.linkedArtifacts.filter((a) => !a.missing).length)}
- </div>
- </div>
- <div class="block">
- <div class="bl">决策 <span class="n">谁拍的板,为什么</span></div>
- <div class="o-drawer-dec">${decisionLine(o)}</div>
- ${o.decision && o.decision.reason ? `<p class="o-reason">${esc(o.decision.reason)}</p>` : ""}
- <div class="o-drawer-actions">
- <span class="tiny muted">改状态</span>
- <div class="seg-btn-group" role="group" aria-label="改状态">${statusBtns}</div>
- </div>
- <input class="o-input" id="o-reason" type="text" placeholder="改状态的理由(可选,会记进决策日志)" maxlength="200">
- ${log}
- </div>
- <div class="block">
- <div class="bl">来源 <span class="n">谁 · 哪个 Agent · 什么方式 · 什么时候</span></div>
- <div class="kv-grid o-source-grid">
- <div><div class="k">上传人</div><div class="v">${UI.person(u.by)}${u.byKey ? `<span class="tiny muted"> · ${esc(u.byKey)}</span>` : ""}</div></div>
- <div><div class="k">Agent</div><div class="v">${esc(u.agentName || "未记录")}${u.agentId ? `<span class="tiny muted"> · ${esc(u.agentId)}</span>` : ""}</div></div>
- <div><div class="k">上传方式</div><div class="v">${T.UPLOAD_METHODS[u.method] ? esc(T.UPLOAD_METHODS[u.method]) : "未记录"}</div></div>
- <div><div class="k">上传时间</div><div class="v num">${esc(u.at ? U.fmtDate(u.at, true) : "未记录")}</div></div>
- </div>
- ${u.complete ? "" : `<div class="note" style="margin-top:10px">${UI.icons.info}<span>这条上传记录缺字段。看板不补造:缺的显示"未记录"。</span></div>`}
- </div>
- <div class="block">
- <div class="bl">评分 <span class="n">多人评分,平均分现算</span></div>
- <p class="tiny muted" style="margin:-4px 0 10px">按评分模型的五个维度打分(加权后为综合分)。同一人再打一次是改分。</p>
- <div class="o-dims">${dims}</div>
- <div class="o-scoreform">
- <label class="tiny muted">评分人
- <select class="o-select" id="o-who"><option value="">选择成员</option>${meOpts}</select>
- </label>
- <span class="o-computed">综合 <b class="num" id="o-computed">—</b></span>
- <input class="o-input" id="o-comment" type="text" placeholder="一句话评语(可选)" maxlength="120" value="${esc(myScore ? myScore.comment : "")}">
- <button class="btn btn-accent" type="button" data-act="opp-score" data-id="${esc(o.id)}">提交评分</button>
- </div>
- ${scoreList}
- </div>
- <div class="block">
- <div class="bl">关联成果 <span class="n">这张卡从哪些成果来的</span></div>
- ${o.linkedProject ? `<p class="tiny" style="margin-bottom:8px">关联项目:<a href="#/project/${encodeURIComponent(o.linkedProject.id)}">${esc(o.linkedProject.title)}</a></p>` : ""}
- ${arts}
- </div>
- </div>
- <div class="drawer-f">
- <button type="button" class="btn" data-act="close-drawer">关闭</button>
- ${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>` : ""}
- </div>`;
- return { html, opp: o };
- }
- /* 抽屉打开后:滑杆联动综合分;"我"默认选中最近确定性身份 */
- function bindDrawer(root) {
- if (!root) return;
- const outs = [...root.querySelectorAll(".o-dim")];
- const comp = root.querySelector("#o-computed");
- const calc = () => {
- let sum = 0, w = 0;
- outs.forEach((d) => {
- const dim = (CFG.scoreDims || []).find((x) => x.key === d.dataset.dim);
- const v = Number(d.querySelector("input").value) || 0;
- /* 维度名来自元素本身,权重来自 config;两者对不上就按等权算,不静默给 0 分 */
- sum += v * ((dim && dim.weight) || 0); w += (dim && dim.weight) || 0;
- d.querySelector("[data-out]").textContent = v;
- });
- if (comp) comp.textContent = w ? Math.round(sum / w) : "—";
- };
- outs.forEach((d) => d.querySelector("input").addEventListener("input", calc));
- const who = root.querySelector("#o-who");
- if (who && !who.value && me) who.value = me;
- calc();
- }
- function readScoreForm(root) {
- const who = root.querySelector("#o-who");
- const vals = [...root.querySelectorAll(".o-dim")];
- let sum = 0, w = 0;
- vals.forEach((d) => {
- const dim = (CFG.scoreDims || []).find((x) => x.key === d.dataset.dim);
- sum += (Number(d.querySelector("input").value) || 0) * ((dim && dim.weight) || 0);
- w += (dim && dim.weight) || 0;
- });
- return {
- user: who ? who.value.trim() : "",
- value: w ? Math.round(sum / w) : null,
- comment: (root.querySelector("#o-comment") || {}).value || ""
- };
- }
- /* 卡片网格 + 排序 */
- const SORTS = {
- 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)) },
- 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)) },
- new: { label: "最新进池", fn: (a, b) => new Date(b.upload.at || b.createdAt || 0) - new Date(a.upload.at || a.createdAt || 0) }
- };
- function rank(p) { return p === "S" ? 0 : p === "A" ? 1 : p === "B" ? 2 : 3; }
- function sortCards(list, key) {
- const s = SORTS[key] || SORTS.prio;
- return list.slice().sort(s.fn);
- }
- function sortSeg(cur, dim) {
- return `<div class="seg" role="group" aria-label="排序">${Object.entries(SORTS).map(([k, s]) =>
- `<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>`;
- }
- window.KF_OPP = {
- card, drawer, bindDrawer, readScoreForm, sortCards, sortSeg, SORTS, statusPill, prio, scoreBlock, sourceLine,
- getMe, setMe, meCandidates, score, setStatus, toast, write, agentChip, agentName,
- submitScore, submitStatus, currentUser, requireUser
- };
- })();
|