Files
danyang-match/miniapp/pages/index/index.ts
T
WorkBuddy f4e00b4d95 feat(miniapp): 首页 4×2 入口宫格图标改版为参考图 2 的渐变砖 + 白色图形
参考图 2 的视觉语言是「高饱和渐变圆角砖 + 白色描画图形 + 主标题/副标题双层文字」,
原实现是「浅色底砖 + 彩色线性 PNG + 单行标题」,两者差异较大,本轮按参考图重做。

- app.wxss:新增 8 个 --gradient-entry-* 渐变 token 与 --mini-entry-shadow。
  白图形的判据取**非文本图形 3:1**(WCAG 1.4.11);渐变是斜向的、图形又跨在两色之间,
  故以「较亮端」为准逐色实算(3.00~3.02:1,较暗端 3.54~4.08:1),实算值写在 token 注释里。
- index.wxss:.entry-icon 由 72rpx 浅色砖改为 96rpx 渐变砖(radius 28rpx + 落影);
  图形改用 data-uri SVG 背景层(几何图形统一 24×24 viewBox),砖面走 --gradient-entry-*;
  新增 .entry-sub(12px muted 副标题);删除已无用的 .entry-icon-image。
  小屏 @media(≤340px) 同步收紧,副标题不降到 micro —— micro 在本项目是「仅角标/装饰」档位,
  这行字承载语义,不属于装饰。
- index.wxml:宫格项去掉 <image>,图标改为纯 CSS 砖;补 aria-label 承接图形语义。
- index.ts:tone 名与色相语义对齐(rose/orange/violet/blue/purple/pink/green/amber),
  并补 subtitle;entry 不再持有 iconAsset(图标不再是图片资源)。
- preview-miniapp.mjs:首页 mock 同步 tone/subtitle,预览才能看到真实砖面与副标题。

渲染态实测(无头浏览器 + CDP,非真机渲染):
- 8 个 .entry-item 均渲染出 label + subtitle;.entry-icon 计算样式 48×48px / radius 14px /
  background-image 含 svg 且为两层(图形 26px + 渐变铺满);label 14px/600;sub 12px #7D839B。
- 证据截图:artifacts/ui-redesign/miniapp/home-entries-page.png 与 home-entries-grid-zoom.png。

门禁:UI 契约 23/23;语种审计 0 命中;tsc --noEmit 通过;wcc/wcsc 真编译 88 WXSS + 86 WXML / 0 失败。

未改(需产品确认):第 8 项业务名仍是「善缘服务站」(参考图 2 写作「万善缘地」),
副标题自拟为「就在身边」;渐变整体比参考图略深,以保证白图形对比度达标。
2026-09-23 15:22:23 +08:00

338 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')
// 4×2 宫格的视觉映射(tone → index.wxss 的 .tone-* 渐变砖;subtitle → 主标题下的说明行)。
// 图标不再走图片资源:改成「CSS 渐变砖 + 白色矢量图形」(见 index.wxss .tone-* 的 data-uri 背景层),
// 所以这里没有 iconAsset。tone 名与色相语义一一对应(app.wxss --gradient-entry-* 注释里有实算对比度):
// 3.01:1 是「白图形 vs 渐变较亮端」的实算值,7 个色相均 ≥3:1(WCAG 1.4.11 非文本图形)。
const ENTRY_VISUALS = {
find_object: { tone: 'rose', subtitle: '真实邂逅' },
matchmaker: { tone: 'orange', subtitle: '官方牵线' },
activities: { tone: 'violet', subtitle: '线下联谊' },
counseling: { tone: 'blue', subtitle: '专业指导' },
classroom: { tone: 'purple', subtitle: '提升自我' },
premarital: { tone: 'pink', subtitle: '幸福起航' },
anti_fraud: { tone: 'green', subtitle: '守护真爱' },
service_station: { tone: 'amber', subtitle: '就在身边' },
}
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')}`
},
})