Просмотр исходного кода

fix(v13-二): reportCard 无描述卡不渲染描述区,卡内按百分比行重新均分;修 .src 最小宽撑破网格导致卡多出 44px

FmodeCode 5 дней назад
Родитель
Сommit
64e4ae7511
2 измененных файлов с 76 добавлено и 48 удалено
  1. 65 47
      scripts/verify-visual-v13.mjs
  2. 11 1
      src/pages/report-card.js

+ 65 - 47
scripts/verify-visual-v13.mjs

@@ -117,43 +117,54 @@ const out = { tag: TAG, at: new Date().toISOString(), one: null, two: null, thre
   await mpage.context().close();
 }
 
-/* ======================= 二、机会卡描述 ======================= */
+/* ======================= 二、报告卡描述(reportCard 的 .lead) =======================
+   量 /reports 与 /stages 的每一张 .rc:
+     - 有无描述(.lead 元素存在且有文字)
+     - 标题 → 指标区(.m)之间的缝:有描述的卡把这条缝摊给四张卡(flex 平均分),
+       无描述的卡应把同一条缝均分到卡内各条缝 —— 不再是 66px 一条空缝
+     - 内容总高 = .rc 内第一个可见子块顶 → 最后一个子块底
+     - 最大连续空白 = .rc 内叶子文字矩形按 top 排,相邻两块之间竖直间隙的最大值 */
 {
-  await gotoView('/pool', 'view-pool');
-  out.two = await page.evaluate(`(async () => {
-    ${DEEP}
-    const v = appR().querySelector('view-pool').shadowRoot;
-    const cards = [...v.querySelectorAll('kf-opportunity-card')];
-    const cardsInfo = cards.map((c) => {
-      const sr = c.shadowRoot, w = sr.querySelector('.wrap'), one = sr.querySelector('.one');
-      const wrap = w.getBoundingClientRect();
-      /* 叶子文字矩形:与 v12「卡内连续空白」同口径 */
-      const leaves = [...sr.querySelectorAll('*')].filter((e) => {
-        if (![...e.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim())) return false;
-        const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0;
-      }).map((e) => e.getBoundingClientRect()).sort((a, b) => a.top - b.top || a.left - b.left);
-      let maxGap = 0, gapAt = '';
-      for (let i = 1; i < leaves.length; i++) {
-        if (leaves[i].top < leaves[i - 1].bottom) continue;  /* 同一行的兄弟 */
-        const g = Math.round(leaves[i].top - leaves[i - 1].bottom);
-        if (g > maxGap) { maxGap = g; gapAt = Math.round(leaves[i - 1].bottom) + '→' + Math.round(leaves[i].top); }
-      }
-      const nums = sr.querySelector('.nums').getBoundingClientRect();
-      const title = sr.querySelector('.title').getBoundingClientRect();
-      const acts = sr.querySelector('.acts').getBoundingClientRect();
-      const hasOne = !!one && one.getBoundingClientRect().height > 0;
-      /* 内容总高 = 首块顶到末块底(不含卡片内衬) */
-      const first = leaves[0], last = leaves[leaves.length - 1];
-      return { title: c.opp.title.slice(0, 14), desc: hasOne ? one.textContent.trim().slice(0, 10) : '',
-        descLen: hasOne ? one.textContent.trim().length : 0, descH: hasOne ? Math.round(one.getBoundingClientRect().height) : 0,
-        lineH: one ? getComputedStyle(one).lineHeight : null, gapAfterTitle_toNums: Math.round(nums.top - title.bottom),
-        contentH: Math.round(last.bottom - first.top), cardH: Math.round(c.getBoundingClientRect().height),
-        maxGap, gapAt, actsBottom: Math.round(acts.bottom - wrap.top), numsTop: Math.round(nums.top - wrap.top) };
-    });
-    /* 有没有空的描述占位:.one 存在但盒子高度为 0 / 只有 padding 的盒子 / aria-hidden 占位 */
-    const empties = cardsInfo.filter((x) => !x.desc).length;
-    return { n: cardsInfo.length, emptyCount: empties, cards: cardsInfo };
-  })()`);
+  out.two = { pages: [] };
+  for (const [p, name] of [['/reports', 'view-reports'], ['/stages', 'view-stages']]) {
+    await gotoView(p, name);
+    const r = await page.evaluate(`(async () => {
+      ${DEEP}
+      const root = appR().querySelector('${name}').shadowRoot;
+      const cards = [...root.querySelectorAll(".rc")].filter((c) => c.getBoundingClientRect().height > 0 && c.getBoundingClientRect().width > 0);
+      const cardsInfo = cards.map((c) => {
+        const lead = c.querySelector('.lead');
+        const tk = c.querySelector('h3').getBoundingClientRect();
+        const m = c.querySelector('.m').getBoundingClientRect();
+        const kids = [...c.children].filter((e) => e.getBoundingClientRect().height > 0);
+        const first = kids[0].getBoundingClientRect(), last = kids[kids.length - 1].getBoundingClientRect();
+        /* 叶子文字块:先取最内层的文字元素(祖先容器不重复计,否则父盒扣掉 padding 后会造出假空白)。
+           display:contents(.src 在手机档里的 .l1/.l2)没有盒,跳过。 */
+        const textAll = [...c.querySelectorAll('*')].filter((e) => [...e.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim()));
+        const inner = textAll.filter((e) => !textAll.some((o) => o !== e && e.contains(o)));
+        const leaves = inner.map((e) => e.getBoundingClientRect()).filter((r) => r.width > 0 && r.height > 0).sort((a, b) => a.top - b.top || a.left - b.left);
+        let maxGap = 0, gapAt = '';
+        for (let i = 1; i < leaves.length; i++) {
+          if (leaves[i].top < leaves[i - 1].bottom - 1) continue;
+          /* 水平不相交(并排的列)不算卡内纵向空白 */
+          if (leaves[i].left >= leaves[i - 1].right - 1 || leaves[i - 1].left >= leaves[i].right - 1) continue;
+          const g = Math.round(leaves[i].top - leaves[i - 1].bottom);
+          if (g > maxGap) { maxGap = g; gapAt = Math.round(leaves[i - 1].bottom) + '→' + Math.round(leaves[i].top); }
+        }
+        /* 卡内每条缝(相邻子块之间),以及每块子块的 stretch 情况 */
+        const gaps = [];
+        for (let i = 1; i < kids.length; i++) gaps.push(Math.round(kids[i].getBoundingClientRect().top - kids[i - 1].getBoundingClientRect().bottom));
+        return { title: (c.querySelector('h3').textContent || '').trim().slice(0, 14),
+          lead: lead ? lead.textContent.trim().slice(0, 12) : null, leadTxt: lead ? lead.textContent.trim() : '',
+          gapTitleToM: Math.round(m.top - tk.bottom), contentH: Math.round(last.bottom - first.top),
+          cardH: Math.round(c.closest('kf-glass-card').getBoundingClientRect().height),
+          maxGap, gapAt, rowIdx: Math.round(c.getBoundingClientRect().top) };
+      });
+      return { n: cardsInfo.length, cards: cardsInfo };
+    })()`);
+    /* 同排(top 相同)卡片的等高由 v12 脚本管;这里只记 */
+    out.two.pages.push({ page: p, ...r });
+  }
 }
 
 /* ======================= 三、页脚类名命中 ======================= */
