问题 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 行生成产物),以免淹没代码评审视线。
193 lines
6.2 KiB
TypeScript
193 lines
6.2 KiB
TypeScript
/**
|
||
* 公益活动列表页 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' });
|
||
},
|
||
});
|