Files
WorkBuddy 1fc34f1bcd 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 行生成产物),以免淹没代码评审视线。
2026-09-23 09:55:40 +08:00

193 lines
6.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 公益活动列表页 U31
*
* 重构要点(遵循 AGENTS.md 不变量):
* - 数据:ActivityStore.fetchActivities / loadMoreActivities(游标分页)
* - 列表项:ui-activity-card 组件,"公益免费"为主视觉标识(AGENTS 第 1 条:用户永久免费)
* - 跳转:点击卡片 → activity-detail,不在列表页处理报名
* - 单向意向保密:本页不接入互选意向提交(仅在 activity-matches 页由用户主动操作,AGENTS 第 6 条)
* - 下拉刷新 + onReachBottom 加载更多
*/
import { activityStore, appStore, authStore } from '../../stores';
import { to } from '../../constants';
// 顶部安全区:实测右上角胶囊按钮矩形,避免分类胶囊与日历入口被胶囊压住(见 utils/nav-metrics.js)
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { navSafeData } = require('../../utils/nav-metrics');
interface ActivityCategory {
code: string;
label: string;
}
/**
* 列表项。沿用共享 Activity 类型,并允许服务端附加分类字段用于本地筛选。
* 分类字段在共享类型中尚未收敛,此处显式声明,避免用 any 绕过类型检查。
*/
interface ActivityListItem {
activityId?: string;
id?: string;
activityType?: string;
category?: string;
[key: string]: unknown;
}
interface ActivityPageData {
activities: ActivityListItem[];
loading: boolean;
loadingMore: boolean;
hasMore: boolean;
firstLoaded: boolean;
activeCategory: string;
categories: ActivityCategory[];
emptyConfig: {
title: string;
description: string;
illustration: string;
actions: Array<{ text: string; event: string; variant: string }>;
};
}
Page({
data: {
navVars: '',
activities: [] as ActivityListItem[],
loading: false,
loadingMore: false,
hasMore: true,
firstLoaded: false,
activeCategory: 'all',
/**
* 分类 Tabs。
*
* <p><b>Phase 14 修复</b>:code 必须**逐字等于**后端 {@code activities.activity_type}
* 的取值(生产约束 {@code chk_activity_type} 白名单)。此前这里写的是
* {@code classroom},而生产白名单里根本没有该值(婚恋课堂是
* {@code relationship_education})—— 点「婚恋课堂」Tab 恒为空白页。
*
* <p>守卫 {@code ActivityConstraintGuardTest#miniappCategoryCodesWithinProductionWhitelist}
* 会解析真实 DDL 校验本数组,改错即刻变红。
*/
categories: [
{ code: 'all', label: '全部' },
{ code: 'matchmaking', label: '公益联谊' },
{ code: 'interest_social', label: '兴趣社交' },
{ code: 'relationship_education', label: '婚恋课堂' },
{ code: 'premarital_guidance', label: '婚前辅导' },
{ code: 'marriage_custom_culture', label: '婚俗文化' },
{ code: 'anti_fraud', label: '反诈宣传' },
] as ActivityCategory[],
/** @type {{title:string, description:string, illustration:string, actions:Array}} */
emptyConfig: {
title: '暂无进行中的活动',
description: '近期暂无报名中的公益活动,请稍后再来',
illustration: 'activity',
actions: [
{ text: '我的活动', event: 'my', variant: 'secondary' },
],
},
} as ActivityPageData,
onLoad() {
// 顶部安全区先算:onLoad 早于首次渲染,避免出现「先压住胶囊再跳开」的闪动
this.setData(navSafeData());
if (!authStore.state.isLoggedIn) return;
(this as any)._unsub = activityStore.bindToPage(this, {
activities: 'activities',
activitiesHasMore: 'activitiesHasMore',
});
this.fetchActivities(true);
},
onShow() {
if (!authStore.state.isLoggedIn) return;
// 从活动详情返回后,若列表为空则刷新(避免详情页报名后列表不同步)
if (this.data.firstLoaded && this.data.activities.length === 0) {
this.fetchActivities(true);
}
},
onUnload() {
if ((this as any)._unsub) (this as any)._unsub();
},
/** 首次或下拉刷新拉取活动列表 */
async fetchActivities(_first = false) {
if (this.data.loading) return;
this.setData({ loading: true });
try {
await activityStore.fetchActivities();
this.applyActivityFilter();
this.setData({
loading: false,
firstLoaded: true,
hasMore: activityStore.state.activitiesHasMore,
});
} catch (err) {
this.setData({ loading: false, firstLoaded: true });
appStore.toastError(err, '加载活动失败');
}
},
/** 加载更多(onReachBottom 触发) */
async loadMore() {
if (this.data.loadingMore || !this.data.hasMore) return;
this.setData({ loadingMore: true });
try {
await activityStore.loadMoreActivities();
this.applyActivityFilter();
this.setData({
loadingMore: false,
hasMore: activityStore.state.activitiesHasMore,
});
} catch (err) {
this.setData({ loadingMore: false });
appStore.toastError(err, '加载更多失败');
}
},
onPullDownRefresh() {
this.fetchActivities(true).then(() => {
wx.stopPullDownRefresh();
});
},
onReachBottom() {
this.loadMore();
},
onCategoryTap(e: WechatMiniprogram.TouchEvent) {
this.setData({ activeCategory: e.currentTarget.dataset.code as string });
this.applyActivityFilter();
},
applyActivityFilter() {
const source = (activityStore.state.activities || []) as ActivityListItem[];
const { activeCategory } = this.data;
this.setData({
activities: activeCategory === 'all'
? source
: source.filter((item) => (item.activityType || item.category) === activeCategory),
});
},
/** 点击活动卡片 → 跳转详情 */
onActivityTap(e: any) {
const activityId = e.detail.activityId;
if (!activityId) return;
wx.navigateTo({ url: to.activityDetail(activityId) });
},
/** 空状态:去我的活动 */
onEmptyAction(e: any) {
const { event } = e.detail;
if (event === 'my') {
wx.navigateTo({ url: '/pages/my-activities/my-activities' });
}
},
/** 顶部"我的活动"入口 */
onMyActivities() {
wx.navigateTo({ url: '/pages/my-activities/my-activities' });
},
});