@@ -263,19 +274,26 @@ console.log(`\n===== v13 三项验收 · tag=${TAG} =====`);
 }
 
 /* 二 */
+function avg(arr, k) { return arr.reduce((n, x) => n + x[k], 0) / arr.length; }
 {
-  const t = out.two;
-  const withD = t.cards.filter((c) => c.descLen), without = t.cards.filter((c) => !c.descLen);
-  const maxGap = Math.max(...t.cards.map((c) => c.maxGap));
-  const dh = withD.length && without.length ? Math.abs(avg(withD, 'contentH') - avg(without, 'contentH')) : -1;
-  const heights = new Set(t.cards.map((c) => c.cardH));
-  check('二 空描述:无描述卡不渲染描述区 + 内容高差 ≤ 40px + 卡内空白 ≤ 24px',
-    without.length === 0 || (heights.size === 1 && without.every((c) => c.descH === 0 && c.gapAfterTitle_toNums === withD[0]?.gapAfterTitle_toNums && c.lineH == null) && dh <= 40 && maxGap <= 24),
-    `共 ${t.n} 张,无描述 ${t.emptyCount} 张;有描述卡内容高 ${withD.map((c) => c.contentH).join('/')},无描述卡内容高 ${without.map((c) => c.contentH).join('/')},差 ${dh.toFixed(1)}px` +
-    `;卡片高去重 [${[...heights].join(',')}];最大连续空白 ${maxGap}px(${t.cards.find((c) => c.maxGap === maxGap)?.title}:${t.cards.find((c) => c.maxGap === maxGap)?.gapAt})` +
-    `;无描述卡:.one 存在=${without.some((c) => c.lineH != null)}(false=不渲染)`);
+  const parts = out.two.pages.map((pg) => {
+    const withD = pg.cards.filter((c) => c.lead), without = pg.cards.filter((c) => !c.lead);
+    const dh = withD.length && without.length ? Math.abs(avg(withD, 'contentH') - avg(without, 'contentH')) : null;
+    const maxGap = Math.max(...pg.cards.map((c) => c.maxGap));
+    const gapW = withD.length ? avg(withD, 'gapTitleToM') : 0;
+    const gapN = without.length ? avg(without, 'gapTitleToM') : 0;
+    const sameRow = new Set(pg.cards.filter((c) => !c.lead).map((c) => c.cardH));
+    return { pg, withD, without, dh, maxGap, gapW, gapN, sameRow,
+      ok: !without.length || (dh !== null && dh <= 40 && maxGap <= 24 && gapN < gapW * 0.8 && sameRow.size === 1) };
+  });
+  check('二 空描述卡:不渲染描述区 + 卡内重新均分 + 内容高差 ≤ 40px + 卡内空白 ≤ 24px',
+    parts.every((x) => x.ok),
+    parts.map(({ pg, withD, without, dh, maxGap, gapW, gapN, sameRow }) =>
+      `${pg.page}:共 ${pg.n} 张,无描述 ${without.length} 张(${without.map((c) => c.title).join('、') || '—'})` +
+      `;有描述卡 gap(标题→指标) 均值 ${gapW.toFixed(1)}px / 无描述卡 ${gapN.toFixed(1)}px` +
+      `;内容高 有描述 [${withD.map((c) => c.contentH).join(',')}] 无描述 [${without.map((c) => c.contentH).join(',')}] 差 ${dh == null ? '—' : dh.toFixed(1) + 'px'}` +
+      `;无描述卡高去重 [${[...sameRow].join(',')}];最大连续空白 ${maxGap}px`).join('\n    '));
 }
