fix(miniapp): 修复导航页与微信胶囊重合,并把首页两个栏目卡缩到 1/3

问题 1:五个导航页顶部与右上角胶囊按钮(··· ⊙)重合
- 根因:自定义导航用 env(safe-area-inset-top) 取的是设备物理安全区,
  在 Android 上常为 0,完全不反映胶囊位置;且右对齐元素从未为胶囊让出宽度。
- 新增 miniapp/utils/nav-metrics.js 作为唯一事实源:以
  wx.getMenuButtonBoundingClientRect() 实测胶囊矩形,反推并导出四个 CSS 变量
  --nav-status / --nav-h / --nav-right / --nav-top,并提供无接口时的兜底值。
- nav-bar 组件改为自行获取导航度量(attached + observer 双触发),
  标题按胶囊垂直居中并收窄 max-width;外部传参降级为兜底。
- index / discover / messages / activity / mine 五页根结点注入同一组变量,
  WXSS 里的 env(safe-area-inset-top)、硬编码 88rpx 与右对齐 right:0 全量替换。

问题 2:首页「善缘公益服务」「善缘安全提醒」两个栏目过宽
- 根因:封面 <image> 只声明 width 未声明 height,小程序默认按 240px 计算,
  实际把卡片撑到约 480rpx 高。
- 按「整体等比缩到 1/3」重构:两栏改为 mini-card 小卡,宽度
  calc((100% - 2*gap)/3)、高度写死 168rpx,封面 height:100%;
  公益服务一行三卡横向排布,安全提醒为「标题小卡 + 右侧文案条」,
  长文案收进详情页,安全提示原文保留。

守卫与验证
- 新增 ui-tests/miniapp-capsule-safe-contracts.mjs:
  A 层多机型断言导航行与胶囊垂直居中、右对齐让位、navTop 与兜底值;
  B 层纯函数审计五个自带导航行页面(页面清单由文件系统推导,失配直接报红);
  C 层断言首页小卡宽为 1/3 且显式声明高度,并钉住旧 service-/safety- 选择器回归。
  审计前剥离注释,避免守卫被自身示例文本误伤。
- 新增 scripts/audit-image-height.mjs:按属性名精确比对,审计全仓
  「有 width 无 height」的 <image>,当前 30 个类 0 命中。
- 负向注入 N1~N7(含 nav-safe 未接入、旧选择器回归、极小屏幕、无胶囊兜底等)
  全部被守卫咬住并 exit 1,还原后复跑通过。
- 真编译(wcc/wcsc):WXSS 88 / WXML 86,失败 0;
  tsc --noEmit 通过;UI 契约门控 21/21 PASS(新增第 21 个守卫并抬高 MIN_GUARDS)。
- 渲染探针实测:单卡 213rpx x 168rpx;五页右对齐元素右缘 423~528px,
  均在胶囊右缘 365px 之外。

