소스 검색

feat(v12-P2a): 同排卡片等高(kf-glass-card 吃满行高 + 机会卡/报告卡弹性间隙)

FmodeCode 5 일 전
부모
커밋
e603a99e26

+ 2 - 0
scripts/verify-visual-v12.mjs

@@ -171,6 +171,8 @@ const MEASURE = `(async () => {
     if (!/grid|flex/.test(s.display)) continue;
     const kids = [...el.children].filter((k) => vis(k) && k.getBoundingClientRect().height > 40);
     if (kids.length < 2) continue;
+    /* 整页布局容器(.shell:侧栏 + 主列)不是「同排卡片」:它的两个子项一高一低是页面结构本身 */
+    if (el.matches('.shell, body, main, #view, .main')) continue;
     const tops = new Map();
     for (const k of kids) {
       const r = k.getBoundingClientRect();

+ 9 - 4
src/components/kf-glass-card.js

@@ -42,11 +42,16 @@ export class KfGlassCard extends LitElement {
        - 内边距 16(卡内 padding)+ 卡内元素 gap 12 由内容自己的布局给(.rc 的 gap),这里只负责盒子。 */
     /* v11b 4.2:宿主 inline 盒 + 内部普通块。Chrome 153 实测:宿主是 block / flex 时,被栅格 stretch 拉高过后
        卡面会按拉伸高度算(303px 内容 → 335px 卡面);inline 化以后卡面按内容收缩,宿主本身被拉高也只占透明空间。 */
+    /* v12 P2:默认仍是「内容多高卡就多高」;但宿主被栅格行 stretch 拉高时(同一行的卡等高),
+       宿主是 grid 项 → 用 flex 列 + margin:auto 让卡面吃满行高,里层再 flex-grow 分摊(见 opportunity-card .wrap)。
+       要保留「不拉伸」的卡片,调用方给宿主加 data-fit(或者栅格写 align-items:start)。 */
     :host { display: block; min-width: 0; }
-    /* 卡面盒高只由内容决定,与宿主被栅格拉伸的高度无关(block-size: fit-content 让「高度算成拉伸值」的怪行为失效) */
-    .frame { width: 100%; height: fit-content; }
-    .frame { box-sizing: border-box; isolation: isolate; display: block; overflow: clip; min-height: 0; transition-property: none; }
-    .frame > .face { display: block; position: relative; z-index: 2; box-sizing: border-box; padding: var(--kf-space-card); border-radius: var(--r-md); }
+    /* 高度只在「宿主的父级是 grid / flex」时才吃满行高:Chrome 153 下 grid —— 父级用 @container style 判断不了,
+       于是用 height:100%:父级不是 grid/flex(普通块)时,百分比高度相对于 auto 高度解析为 auto,等于原行为。 */
+    :host { height: 100%; }
+    .frame { width: 100%; height: 100%; min-height: 0; display: flex; flex-direction: column; }
+    .frame { box-sizing: border-box; isolation: isolate; overflow: clip; min-height: 0; transition-property: none; }
+    .frame > .face { display: block; position: relative; z-index: 2; box-sizing: border-box; padding: var(--kf-space-card); border-radius: var(--r-md); flex: 1 1 auto; min-height: 0; }
     slot { display: block; }
     /* hover 光斑:只亮边框;坐标由 kf-app 的 pointermove 写入 --x / --y */
     :host([lift]) .frame::before {

+ 8 - 2
src/components/kf-opportunity-card.js

@@ -45,6 +45,9 @@ export class KfOpportunityCard extends LitElement {
   static styles = css`
     /* v11:高度只由内容决定(v11 10 的 padding 规则换掉了 face 内衬后,「100% + 栅格 stretch」会把三张卡拉成同一高、内容被纵向拉开) */
     :host { display: block; min-width: 0; }