-function avg(arr, k) { return arr.reduce((n, x) => n + x[k], 0) / arr.length; }
 
 /* 三 */
 {

+ 11 - 1
src/pages/report-card.js

@@ -32,6 +32,14 @@ export const CARD_CSS = css`
   .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; }
+  /* v13 二:没有描述(lead)的卡,多出来的高度不进「标题 → 指标区」这一条缝(那就是一块空到 66px 的空白),
+     整张卡重新均分到卡内各块之间。无 lead 的卡挂 .nolead。
+     列宽必须 minmax(0, 1fr):.src 里「上传人未记录 · Agent 未记录 · 方式未记录 · 时间未记录」四项 nowrap,
+     最小内容宽 140px;默认 min-width:auto 时轨道被撑成两个 140px 再换行,行高翻倍溢出去(实测卡多出 44px)。
+     有描述的卡不挂 .nolead,不受影响(仍是 .rc.fit 的 flex 布局,来源行钉底边)。 */
+  .rc.fit.nolead { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: 25% 30% 30% auto 15%; row-gap: var(--kf-space-in); }
+  .rc.fit.nolead > * { margin-top: 0; min-width: 0; }
+  .rc.fit.nolead > .src { margin-top: 0; }
   /* 卡内元素间距 12:用相邻兄弟给,块布局下卡片被拉高也不会把间隙撑开 */
   /* 卡内元素间距 12:用相邻兄弟给,块布局下卡片被拉高也不会把间隙撑开 */
   .rc > * + * { margin-top: var(--kf-space-in); }
@@ -256,8 +264,10 @@ export function reportCard({ e, recent = false, typeName = '', expanded = false,
   const f = cardFacts(e);
   const lead = String(e.lead || '').trim();
   const q = (e.quotes || [])[0];
+  /* 不编文案(v13 二):没有描述就不渲染描述区,`.nolead` 让卡内纵向重新均分。
+     注意 /pool 的机会卡不是这个组件(它在 kf-opportunity-card.js,一句话恒占两行高)。 */
   return html`
-    <div class="rc ${fit ? 'fit' : ''}" data-entry=${e.id} data-kind=${e.kind} data-stage=${f.stage} data-owner=${e.owner || ''}>
+    <div class="rc ${fit ? 'fit' : ''} ${lead ? '' : 'nolead'}" 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)}