changes.patch 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  1. --- a/src/components/kf-opportunity-card.js
  2. +++ b/src/components/kf-opportunity-card.js
  3. @@ -4,14 +4,12 @@
  4. * 一屏读完五要素(任务书 3.1):
  5. * 1. 优先级 / 品类 / 阶段 2. 机会名 3. 一句话
  6. * 4. 综合评分(多人平均)/ 证据强度 / 引用条数
  7. - * 5. 决策记录 + 来源(谁 · 哪个 Agent · 什么方式 · 什么时候)
  8. + * 5. 来源(谁 · 哪个 Agent · 什么方式 · 什么时候)
  9. *
  10. * wc 层不做业务:评分平均、状态文字都由调用方把「已归一化的机会卡对象」传进来(.opp)。
  11. - * 交互只派发事件,由 page 决定跳转 / 改状态:
  12. + * 交互只派发阅读事件,由 page 决定跳转:
  13. * card-open { id } 点整卡阅读报告
  14. - * status-change { id, status } 状态按钮(悬停出现的快捷操作)
  15. - *
  16. - * 状态操作与决策记录不触发整卡跳转。
  17. + * 决策操作、说明与历史统一在报告阅读页展示。
  18. */
  19. import { LitElement, html, css } from 'lit';
  20. import { useTokens, define } from './base.js';
  21. @@ -43,28 +41,17 @@
  22. }
  23. static styles = css`
  24. - /* v11:高度只由内容决定(v11 10 的 padding 规则换掉了 face 内衬后,「100% + 栅格 stretch」会把三张卡拉成同一高、内容被纵向拉开) */
  25. - :host { display: block; min-width: 0; }
  26. - /* 卡内三段:标题区(top / title / one,定高内容)/ 指标区 / 决策 + 来源 + 操作。
  27. - 同一行的卡被拉成等高后,多出来的高度放进 .nums 与 .dec 之间的弹性间隙,
  28. - 而不是让来源行「沉底 + 中间一大块空」。标题、一句话、决策行各自都有定高(单 / 两行统一)。
  29. - .dec 的 margin-top: auto 把 .dec / .src / .acts 三行贴着底边走 —— 同一排卡的「分隔线 / 按钮 / 来源行」落同一 y。 */
  30. - /* v14 定稿:.wrap 是 slotted 到 <kf-glass-card> 的子元素,父级(宿主 kf-glass-card)高 389px,
  31. - 但 slotted 子元素默认 flex:0 1 auto,height:100% 在 flex item 上解析不出百分比 → 实测只有内容高 303px,
  32. - 导致无描述卡片的 .nums margin-top:auto 没有余量可分(底部差 53.6px)。用 box-sizing + min-height 撑满。 */
  33. - .wrap { flex: 1 1 auto; min-height: 0; box-sizing: border-box; display: flex; flex-direction: column; gap: 12px; }
  34. + /* 决策区移入报告后,卡面按实际内容收缩,不预留空描述或操作区。 */
  35. + :host { display: block; min-width: 0; align-self: start; }
  36. + .wrap { min-width: 0; box-sizing: border-box; display: flex; flex-direction: column; gap: 12px; }
  37. .top { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
  38. .top .right { margin-left: auto; display: flex; gap: 6px; }
  39. /* 长标题不截断(5.6):折两行;正文行高 1.6、字距 0(1.5) */
  40. .title { margin: 0; font-size: 16px; line-height: 1.35; color: var(--kf-ink); font-weight: 600; letter-spacing: var(--ls-cn-display);
  41. word-break: keep-all; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; min-width: 0; }
  42. /* 行盒修剪 + -webkit-box 行数截断会把行盒压成 ~1 行高(实测 11.6px);按两行高度收(2 × 1.35em) */
  43. - .title { min-height: calc(2.7em + 0.25em); padding-block: 0.125em; }
  44. - .one { margin: 0; font-size: 14px; line-height: 1.6; letter-spacing: var(--ls-cn-body); color: var(--kf-ink-2); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; min-height: calc(2 * 1.6em); }
  45. - /* 卡面余量摊给无描述卡(v14 定稿):.one 不出文字但保留等高空块(aria-hidden,无文字节点,读屏与搜索都看不见「未填写」)。
  46. - 实测约束——余量 53px,只在单缝做 margin:auto 那缝必 >24px;平分到多缝则 .dec/.acts 整体上移、与邻卡底边错位 53px。
  47. - 保留等高空块是唯一能同时满足「不显示文字 / 卡内无大缝 / 底边逐 px 对齐」的做法。 */
  48. - .one-empty { min-height: calc(2 * 1.6em); }
  49. + .title { padding-block: 0.125em; }
  50. + .one { margin: 0; font-size: 14px; line-height: 1.6; letter-spacing: var(--ls-cn-body); color: var(--kf-ink-2); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
  51. .nums { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
  52. .n { min-width: 0; box-sizing: border-box; padding: 12px 10px; border: 1px solid #B4C1D5; border-radius: 8px; background: #EEF2F9; }
  53. /* 三个数值(1.2 单位分层):大数字 = Inter 数字 + 行盒修剪;单位 12px 灰,留 0.12em */
  54. @@ -74,33 +61,13 @@
  55. .n .l { font-size: 12px; color: var(--kf-ink-3); margin-top: 4px; letter-spacing: var(--ls-cn-small); }
  56. .n.score .v { color: var(--kf-accent-ink); }
  57. .n.score .v.none { color: var(--kf-ink-3); text-shadow: none; font-weight: 600; font-size: 16px; }
  58. - /* 决策行 = 状态 + 一句理由。理由最多两行,理由一长卡就被撑高、同排卡的分隔线跟着错位,
  59. - 所以这里定高两行(理由两行截断);「谁 · 何时」在标题上浮出全文,不靠这一行承载。 */
  60. - /* v15:卡面不再被拉伸(fill-to 只用于需要等高铺满的调用方),这里不从 auto 取水平余量 */
  61. - .dec { font-size: 14px; color: var(--kf-ink-2); margin-top: 4px; line-height: 1.6; min-height: calc(2 * 1.6em); max-height: calc(2 * 1.6em); overflow: hidden;
  62. - display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
  63. - .dec .kf-muted { display: inline; }
  64. - .dec b { color: var(--kf-ink); font-weight: 600; }
  65. .src { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding-top: 12px; border-top: 1px solid var(--kf-line); font-size: 12px; line-height: 20px; letter-spacing: var(--ls-cn-small); color: var(--kf-ink-3); font-variant-numeric: tabular-nums; }
  66. .src .who { color: var(--kf-ink); font-weight: 600; }
  67. .src .agent { color: var(--kf-accent-ink); font-weight: 500; }
  68. .src .miss { color: var(--kf-warn-text); }
  69. - .acts { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
  70. - .decision-history { font-size: 12px; line-height: 1.7; color: var(--kf-ink-2); }
  71. - .decision-history summary { cursor: pointer; width: fit-content; padding: 4px 0; }
  72. - .decision-history ol { margin: 8px 0 0; padding-left: 18px; }
  73. - .decision-history li { margin-top: 6px; overflow-wrap: anywhere; }
  74. - .st { display: inline-flex; border: 1px solid var(--kf-line-strong); border-radius: var(--r-sm); overflow: hidden; background: var(--kf-surface); }
  75. - .st button { border: 0; background: transparent; font: 500 12px/1 var(--kf-font); color: var(--kf-ink-2); padding: 0 10px; height: 26px; cursor: pointer; transition: background var(--kf-dur) var(--kf-ease); }
  76. - .st button:hover { background: var(--kf-surface-btn); }
  77. - .st button[aria-pressed="true"]:hover { background: var(--kf-ink); }
  78. - .st button[aria-pressed="true"] { background: var(--kf-ink); color: #fff; }
  79. - .st button[aria-pressed="true"].ok { background: #0f5232; }
  80. - .st button[aria-pressed="true"].no { background: #8a1a14; }
  81. - .sample { font-size: 12px; line-height: 1; color: #5c4300; background: rgba(255,236,170,0.55); border: 1px solid rgba(170,130,0,0.35); border-radius: var(--r-sm); padding: 1px 6px; }
  82. :host([compact]) .title { font-size: 14px; }
  83. :host([compact]) .nums .v { font-size: 16px; }
  84. - /* 手机(任务书 1.3 / 1.5 / 2.4):标题 17/600、一句话 15/1.55、大数字 24/700、按钮 44 触摸高 */
  85. + /* 手机保留指标布局,来源拆成两行。 */
  86. @media (max-width: 767px) {
  87. .wrap { gap: 12px; }
  88. .title { font-size: 16px; font-weight: 600; line-height: 1.4; }
  89. @@ -115,13 +82,6 @@
  90. .src .l1, .src .l2 { display: flex; gap: 6px; flex-wrap: wrap; }
  91. .src .l2 { color: var(--kf-ink-2); }
  92. .src .who, .src .agent { white-space: nowrap; }
  93. - .dec { font-size: 14px; }
  94. - .acts { gap: 8px; }
  95. - .st { height: 44px; }
  96. - .st button { height: 44px; padding: 0 14px; font-size: 14px; }
  97. - .st button:active { background: var(--kf-surface-btn); }
  98. - .decision-history summary { min-height: 44px; display: flex; align-items: center; }
  99. - .sample { font-size: 12px; padding: 3px 8px; }
  100. }
  101. @media (max-width: 359px) {
  102. .nums { gap: 6px; }
  103. @@ -137,43 +97,25 @@
  104. this.dispatchEvent(new CustomEvent('card-open', { detail: { id: this.opp.id }, bubbles: true, composed: true }));
  105. };
  106. - #status(status) {
  107. - return (e) => {
  108. - e.preventDefault();
  109. - e.stopPropagation();
  110. - this.dispatchEvent(new CustomEvent('status-change', { detail: { id: this.opp.id, status }, bubbles: true, composed: true }));
  111. - };
  112. - }
  113. -
  114. render() {
  115. const o = this.opp;
  116. if (!o) return html`<div class="wrap"><slot></slot></div>`;
  117. - // v14 定稿:has-one 必须挂在宿主 <kf-opportunity-card> 上,
  118. - // 因为 :host(:not([has-one])) .nums 的选择器作用域是宿主自身;
  119. - // 早前挂在 <kf-glass-card> 上,属性与 :host 不在同一元素,规则永不命中(margin-top 恒为 0px)。
  120. - if (o.oneLiner) {
  121. - if (!this.hasAttribute('has-one')) this.setAttribute('has-one', '');
  122. - } else if (this.hasAttribute('has-one')) {
  123. - this.removeAttribute('has-one');
  124. - }
  125. const s = o.stats || { avg: null, n: 0 };
  126. const u = o.upload || {};
  127. - const dec = o.decision;
  128. return html`
  129. - <kf-glass-card lift fill-to surface="2" tone=${o.status === 'rejected' ? 'danger' : o.status === 'approved' ? 'ok' : ''} @card-activate=${this.#open}>
  130. + <kf-glass-card lift surface="2" tone=${o.status === 'rejected' ? 'danger' : o.status === 'approved' ? 'ok' : ''} @card-activate=${this.#open}>
  131. <div class="wrap">
  132. <div class="top">
  133. ${o.priority ? html`<kf-badge kind="priority" .value=${o.priority}></kf-badge>` : ''}
  134. ${o.category ? html`<kf-badge kind="plain" .value=${o.category}></kf-badge>` : ''}
  135. ${o.stageObj ? html`<kf-badge kind="stage" .value=${o.stageObj.code} label=${`${o.stageObj.code} ${o.stageObj.name}`}></kf-badge>` : ''}
  136. <span class="right" style="align-items:center">
  137. - ${o.seedKey ? html`<span class="sample" title="示例数据(seed):用于演示,不代表真实学员提交">示例</span>` : ''}
  138. <kf-badge kind="status" .value=${o.status}></kf-badge>
  139. </span>
  140. </div>
  141. <h3 class="title">${o.title}</h3>
  142. - ${o.oneLiner ? html`<p class="one">${o.oneLiner}</p>` : html`<p class="one one-empty" aria-hidden="true"></p>`}
  143. + ${o.oneLiner ? html`<p class="one">${o.oneLiner}</p>` : ''}
  144. <div class="nums">
  145. <div class="n score">
  146. @@ -190,17 +132,6 @@
  147. </div>
  148. </div>
  149. - <div class="dec">
  150. - ${dec
  151. - ? html`${t('决策:')}<b>${dec.label}</b>${dec.reason ? html`<span class="kf-muted"> · ${dec.reason}</span>` : ''}<span class="kf-muted">${t(`(${dec.by || '未署名'} · ${fmtDate(dec.at, true)})`)}</span>`
  152. - : html`<span class="kf-muted">${t('决策:尚无决策,')}${o.status === 'candidate' ? '等待评估' : '等待补充记录'}</span>`}
  153. - </div>
  154. - ${o.decisionLog?.length ? html`<details class="decision-history" data-no-activate>
  155. - <summary>${t(`决策记录 ${o.decisionLog.length} 条(最近 3 条)`)}</summary>
  156. - <ol>${o.decisionLog.slice(-3).reverse().map(x => html`<li><b>${x.label}</b> ${x.reason}
  157. - <span class="kf-muted">${t(`${x.by || '未署名'} · ${fmtDate(x.at, true)}`)}</span></li>`)}</ol>
  158. - </details>` : ''}
  159. -
  160. <div class="src">
  161. <span class="l1">
  162. ${u.uploadedBy ? html`<span class="who">${u.uploadedBy}</span>` : html`<span class="miss">上传人未记录</span>`}
  163. @@ -212,13 +143,6 @@
  164. </span>
  165. </div>
  166. - <div class="acts" data-no-activate>
  167. - <div class="st" role="group" aria-label="改状态">
  168. - <button aria-pressed=${o.status === 'candidate' ? 'true' : 'false'} @click=${this.#status('candidate')}>候选</button>
  169. - <button class="ok" aria-pressed=${o.status === 'approved' ? 'true' : 'false'} @click=${this.#status('approved')}>立项</button>
  170. - <button class="no" aria-pressed=${o.status === 'rejected' ? 'true' : 'false'} @click=${this.#status('rejected')}>否决</button>
  171. - </div>
  172. - </div>
  173. </div>
  174. </kf-glass-card>
  175. `;
  176. --- a/src/pages/view-report.js
  177. +++ b/src/pages/view-report.js
  178. @@ -202,16 +202,20 @@
  179. </div>`;
  180. }
  181. + #selectedOpportunity() {
  182. + return this.artifactId ? reportOpportunity(this.db, this.artifactId, this.opportunityId)
  183. + : this.db.opportunityById?.[this.opportunityId];
  184. + }
  185. +
  186. #rating() {
  187. const linked = this.artifactId ? opportunitiesForReport(this.db, this.artifactId) : [];
  188. - const selected = this.artifactId ? reportOpportunity(this.db, this.artifactId, this.opportunityId)
  189. - : this.db.opportunityById?.[this.opportunityId];
  190. + const selected = this.#selectedOpportunity();
  191. if (!linked.length && !selected && !this.opportunityId) return '';
  192. return html`<kf-glass-card surface="1" class="r rating-card" id="opportunity-rating" data-opportunity-rating><section class="r-in">
  193. <h2 class="rating-title">多人评分</h2>
  194. ${linked.length > 1 || (this.artifactId && this.opportunityId && !selected) ? html`
  195. - <label class="rating-label" for="rating-opportunity">选择要评分的机会</label>
  196. - <select id="rating-opportunity" aria-label="评分机会" @change=${e => {
  197. + <label class="rating-label" for="rating-opportunity">选择评分与决策的机会</label>
  198. + <select id="rating-opportunity" aria-label="评分与决策机会" @change=${e => {
  199. const q = new URLSearchParams(this.routeQuery || {});
  200. q.set('opportunity', e.target.value);
  201. navigate(`/report/${encodeURIComponent(this.artifactId)}?${q}`, { replace: true });
  202. @@ -220,7 +224,36 @@
  203. </select>` : ''}
  204. ${selected ? html`<p class="rating-target">${selected.title}</p>
  205. ${keyed(selected.id, html`<kf-score-panel compact .opportunityId=${selected.id} .stats=${selected.stats} .user=${this.me}></kf-score-panel>`)}`
  206. - : html`<p class="evidence-note">${this.opportunityId ? '当前机会与这份报告没有有效关联,请重新选择后评分。' : '这份报告关联多个机会,请先选择要评分的机会。'}</p>`}
  207. + : html`<p class="evidence-note">${this.opportunityId ? '当前机会与这份报告没有有效关联,请重新选择后评分和决策。' : '这份报告关联多个机会,请先选择要评分和决策的机会。'}</p>`}
  208. + </section></kf-glass-card>`;
  209. + }
  210. +
  211. + #decision() {
  212. + const selected = this.#selectedOpportunity();
  213. + if (!selected) return '';
  214. + const decision = selected.decision;
  215. + const history = selected.decisionLog || [];
  216. + return html`<kf-glass-card surface="1" class="r decision-card" data-opportunity-decision=${selected.id}><section class="r-in">
  217. + <div class="decision-heading"><h2 class="rating-title">机会决策</h2><kf-badge kind="status" .value=${selected.status}></kf-badge></div>
  218. + <p class="rating-target">${selected.title}</p>
  219. + <div class="decision-actions" role="group" aria-label="机会决策">
  220. + ${[['candidate', '候选'], ['approved', '立项'], ['rejected', '否决']].map(([status, label]) => html`
  221. + <button type="button" class=${status} aria-pressed=${selected.status === status ? 'true' : 'false'}
  222. + @click=${() => this.dispatchEvent(new CustomEvent('status-change', { detail: { id: selected.id, status }, bubbles: true, composed: true }))}>${label}</button>`)}
  223. + </div>
  224. + <section class="decision-summary"><h3>决策说明</h3>
  225. + ${decision ? html`<p class="decision-label">${decision.label}</p>
  226. + ${decision.reason ? html`<p class="decision-reason">${decision.reason}</p>` : html`<p class="evidence-note">未填写决策说明。</p>`}
  227. + <p class="decision-meta">${decision.by || '未署名'} · ${fmtDate(decision.at, true) || '时间未记录'}</p>`
  228. + : html`<p class="evidence-note">${selected.status === 'candidate' ? '尚无决策,等待评估。' : '尚无决策说明,等待补充记录。'}</p>`}
  229. + </section>
  230. + <details class="decision-history">
  231. + <summary>决策记录 ${history.length} 条</summary>
  232. + ${history.length ? html`<ol>${[...history].reverse().map(item => html`<li>
  233. + <p class="decision-label">${item.label}</p>${item.reason ? html`<p class="decision-reason">${item.reason}</p>` : ''}
  234. + <p class="decision-meta">${item.by || '未署名'} · ${fmtDate(item.at, true) || '时间未记录'}</p>
  235. + </li>`)}</ol>` : html`<p class="evidence-note">暂无决策记录。</p>`}
  236. + </details>
  237. </section></kf-glass-card>`;
  238. }
  239. @@ -239,14 +272,14 @@
  240. return html`<nav class="reading-nav" aria-label="报告导航"><a href=${href(back)} data-link data-back>← 返回${back.startsWith('/pool') ? '机会卡池' : '列表'}</a>${this.#ratingJump()}</nav>
  241. <kf-glass-card surface="2" class="cover"><div class="cover-in"><h1>${opportunity.title}</h1><p class="sub">暂无完整报告</p></div></kf-glass-card>
  242. <div class="layout"><div class="col">
  243. - <article class="ch kf-card" data-report-pending><h2>报告尚未收录</h2><p class="para">这张机会卡尚未关联可读取的报告正文。以下保留已提交的机会说明与来源,可先阅读并参与评分。</p>
  244. + <article class="ch kf-card" data-report-pending><h2>报告尚未收录</h2><p class="para">这张机会卡尚未关联可读取的报告正文。以下保留已提交的机会说明与来源,可先阅读并参与评分和决策。</p>
  245. ${opportunity.oneLiner ? html`<section class="sec"><h4>机会说明</h4><p class="para">${opportunity.oneLiner}</p></section>` : ''}
  246. <section class="sec"><h4>提交来源</h4><p class="para">${opportunity.upload.uploadedBy || '提交人未记录'} · ${opportunity.upload.agentName || '数字人未记录'} · ${opportunity.upload.methodLabel || '方式未记录'} · ${fmtDate(opportunity.upload.uploadedAt, true) || '时间未记录'}</p></section>
  247. </article></div>
  248. <aside class="rail with-rating"><kf-glass-card surface="1" class="r"><div class="r-in"><h2 class="rating-title">证据链</h2>
  249. ${opportunity.linkedArtifacts.length ? opportunity.linkedArtifacts.map(a => a.missing ? html`<p class="evidence-note">${a.title}</p>` : html`<button class="linked-evidence" @click=${() => this.#artifact(a.id)}>${a.shortTitle || a.title}</button>`)
  250. : html`<p class="evidence-note">尚未关联成果来源。</p>`}
  251. - </div></kf-glass-card>${this.#rating()}</aside>
  252. + </div></kf-glass-card>${this.#rating()}${this.#decision()}</aside>
  253. </div>`;
  254. }
  255. @@ -367,6 +400,7 @@
  256. <button class="kf-btn" @click=${() => this.#artifact(this.artifactId)}>证据链</button>
  257. </div></kf-glass-card>
  258. ${this.#rating()}
  259. + ${this.#decision()}
  260. </aside>
  261. </div>
  262. </div>
  263. --- a/src/pages/report-styles.js
  264. +++ b/src/pages/report-styles.js
  265. @@ -137,6 +137,25 @@
  266. .rating-label { font-size:12px; color:var(--kf-ink-3); }
  267. .rating-card { scroll-margin-top:var(--report-anchor,84px); }
  268. .rating-card select { width:100%; min-width:0; min-height:40px; padding:8px; border:1px solid var(--kf-line-strong); border-radius:8px; background:var(--kf-surface); color:var(--kf-ink); font:12px var(--kf-font); }
  269. + .decision-heading { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
  270. + .decision-actions { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); border:1px solid var(--kf-line-strong); border-radius:8px; overflow:hidden; }
  271. + .decision-actions button { appearance:none; border:0; margin:0; min-width:0; min-height:40px; padding:8px; background:var(--kf-surface); color:var(--kf-ink-2); font:500 13px/1.5 var(--kf-font); cursor:pointer; }
  272. + .decision-actions button+button { border-left:1px solid var(--kf-line-strong); }
  273. + .decision-actions button:hover { background:var(--kf-surface-btn); }
  274. + .decision-actions button[aria-pressed="true"] { color:#fff; background:var(--kf-ink); }
  275. + .decision-actions button.approved[aria-pressed="true"] { background:#0f5232; }
  276. + .decision-actions button.rejected[aria-pressed="true"] { background:#8a1a14; }
  277. + .decision-actions button:focus-visible { outline:2px solid var(--kf-focus); outline-offset:-3px; }
  278. + .decision-summary { border-top:1px solid var(--kf-line); padding-top:14px; }
  279. + .decision-summary h3 { margin:0 0 8px; font-size:13px; font-weight:600; color:var(--kf-ink); }
  280. + .decision-label,.decision-reason,.decision-meta { margin:0; overflow-wrap:anywhere; }
  281. + .decision-label { font-size:13px; line-height:1.8; font-weight:600; color:var(--kf-ink); }
  282. + .decision-reason { margin-top:6px; font-size:13px; line-height:1.9; color:var(--kf-ink-2); white-space:pre-wrap; }
  283. + .decision-meta { margin-top:6px; font-size:12px; line-height:1.8; color:var(--kf-ink-3); }
  284. + .decision-history { border-top:1px solid var(--kf-line); padding-top:10px; }
  285. + .decision-history summary { cursor:pointer; min-height:30px; font-size:13px; line-height:1.8; color:var(--kf-ink-2); }
  286. + .rail .decision-history ol { gap:12px; margin-top:10px; }
  287. + .decision-history li { border:1px solid var(--kf-line); border-radius:8px; padding:12px; min-width:0; background:var(--kf-surface-btn); }
  288. .linked-evidence { border:0; padding:6px 0; background:transparent; color:var(--kf-accent-ink); text-align:left; font:12px/1.8 var(--kf-font); cursor:pointer; overflow-wrap:anywhere; }
  289. .rail ol { display:grid; gap:2px; list-style:none; margin:0; padding:0; }
  290. .rail a.jump { display:block; padding:9px 0; font-size:12px; line-height:1.9; color:var(--kf-ink-3); text-decoration:none; }
  291. @@ -147,7 +166,7 @@
  292. .chain i { font-style:normal; font-size:11px; color:var(--kf-ink-3); }
  293. @media(min-width:1500px) { .cover { --kf-card-pad:32px; } article.ch { padding:32px; } }
  294. - @media(max-width:1100px) { .layout { grid-template-columns:1fr; } aside.rail { position:static; grid-template-columns:repeat(auto-fit,minmax(min(210px,100%),1fr)); } }
  295. + @media(max-width:1100px) { .layout { grid-template-columns:1fr; } aside.rail { position:static; grid-template-columns:repeat(auto-fit,minmax(min(210px,100%),1fr)); } .rail.with-rating .rating-card,.rail.with-rating .decision-card { grid-column:1/-1; } }
  296. @media(max-width:767px) {
  297. :host { --report-gap:18px; --report-radius:12px; }
  298. .cover { --kf-card-pad:20px; }
  299. @@ -183,6 +202,8 @@
  300. .attachment-grid { grid-template-columns:1fr; }
  301. .file-entry { padding:16px; }
  302. .chain { grid-template-columns:repeat(4,1fr); }
  303. + .decision-actions button { min-height:44px; }
  304. + .decision-history summary { min-height:44px; display:flex; align-items:center; }
  305. }
  306. @media(max-width:360px) { .cover { --kf-card-pad:16px; } article.ch { padding:16px; } .cover h1 { font-size:21px; } .finding { padding:12px; } }
  307. @media print {
  308. --- a/src/pages/report-card.js
  309. +++ b/src/pages/report-card.js
  310. @@ -185,9 +185,9 @@
  311. .cfoot .kf-btn { height: 28px; padding: 0 10px; font-size: 12px; }
  312. /* v15:工作台的机会卡(.rc.play)—— 比类型页的成果卡矮一档,只讲「是什么 + 三个数 + 状态」 */
  313. - .rc.play { display: flex; flex-direction: column; gap: 10px; height: 100%; min-width: 0; }
  314. + .rc.play { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
  315. .rc.play h3 { font-size: 16px; }
  316. - .rc.play .lead { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; min-height: calc(2 * 1.6em); }
  317. + .rc.play .lead { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
  318. .rc.play .top .right { margin-left: auto; }
  319. .rc.play .opp-st { font-size: 12px; line-height: 20px; padding: 0 8px; border-radius: var(--r-sm); border: 1px solid var(--kf-line-soft); color: var(--kf-ink-2); white-space: nowrap; }
  320. .rc.play .opp-st.st-approved { color: var(--kf-ok-text); border-color: rgba(21,92,58,0.35); background: rgba(21,92,58,0.06); }
  321. @@ -197,27 +197,6 @@
  322. .rc.play .play-nums > div:first-child { border-left: 0; padding-left: 0; }
  323. .rc.play .play-nums b { display: block; font-size: 20px; font-weight: 600; line-height: 1.1; color: var(--kf-ink); font-variant-numeric: tabular-nums; white-space: nowrap; }
  324. .rc.play .play-nums span { display: block; margin-top: 2px; font-size: 12px; color: var(--kf-ink-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  325. - .rc.play .play-acts { display: flex; align-items: center; gap: 8px; margin-top: 2px; }
  326. - /* v16:只有按钮文字的样式放这里;布局(内边距 / 间隙 / 是否撑满)由页面给 —— 同一条 ">
  327. - 规则写进 CARD_CSS 后,工作台的 flex 页脚(空 span 把「详情 →」推到最右)会被 gap 撑出 84px。 */
  328. - .card-play .quiet button { white-space: nowrap; }
  329. - .rc.play .quiet button { border: 0; background: none; padding: 0; height: 20px; font: 500 12px/1 var(--kf-font); color: var(--kf-ink-3); cursor: pointer;
  330. - text-box-trim: trim-both; text-box-edge: cap alphabetic; }
  331. - .rc.play .quiet button:hover { color: var(--kf-ink); }
  332. - .rc.play .quiet button[aria-pressed="true"] { color: var(--kf-accent-ink); font-weight: 600; box-shadow: inset 0 -1px 0 var(--kf-accent); }
  333. - .rc.play .sp { flex: 1; }
  334. - .rc.play .more { font-size: 12px; color: var(--kf-ink-3); }
  335. - @media(max-width:1023px){
  336. - .rc.play .play-acts{flex-wrap:wrap;gap:6px}
  337. - .rc.play .quiet{display:flex;flex-wrap:wrap;gap:4px 10px;min-width:0}
  338. - .rc.play .sp{display:none}
  339. - .rc.play .more{margin-left:auto;white-space:nowrap}
  340. - }
  341. - @media(max-width:767px){
  342. - .rc.play .quiet{gap:4px}
  343. - .rc.play .quiet button{min-height:44px;min-width:44px;height:auto;padding:8px 6px;touch-action:manipulation}
  344. - .rc.play .more{display:inline-flex;align-items:center;min-height:44px}
  345. - }
  346. `;
  347. /** 证据档位的文字(跟在数字后面当单位,1.2):2 → 「中」 */
  348. @@ -308,14 +287,12 @@
  349. /**
  350. * 工作台用的「安静」机会卡(v15):和卡池那张卡同源(同一个 normalized opportunity),
  351. - * 只留「这张卡是什么 + 关键数字 + 快捷决策」三块,整卡直接阅读报告。
  352. + * 只留机会信息、关键数字与当前状态,整卡直接阅读报告;决策在报告侧栏操作。
  353. * opp 机会卡对象(core normalizeOpportunity 的产物)
  354. - * status 当前状态(渲染方持有;点击只回 onStatus,由页面去写)
  355. - * onStatus (status) => void
  356. - */
  357. -export function playCard({ opp: o, status, onStatus }) {
  358. + */
  359. +export function playCard({ opp: o }) {
  360. const s = o.stats || { avg: null, n: 0 };
  361. - const st = status || o.status;
  362. + const st = o.status;
  363. return html`
  364. <div class="rc play card-play" data-opp=${o.id}>
  365. <div class="top">
  366. @@ -331,13 +308,6 @@
  367. <div><b>${o.evidenceLabel}</b><span>证据</span></div>
  368. <div><b>${o.quoteCount}</b><span>条引用</span></div>
  369. </div>
  370. - <!-- v16:三连灰底按钮 → 静默文字行(dr6/dr1 的卡片底部没有按钮组)。字重 500,选中项加粗 + 下划线 -->
  371. - <div class="play-acts" data-no-activate>
  372. - <div class="quiet" role="group" aria-label="改状态">
  373. - ${['candidate', 'approved', 'rejected'].map((k) => html`<button type="button" ?aria-pressed=${st === k}
  374. - @click=${(e) => { e.preventDefault(); e.stopPropagation(); onStatus?.(k); }}>${OPP_STATUS_LABEL[k]}</button>`)}
  375. - </div>
  376. - </div>
  377. </div>`;
  378. }
  379. @@ -351,8 +321,7 @@
  380. const f = cardFacts(e);
  381. const lead = String(e.lead || '').trim();
  382. const q = (e.quotes || [])[0];
  383. - /* 不编文案(v13 二):没有描述就不渲染描述区,`.nolead` 让卡内纵向重新均分。
  384. - 注意 /pool 的机会卡不是这个组件(它在 kf-opportunity-card.js,一句话恒占两行高)。 */
  385. + /* 没有描述就不渲染描述区,`.nolead` 调整卡内排布。 */
  386. return html`
  387. <div class="rc ${fit ? 'fit' : ''} ${lead ? '' : 'nolead'}" data-entry=${e.id} data-kind=${e.kind} data-stage=${f.stage} data-owner=${e.owner || ''}>
  388. <div class="top">
  389. --- a/src/pages/view-pool.js
  390. +++ b/src/pages/view-pool.js
  391. @@ -99,7 +99,7 @@
  392. .frow.toolbar .cfoot[data-foot] { margin: 0 0 0 4px; justify-content: flex-start;
  393. padding: 0 0 0 12px; border-top: 1px solid var(--kf-line); border-right: 0; border-bottom: 0; border-left: 1px solid var(--kf-line); }
  394. .sfield input[type="search"] { margin-left: 0; }
  395. - .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(330px, 1fr)); gap: 16px; }
  396. + .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(330px, 1fr)); gap: 16px; align-items: start; }
  397. .cards kf-opportunity-card.is-focus { outline: 2px solid var(--kf-accent); outline-offset: 3px; border-radius: var(--r-md); }
  398. .count { font-size: 14px; color: var(--kf-ink-3); font-variant-numeric: tabular-nums; }
  399. .count b { color: var(--kf-ink); font-variant-numeric: tabular-nums; }
  400. @@ -182,11 +182,6 @@
  401. const c = { ...this.#criteria, q };
  402. // 搜索输入用 replace 节流:每敲一个字不新增历史
  403. this.#apply(c);
  404. - }
  405. -
  406. - #changeStatus(e) {
  407. - e.stopPropagation();
  408. - this.dispatchEvent(new CustomEvent('status-change', { detail: e.detail, bubbles: true, composed: true }));
  409. }
  410. render() {
  411. @@ -249,7 +244,7 @@
  412. ${filtered.length ? html`<div class="cards kf-enter" data-list-ids=${filtered.map((o) => o.id).join(',')}>
  413. ${filtered.map((o) => html`<kf-opportunity-card key=${o.id} class=${o.id === this.focusId ? 'is-focus' : ''} .opp=${o} base="/pool"
  414. @card-open=${(e) => { e.stopPropagation(); this.dispatchEvent(new CustomEvent('card-open', { detail: e.detail, bubbles: true, composed: true })); }}
  415. - @status-change=${this.#changeStatus}></kf-opportunity-card>`)}
  416. + ></kf-opportunity-card>`)}
  417. </div>` : html`<kf-glass-card surface="2"><kf-empty title="没有符合条件的卡" hint="试试点上面的「清除全部条件」,或换一个排序看看。" action-label="清除全部条件" @empty-action=${() => navigate('/pool', { replace: true })}></kf-empty></kf-glass-card>`}
  418. ${/* 筛选栏已收起页脚(排序行末尾:本页条数 + 回到顶部),卡片流之后不再重复 */ ''}
  419. </div>
  420. --- a/src/pages/view-workbench.js
  421. +++ b/src/pages/view-workbench.js
  422. @@ -192,11 +192,8 @@
  423. .cards-main { grid-column: span 8; min-width: 0; }
  424. .cards-main .feed-head { margin-bottom: 0; }
  425. .cards-main .cards { margin-top: 12px; }
  426. - .cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; align-items: stretch; }
  427. + .cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; align-items: start; }
  428. .cards > kf-glass-card { min-width: 0; }
  429. - /* 机会卡底部「候选 / 已立项 / 已否决」:静默文字行(20px、无底),页脚仍是「左段 + 详情 →」两端对齐 */
  430. - .cards .quiet { display: inline-flex; gap: 14px; }
  431. - .cards .quiet button { height: 20px; }
  432. .side-stack { grid-column: span 4; min-width: 0; }
  433. .side-in { display: flex; flex-direction: column; height: 100%; gap: 8px; }
  434. .side-in [data-feed] { flex: 1 1 auto; }
  435. @@ -295,7 +292,7 @@
  436. <div class="cards-main">
  437. <div class="feed-head"><span class="lbl">${t('最近动过')}</span>
  438. <a class="go" href=${href('/pool')} data-link aria-label="全部机会卡">全部 ${pool.total} →</a></div>
  439. - ${play.length ? html`<div class="cards">${play.map((o) => html`<kf-glass-card lift fill-to @card-activate=${() => this.#openCard(o.id)}>${playCard({ opp: o, status: o.status, onStatus: (st) => this.#setStatus({ id: o.id, status: st }) })}</kf-glass-card>`)}</div>`
  440. + ${play.length ? html`<div class="cards">${play.map((o) => html`<kf-glass-card lift @card-activate=${() => this.#openCard(o.id)}>${playCard({ opp: o })}</kf-glass-card>`)}</div>`
  441. : html`<p class="feed-empty">还没有机会卡</p>`}
  442. </div>
  443. <kf-glass-card class="side-stack" data-role="g" fill-to>
  444. @@ -319,11 +316,6 @@
  445. /** 与卡池共用机会报告入口,不再经过详情弹窗。 */
  446. #openCard(id) {
  447. this.dispatchEvent(new CustomEvent('opportunity-open', { detail: { id }, bubbles: true, composed: true }));
  448. - }
  449. -
  450. - /** 快捷决策:复用 app 的 status-change(带操作人校验与写入),与卡池同一路径 */
  451. - #setStatus({ id, status }) {
  452. - this.dispatchEvent(new CustomEvent('status-change', { detail: { id, status }, bubbles: true, composed: true }));
  453. }
  454. #stagebar(rows, maxSub, picked) {