+    /* 卡内三段:标题区(top / title / one,定高内容)/ 指标区 / 决策 + 来源 + 操作。
+       同一行的卡被拉成等高后,多出来的高度放进 .dec 与 .one 之间的弹性间隙(.nums 之后),
+       而不是让来源行「沉底 + 中间一大块空」—— 两行标题 / 两行一句话本身就吃掉了高度差。 */
     .wrap { height: 100%; display: flex; flex-direction: column; gap: 12px; }
     .top { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
     .top .right { margin-left: auto; display: flex; gap: 6px; }
@@ -53,7 +56,7 @@ export class KfOpportunityCard extends LitElement {
       word-break: keep-all; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; min-width: 0; }
     /* 行盒修剪 + -webkit-box 行数截断会把行盒压成 ~1 行高(实测 11.6px);按两行高度收(2 × 1.35em) */
     .title { min-height: calc(2.7em + 0.25em); padding-block: 0.125em; }
-    .one { margin: 0; font-size: 13px; 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; }
+    .one { margin: 0; font-size: 13px; 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); }
     .nums { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; padding: 10px 0 8px; border-top: 1px solid var(--kf-line); border-bottom: 1px solid var(--kf-line); }
     .n { min-width: 0; }
     /* 三个数值(1.2 单位分层):大数字 = Inter 数字 + 行盒修剪;单位 11px 灰,留 0.12em */