未纳入本次提交:miniapp/project.config.json(微信开发者工具自动改写,
仅追加若干布尔开关并删掉文件末换行)与生成的预览页 artifacts preview.html
(约 7900 行生成产物),以免淹没代码评审视线。
This commit is contained in:
WorkBuddy
2026-09-23 09:55:40 +08:00
parent 2c325136ab
commit 1fc34f1bcd
24 changed files with 734 additions and 96 deletions
+159
View File
@@ -0,0 +1,159 @@
/**
* 顶部安全区几何(状态栏 + 右上角胶囊按钮)——全站唯一事实源
*
* 为什么需要它(2026-09-23 真实缺陷):
* 五个自定义导航页(index / discover / messages / activity / mine)此前用
* `padding-top: env(safe-area-inset-top)` 顶开状态栏。这个值在微信里**只反映设备的
* 物理安全区**(刘海/挖孔),且安卓上常为 0,更关键的是它**完全不反映右上角胶囊按钮
* (「••• ⊙」)的位置**。于是出现两类重合:
* ① 竖向:导航行落在胶囊所在的横带里(消息页四个快捷入口、活动页分类胶囊被压住);
* ② 横向:右对齐元素(首页「消息」、找对象页搜索、活动页日历、我的页「编辑资料」、
* 聊天页「⋯」)直接压在胶囊下面,点不到。
* 正确做法是用 `wx.getMenuButtonBoundingClientRect()` 实测胶囊矩形,反推:
* - navBarHeight:与胶囊同高并垂直居中的导航行高度 = 胶囊高 + 上下间距 ×2
* - reserve :屏幕右边缘到胶囊左边缘的距离 + 余量,即右对齐元素必须让出的宽度
* - navTop :状态栏 + 导航行高度,即页面内容「可以开始」的 y 坐标
*
* 用法(页面):
* const { navSafeData } = require('../../utils/nav-metrics')
* Page({ data: { navVars: '' }, onLoad() { this.setData(navSafeData()) } })
* <view class="page" style="{{navVars}}"> <!-- wxss 里用 var(--nav-top) 等 -->
*
* 单位一律 px(inline style 与 rpx 混用会算错),rpx 换算交给 wxss 侧的 var()。
*/
/** 胶囊左侧再留出的余量,避免右对齐文字与胶囊贴边(px) */
var CAPSULE_GAP_PX = 8;
/** 导航行高度合理区间:过小会压字,过大会把内容推太远(px) */
var MIN_NAV_BAR_HEIGHT = 40;
var MAX_NAV_BAR_HEIGHT = 56;
/** 拿不到胶囊信息时的兜底(按带刘海的 iPhone 取,宁可多留不可少留) */
var FALLBACK = {
windowWidth: 375,
statusBarHeight: 20,
navBarHeight: 44,
reserve: 102,
navTop: 64,
};
var cache = null;
function toPositiveNumber(value, fallback) {
var n = Number(value);
if (!isFinite(n) || n <= 0) return fallback;
return n;
}
function clamp(value, min, max) {
if (value < min) return min;
if (value > max) return max;
return value;
}
/**
* 纯函数:只做几何计算,不碰 wx。便于在 Node 侧用多机型样本做断言。
* @param {{windowWidth?:number, statusBarHeight?:number, capsule?:{top:number,left:number,width:number,height:number,right?:number}}} input
*/
function computeNavMetrics(input) {
var source = input || {};
var windowWidth = toPositiveNumber(source.windowWidth, FALLBACK.windowWidth);
var statusBarHeight = toPositiveNumber(source.statusBarHeight, FALLBACK.statusBarHeight);
var capsule = source.capsule;
var hasCapsule = !!(capsule && Number(capsule.width) > 0 && Number(capsule.height) > 0);
var navBarHeight = FALLBACK.navBarHeight;
var reserve = FALLBACK.reserve;
if (hasCapsule) {
// 胶囊在状态栏下方通常还有一点间距,导航行取「胶囊高 + 上下对称间距」,视觉上正好居中对齐
var gap = Math.max(Number(capsule.top || 0) - statusBarHeight, 0);
navBarHeight = clamp(
Number(capsule.height) + gap * 2,
MIN_NAV_BAR_HEIGHT,
MAX_NAV_BAR_HEIGHT,
);
// 右对齐元素要整体避开胶囊:从右边缘到胶囊左边 + 余量,且不超过半屏(避免极端机型把内容挤没)
reserve = clamp(windowWidth - Number(capsule.left || 0) + CAPSULE_GAP_PX, 0, windowWidth * 0.5);
}
navBarHeight = Math.round(navBarHeight);
reserve = Math.round(reserve);
return {
windowWidth: windowWidth,
statusBarHeight: Math.round(statusBarHeight),
navBarHeight: navBarHeight,
reserve: reserve,
navTop: Math.round(statusBarHeight) + navBarHeight,
source: hasCapsule ? 'capsule' : 'fallback',
};
}
function readWindowInfo() {
try {
if (typeof wx !== 'undefined' && typeof wx.getWindowInfo === 'function') {
return wx.getWindowInfo() || {};
}
} catch (e) { /* 低版本基础库无此接口 */ }
try {
if (typeof wx !== 'undefined' && typeof wx.getSystemInfoSync === 'function') {
return wx.getSystemInfoSync() || {};
}
} catch (e) { /* 接口异常时走兜底 */ }
return {};
}
function readCapsuleRect() {
try {
if (typeof wx !== 'undefined' && typeof wx.getMenuButtonBoundingClientRect === 'function') {
return wx.getMenuButtonBoundingClientRect();
}
} catch (e) { /* 同上 */ }
return null;
}
/**
* 带缓存的取值:同一进程内胶囊几何不会变,避免每页 onLoad 重复调用系统接口。
* @param {boolean} [force] 强制重新读取(机型/窗口变化时可用)
*/
function getNavMetrics(force) {
if (cache && !force) return cache;
var win = readWindowInfo();
cache = computeNavMetrics({
windowWidth: win.windowWidth,
statusBarHeight: win.statusBarHeight,
capsule: readCapsuleRect(),
});
return cache;
}
/**
* 页面 data 片段:同时给出 CSS 变量串(给 wxml 根节点 inline style)与 px 数值。
* wxss 侧约定(四个变量各司其职,别混用):
* --nav-status 状态栏高度:把「导航行」顶到胶囊所在横带的起点
* --nav-h 导航行高度:与胶囊垂直居中对齐,导航行高就写它
* --nav-right 右对齐元素必须让出的宽度(胶囊安全区)
* --nav-top 状态栏 + 导航行 = 内容真正可以开始的 y(导航行之后的第一个区块用)
*/
function navSafeData(force) {
var m = getNavMetrics(force);
return {
navVars: '--nav-status:' + m.statusBarHeight + 'px;'
+ '--nav-h:' + m.navBarHeight + 'px;'
+ '--nav-right:' + m.reserve + 'px;'
+ '--nav-top:' + m.navTop + 'px;',
navTopPx: m.navTop,
navStatusPx: m.statusBarHeight,
navBarHeightPx: m.navBarHeight,
capsuleReservePx: m.reserve,
};
}
/** 仅测试用:清空缓存 */
function clearNavMetricsCache() {
cache = null;
}
module.exports = {
computeNavMetrics: computeNavMetrics,
getNavMetrics: getNavMetrics,
navSafeData: navSafeData,
clearNavMetricsCache: clearNavMetricsCache,
FALLBACK: FALLBACK,
};