Files
danyang-match/miniapp/pages/index/index.ts
T
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

334 lines
14 KiB
TypeScript
Raw 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.
/**
* 首页 - V1.1 新版(U18)
* 结构:品牌区 → 用户状态条 → Banner → 8入口 → 推荐 → 服务建议 → 活动 → 服务站 → 安全提醒
*/
const { authStore, appStore, matchStore } = require('../../stores')
const api = require('../../utils/api')
// 顶部安全区:实测右上角胶囊按钮矩形,避免品牌栏「消息」入口被胶囊压住(见 utils/nav-metrics.js)
const { navSafeData } = require('../../utils/nav-metrics')
const ENTRY_VISUALS = {
find_object: { tone: 'rose', iconAsset: '/assets/icons/home/find-object.png' },
matchmaker: { tone: 'purple', iconAsset: '/assets/icons/home/matchmaker.png' },
activities: { tone: 'blue', iconAsset: '/assets/icons/home/activity.png' },
counseling: { tone: 'pink', iconAsset: '/assets/icons/home/counseling.png' },
classroom: { tone: 'amber', iconAsset: '/assets/icons/home/classroom.png' },
premarital: { tone: 'indigo', iconAsset: '/assets/icons/home/premarital.png' },
anti_fraud: { tone: 'red', iconAsset: '/assets/icons/home/anti-fraud.png' },
service_station: { tone: 'green', iconAsset: '/assets/icons/home/service-station.png' },
}
const QUICK_ENTRIES = [
{ code: 'find_object', label: '找对象', targetPage: '/pages/discover/discover', enabled: true },
{ code: 'matchmaker', label: '公益红娘', targetPage: '/pages/matchmaker/matchmaker', enabled: true },
{ code: 'activities', label: '公益活动', targetPage: '/pages/activity/activity', enabled: true },
{ code: 'counseling', label: '婚恋咨询', targetPage: '/pages/counseling-intro/counseling-intro', enabled: true },
{ code: 'classroom', label: '婚恋课堂', targetPage: '/pages/content/content', enabled: true },
{ code: 'premarital', label: '婚前辅导', targetPage: '/pages/premarital-intro/premarital-intro', enabled: true },
{ code: 'anti_fraud', label: '婚恋反诈', targetPage: '/pages/anti-fraud/anti-fraud', enabled: true },
{ code: 'service_station', label: '善缘服务站', targetPage: '/pages/service-station/service-station', enabled: true },
].map((entry) => ({ ...entry, ...ENTRY_VISUALS[entry.code] }))
Page({
data: {
navVars: '',
unreadCount: 0,
loading: true,
loadError: false,
userState: null,
isOnboarding: false,
isPaused: false,
isSuccessful: false,
onboardingMessage: '',
onboardingActions: [],
statusChecks: [],
banners: [],
currentBannerIndex: 0,
quickEntries: QUICK_ENTRIES,
recommendations: [],
recState: 'idle',
actionLoadingUserId: '',
serviceRecommendations: [],
activities: [],
serviceLocation: null,
articles: [],
safetyNotice: {
text: '实名认证和人工审核可以提高信息真实性,交往过程中仍请谨慎涉及借款、投资和个人隐私。',
ctaText: '查看婚恋反诈指南',
ctaTarget: ''
},
emptyActions: [
{ text: '查看择偶条件', event: 'preference', variant: 'secondary' },
{ text: '申请公益红娘', event: 'matchmaker', variant: 'primary' },
],
},
onLoad() {
// 顶部安全区先算:onLoad 早于首次渲染,避免出现「先压住胶囊再跳开」的闪动
this.setData(navSafeData())
// 订阅登录态:会话失效时回到登录引导;刷新成功且首页停在错误态时自动重试。
this._unsubAuth = authStore.subscribe((state) => {
if (state.authStatus === 'expired') {
this.setData({ loading: false, loadError: false, recState: 'idle' })
this.checkAuthAndLoad()
return
}
if (state.isLoggedIn && this.data.loadError && !this.data.loading) {
this.loadHomeData()
}
})
this.checkAuthAndLoad()
},
onShow() {
if (authStore.state.isLoggedIn && !this.data.loading) {
this.loadHomeData()
}
},
onUnload() {
if (typeof this._unsubAuth === 'function') {
this._unsubAuth()
this._unsubAuth = null
}
},
/** 错误态「重试」按钮:重新鉴权判断并加载首页 */
onRetry() {
this.checkAuthAndLoad()
},
onPullDownRefresh() {
this.loadHomeData().finally(() => wx.stopPullDownRefresh())
},
// ==================== 核心加载 ====================
async checkAuthAndLoad() {
if (!authStore.state.isLoggedIn) {
this.setData({
loading: false,
isOnboarding: true,
onboardingMessage: '登录后即可开始寻找合适的人',
onboardingActions: [{ text: '去登录', event: 'login', variant: 'primary' }],
})
return
}
await this.loadHomeData()
},
async loadHomeData() {
this.setData({ loading: true, loadError: false })
try {
const res = await api.get('/home')
this.processHomeData(res)
} catch (err) {
console.error('首页数据加载失败', err)
this.setData({ loading: false, loadError: true, recState: 'error' })
}
},
processHomeData(data) {
const { userState, banners, quickEntries, recommendations,
serviceRecommendations, activities, serviceLocation, safetyNotice } = data
const relStatus = userState?.relationshipStatus || ''
const profileStatus = userState?.profileStatus || ''
const eligStatus = userState?.localEligibilityStatus || 'not_declared'
const isOnboarding = !userState || !userState.verified || (profileStatus !== 'approved' && profileStatus !== 'completed')
const isPaused = relStatus === 'paused'
const isSuccessful = relStatus === 'matched_successfully'
// 注意:本地资格核验通过与否由 checks 里的「本地资格」项表达(eligStatus === 'verified')。
// 此前曾有一个 isVerified = eligStatus === 'verified' 的死状态 —— 名字像「实名」实为本地资格,
// WXML/WXSS 零引用且语义错位(实名判定是 userState.verified),已移除以防未来误用。
const checks = [
{ label: '已实名', done: userState?.verified || false },
{ label: '本地资格', done: eligStatus === 'verified' },
{ label: '资料已审核', done: profileStatus === 'approved' || profileStatus === 'completed' },
]
this.setData({
loading: false, loadError: false,
userState,
isOnboarding, isPaused, isSuccessful,
onboardingMessage: this.buildOnboardingMessage(userState, profileStatus, eligStatus),
onboardingActions: this.buildOnboardingActions(userState, profileStatus, eligStatus),
statusChecks: checks,
banners: banners || [],
quickEntries: quickEntries?.length
? QUICK_ENTRIES.map((defaultEntry) => {
const serverEntry = quickEntries.find((entry) => entry.code === defaultEntry.code)
return serverEntry
? { ...defaultEntry, ...serverEntry, ...ENTRY_VISUALS[defaultEntry.code] }
: { ...defaultEntry, enabled: false, disabledReason: '服务暂未开放' }
})
: QUICK_ENTRIES,
recommendations: recommendations || [],
recState: (recommendations && recommendations.length > 0) ? 'loaded' : 'empty',
serviceRecommendations: serviceRecommendations || [], activities: activities || [], serviceLocation,
articles: data.articles || [],
safetyNotice: safetyNotice || this.data.safetyNotice,
})
},
buildOnboardingMessage(state, profileStatus, eligStatus) {
if (!state) return '登录后即可开始寻找合适的人'
if (!state.verified) return '完成实名认证后,即可继续完善资料并使用公益婚恋服务'
if (profileStatus === 'pending_review') return '资料正在人工审核,请耐心等待审核结果'
if (eligStatus === 'not_declared') return '完成本地资格声明后即可开始寻找合适的人'
if (eligStatus === 'needs_evidence') return '补充本地资格佐证后即可开始寻找合适的人'
if (profileStatus !== 'approved' && profileStatus !== 'completed') return '资料审核通过后即可开始寻找合适的人'
return '完成资料后开始寻找合适的人'
},
buildOnboardingActions(state, profileStatus, eligStatus) {
if (!state) return [{ text: '去登录', event: 'login', variant: 'primary' }]
const actions = []
if (eligStatus === 'not_declared' || eligStatus === 'needs_evidence') {
actions.push({ text: '声明本地资格', event: 'local-eligibility', variant: 'primary' })
}
if (profileStatus !== 'approved' && profileStatus !== 'completed') {
actions.push({ text: '完善资料', event: 'profile', variant: 'secondary' })
}
if (actions.length === 0) {
actions.push({ text: '开始寻找', event: 'discover', variant: 'primary' })
}
return actions
},
// ==================== 事件处理 ====================
onBannerChange(e) {
this.setData({ currentBannerIndex: e.detail.current })
},
onBannerTap(e) {
const { targetType, targetValue } = e.currentTarget.dataset.item
if (targetType === 'activity' && targetValue) {
wx.navigateTo({ url: `/pages/activity-detail/activity-detail?id=${targetValue}` })
} else if (targetType === 'matchmaker') {
wx.navigateTo({ url: '/pages/matchmaker/matchmaker' })
} else {
const routes = { matchmaker: '/pages/matchmaker/matchmaker', counseling: '/pages/counseling-intro/counseling-intro', premarital: '/pages/premarital-intro/premarital-intro', service_location: '/pages/service-station/service-station', article: '/pages/content-detail/content-detail', internal_page: targetValue }
const url = routes[targetType]
if (url && /^\/pages\//.test(url)) wx.navigateTo({ url })
}
},
onEntryTap(e) {
const { code, enabled, targetPage } = e.currentTarget.dataset.entry
if (!enabled) {
wx.showToast({ title: e.currentTarget.dataset.entry.disabledReason || '暂不可使用此功能', icon: 'none' })
return
}
if (targetPage === '/pages/discover/discover' || targetPage === '/pages/activity/activity') wx.switchTab({ url: targetPage })
else if (targetPage?.startsWith('/pages/')) wx.navigateTo({ url: targetPage })
},
onViewDetail(e) {
const userId = e.detail?.userId || e.currentTarget.dataset?.userId
if (userId) wx.navigateTo({ url: `/pages/profile-detail/profile-detail?id=${userId}` })
},
async onLike(e) {
const userId = e.detail?.userId
if (!userId || this.data.actionLoadingUserId) return
this.setData({ actionLoadingUserId: userId })
try {
const result = await matchStore.like(userId)
this.setData({ recommendations: this.data.recommendations.filter((item) => item.userId !== userId) })
if (result?.mutualMatch) {
appStore.toastSuccess('双向喜欢!可以开始聊天了')
if (result.matchedConversationId) {
wx.navigateTo({ url: `/pages/chat/chat?id=${result.matchedConversationId}` })
}
} else {
appStore.toastSuccess('已表达喜欢')
}
} catch (err) {
appStore.toastError(err instanceof Error ? err.message : '操作失败')
} finally {
this.setData({ actionLoadingUserId: '' })
}
},
async onPass(e) {
const userId = e.detail?.userId
if (!userId || this.data.actionLoadingUserId) return
this.setData({ actionLoadingUserId: userId })
try {
await matchStore.pass(userId)
this.setData({ recommendations: this.data.recommendations.filter((item) => item.userId !== userId) })
appStore.toastSuccess('已略过')
} catch (err) {
appStore.toastError(err instanceof Error ? err.message : '操作失败')
} finally {
this.setData({ actionLoadingUserId: '' })
}
},
onEmptyAction(e) {
const event = e.currentTarget.dataset.event
switch (event) {
case 'login':
wx.reLaunch({ url: '/pages/login/login' })
break
case 'discover':
wx.switchTab({ url: '/pages/discover/discover' })
break
case 'matchmaker':
wx.navigateTo({ url: '/pages/matchmaker/matchmaker' })
break
case 'preference':
wx.navigateTo({ url: '/pages/profile-preference/profile-preference' })
break
case 'local-eligibility':
wx.navigateTo({ url: '/pages/local-eligibility/local-eligibility' })
break
case 'profile':
wx.navigateTo({ url: '/pages/profile-basic/profile-basic' })
break
}
},
onResume() {
wx.navigateTo({ url: '/pages/relationship-status/relationship-status' })
},
onSeeMore() { wx.switchTab({ url: '/pages/discover/discover' }) },
onSeeAllActivities() { wx.switchTab({ url: '/pages/activity/activity' }) },
onActivityTap(e) {
const id = e.currentTarget.dataset.id
if (id) wx.navigateTo({ url: `/pages/activity-detail/activity-detail?id=${id}` })
},
onStationTap() {
wx.navigateTo({ url: '/pages/service-station/service-station' })
},
onServiceTap(e) {
const type = e.currentTarget.dataset.type
switch (type) {
case 'matchmaker': wx.navigateTo({ url: '/pages/matchmaker/matchmaker' })
break
case 'counseling':
wx.navigateTo({ url: '/pages/counseling-intro/counseling-intro' })
break
case 'premarital':
wx.navigateTo({ url: '/pages/premarital-intro/premarital-intro' })
break
}
},
onSafetyTap() {
wx.navigateTo({ url: '/pages/anti-fraud/anti-fraud' })
},
onArticleTap(e) { wx.navigateTo({ url: `/pages/content-detail/content-detail?id=${e.currentTarget.dataset.id}` }) },
onMessages() { wx.switchTab({ url: '/pages/messages/messages' }) },
formatDate(timestamp) {
if (!timestamp) return ''
const d = new Date(timestamp)
return `${d.getMonth() + 1}月${d.getDate()}日 ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
},
})