@@ -63,9 +66,12 @@ export class KfOpportunityCard extends LitElement {
     .n .l { font-size: 11px; color: var(--kf-ink-3); margin-top: 4px; letter-spacing: var(--ls-cn-small); }
     .n.score .v { color: var(--kf-accent-ink); }
     .n.score .v.none { color: var(--kf-ink-3); text-shadow: none; font-weight: 600; font-size: 16px; }
+    /* .dec 的 13px 字,行数不定(有决策 / 无决策两种文案)。多出来的高度交给它前面的弹性间隙,
+       .dec / .src / .acts 三行贴着底边走 —— 同一排卡的「分隔线 / 按钮 / 来源行」落同一 y。 */
     .dec { font-size: 13px; color: var(--kf-ink-2); }
+    .dec { margin-top: auto; }
     .dec b { color: var(--kf-ink); font-weight: 600; }
-    .src { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding-top: 12px; border-top: 1px solid var(--kf-line); font-size: 11px; line-height: 20px; letter-spacing: var(--ls-cn-small); color: var(--kf-ink-3); margin-top: auto; font-variant-numeric: tabular-nums; }
+    .src { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding-top: 12px; border-top: 1px solid var(--kf-line); font-size: 11px; line-height: 20px; letter-spacing: var(--ls-cn-small); color: var(--kf-ink-3); font-variant-numeric: tabular-nums; }
     .src .who { color: var(--kf-ink); font-weight: 600; }
     .src .agent { color: var(--kf-accent-ink); font-weight: 500; }
     .src .miss { color: var(--kf-warn-text); }

+ 7 - 2
src/pages/report-card.js

@@ -26,6 +26,11 @@ export const CARD_CSS = css`
   /* 不写 height:写 max-content 时会被卡面内部的 flex 槽位撑高(实测 303px 的栅格内容算成 335px)。
      卡面高度由外层 .frame / 宿主 block 收缩决定。 */
   .rc { display: block; color: var(--kf-ink-2); line-height: 1.6; }
+  /* v12 P2:卡片被同一行拉成等高时(.rc.fit),标题区定高、来源行钉在底边,
+     多出来的高度全部落在标题与来源之间的弹性间隙里 —— 同排卡的来源行落同一 y。 */
+  .rc.fit { display: flex; flex-direction: column; gap: var(--kf-space-in); height: 100%; box-sizing: border-box; }
+  .rc.fit > * + * { margin-top: 0; }
+  .rc.fit > .src { margin-top: auto; }
   /* 卡内元素间距 12:用相邻兄弟给,块布局下卡片被拉高也不会把间隙撑开 */
   .rc > * + * { margin-top: var(--kf-space-in); }
   .rc .top { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
@@ -219,12 +224,12 @@ export function sourceRow(e, { nowrap = false } = {}) {
  *   recent    是否近 7 天新增(数字旁 ▲)
  *   expanded  把「原文」直接摊开(紧凑列表 / 单一视图);默认收起成一行「原文」,hover 时展开
  */
-export function reportCard({ e, recent = false, typeName = '', expanded = false }) {
+export function reportCard({ e, recent = false, typeName = '', expanded = false, fit = false }) {
   const f = cardFacts(e);
   const lead = String(e.lead || '').trim();
   const q = (e.quotes || [])[0];
   return html`
-    <div class="rc" data-entry=${e.id} data-kind=${e.kind} data-stage=${f.stage} data-owner=${e.owner || ''}>
+    <div class="rc ${fit ? 'fit' : ''}" data-entry=${e.id} data-kind=${e.kind} data-stage=${f.stage} data-owner=${e.owner || ''}>
       <div class="top">
         ${typeName ? html`<span class="badge cat type">${typeName}</span>` : ''}
         ${badges(f)}

+ 1 - 1
src/pages/view-report-type.js

@@ -229,7 +229,7 @@ export class ViewReportType extends LitElement {
               ${pg.items.map((e) => reportRow({ e, recent: recent(e), onOpen: () => this.#open(e) }))}
             </kf-glass-card>`
           : html`<div class="cards" data-cards>
-              ${pg.items.map((e) => html`<kf-glass-card surface="2" lift @card-activate=${() => this.#open(e)}>${reportCard({ e, recent: recent(e) })}</kf-glass-card>`)}
+              ${pg.items.map((e) => html`<kf-glass-card surface="2" lift @card-activate=${() => this.#open(e)}>${reportCard({ e, recent: recent(e), fit: true })}</kf-glass-card>`)}
             </div>`}
       ${needPager ? this.#pager(pg) : ''}
     `;

+ 4 - 4
src/pages/view-reports.js

@@ -59,11 +59,11 @@ export class ViewReports extends LitElement {
   static styles = [VIEW_CSS, CARD_CSS, TOOLBAR_CSS, css`
     /* 不按 5 等分铺满:等分列在本页实测被渲染成 ~217px(< 内容最小宽),数字被压出格。
        改用可换行的自动列,宽了 5 张一行,窄了自然折成 3 + 2,每个格子都保证内容读得清。 */
-    .types { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--kf-space-in); margin: var(--kf-space-in) 0 0; align-items: start; }
+    .types { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--kf-space-in); margin: var(--kf-space-in) 0 0; align-items: stretch; }
     /* <a> 是玻璃容器本身(padding 由这里给);.t 只负责排版,不再撑高 */
-    .types > a { text-decoration: none; color: inherit; min-width: 0; padding: var(--kf-space-card); box-sizing: border-box; }
+    .types > a { text-decoration: none; color: inherit; min-width: 0; padding: var(--kf-space-card); box-sizing: border-box; height: 100%; }
     /* 类型片:名称 + 份数 + 近 7 天 sparkline(形状)+ 阶段分布条形(形状)+ 最新一条。文字只剩名字与时间 */
-    .t { display: grid; gap: var(--kf-space-in); min-width: 0; align-content: start; }
+    .t { display: grid; gap: var(--kf-space-in); min-width: 0; align-content: start; height: 100%; }
     .t .src .it { max-width: 74px; font-size: 11px; }
     .t .src .who { font-size: 11px; }
     .t .src .tm { margin-left: 0; }
@@ -167,7 +167,7 @@ export class ViewReports extends LitElement {
               ${pg.items.map((e) => reportRow({ e, recent: recent(e), onOpen: () => this.#open(e) }))}
             </kf-glass-card>`
           : html`<div class="cards" data-cards>
-              ${pg.items.map((e) => html`<kf-glass-card surface="2" lift @card-activate=${() => this.#open(e)}>${reportCard({ e, recent: recent(e), typeName: e.typeName })}</kf-glass-card>`)}
+              ${pg.items.map((e) => html`<kf-glass-card surface="2" lift @card-activate=${() => this.#open(e)}>${reportCard({ e, recent: recent(e), typeName: e.typeName, fit: true })}</kf-glass-card>`)}
             </div>`}
       ${matched.length > 12 ? html`<div class="pager" data-pager>
         <button ?disabled=${pg.page === 1} aria-label="上一页" @click=${() => this.onCardPage(pg.page - 1)}>${icon('left', 14)}</button>

+ 7 - 5
src/pages/view-stages.js

@@ -43,8 +43,10 @@ export class ViewStages extends LitElement {
 
   static styles = [VIEW_CSS, CARD_CSS, css`
     .overview { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--kf-space-in); margin-top: var(--kf-space-in); }
-    /* align-items: start —— 五张卡各自按内容高,不被行高拉齐(v11b 4.2) */
-    .overview { align-items: start; }
+    /* v12 P2:五张概览卡等高(同一行底边齐);内容是「徽标 + 环 / 份数 / 占比条」三行,撑满不产生虚空 */
+    .overview { align-items: stretch; }
+    .overview > .kf-card { display: flex; }
+    .overview > .kf-card > .ov { width: 100%; align-content: space-between; }
     .overview > .kf-card { padding: var(--kf-space-card); box-sizing: border-box; }
     .ov { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: var(--kf-space-in); cursor: pointer; text-align: left; }
     .ov .b { grid-column: 1; } .ov kf-ring { grid-column: 2; grid-row: 1; }   /* 环与阶段徽标同一行右端 */
@@ -68,8 +70,8 @@ export class ViewStages extends LitElement {
     .tool { display: flex; gap: 8px; align-items: center; margin: var(--kf-space-section) 0 var(--kf-space-in); flex-wrap: wrap; }
     .tool .grow { flex: 1; }
     .tool .cnt { font: 500 11px var(--kf-font-num); color: var(--kf-ink); font-variant-numeric: tabular-nums; }
-    /* align-items: start —— 同一排的卡各自按内容高,不被行高拉齐(v11b 4.2) */
-    .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: var(--kf-space-in); align-items: start; }
+    /* v12 P2:同一排的卡等高(reportCard 的 fit 布局把来源行钉在底边) */
+    .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: var(--kf-space-in); align-items: stretch; }
     [data-stage="S1"] { --tone: var(--kf-s1); } [data-stage="S2"] { --tone: var(--kf-s2); } [data-stage="S3"] { --tone: var(--kf-s3); }
     [data-stage="S4"] { --tone: var(--kf-s4); } [data-stage="S5"] { --tone: var(--kf-s5); }
     @media (max-width: 1100px) { .overview { grid-template-columns: repeat(2, 1fr); } }
@@ -122,7 +124,7 @@ export class ViewStages extends LitElement {
       </div>
 
       ${items.length
-        ? html`<div class="cards" data-cards>${items.map((e) => html`<kf-glass-card surface="2" lift @card-activate=${() => this.#open(e)}>${reportCard({ e, expanded: true })}</kf-glass-card>`)}</div>`
+        ? html`<div class="cards" data-cards>${items.map((e) => html`<kf-glass-card surface="2" lift @card-activate=${() => this.#open(e)}>${reportCard({ e, expanded: true, fit: true })}</kf-glass-card>`)}</div>`
         : html`<kf-glass-card surface="1"><kf-empty title=${only ? t(`${only} 还没有产出`) : '还没有产出'} action-label=${only || evOnly || mineSel ? '清除筛选' : '去工作台'}
             @empty-action=${() => (only || evOnly || mineSel ? this.#set({ stage: '', ev: '', mine: '' }) : navigate('/'))}></kf-empty></kf-glass-card>`}
     `;

+ 3 - 2
src/pages/view-workbench.js

@@ -39,7 +39,8 @@ export class ViewWorkbench extends LitElement {
     /* 卡片之间 24;卡片在图里会被拉伸到同一行高,所以卡片本身就是 flex 列:位置靠上,空的地方露出来 */
     .grid2 { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: var(--kf-space-card-gap); align-items: start; }
     /* 两张卡内容高度不同:各自按内容收缩,不被栅格拉伸(v11b 4.2) */
-    .grid2 > kf-glass-card { align-self: start; }
+    /* v12 P3:左右两卡等高(栅格 stretch,kf-glass-card 宿主 height:100% 吃满行高) */
+    .grid2 > kf-glass-card { height: 100%; }
     /* 页顶 → 首个区块 16(任务书 3.1) */
     /* 页顶 → 首个区块 16(任务书 3.1):页头、第二个区块标题都收这条 */
     .p-head { padding-top: var(--kf-space-top); padding-bottom: var(--kf-space-top); }
@@ -113,7 +114,7 @@ export class ViewWorkbench extends LitElement {
       border: 1px dashed color-mix(in srgb, var(--kf-ink-3) 24%, transparent); border-radius: var(--r-md); }
     .empty .go { margin-left: auto; }
     .stagebar { margin-top: var(--kf-space-in); }
-    .latest { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--kf-space-card-gap); align-items: start; margin-top: var(--kf-space-in); }
+    .latest { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--kf-space-card-gap); align-items: stretch; margin-top: var(--kf-space-in); }
     .latest kf-opportunity-card { --i: 0; }
     @media (max-width: 1100px) { .grid4, .latest { grid-template-columns: repeat(2, minmax(0, 1fr)); } .grid2 { grid-template-columns: 1fr; } }
     /* 手机:单列;KPI 两列;阶段条五格压缩;可点区域 44px */