chore: establish V1.1 recovery baseline

This commit is contained in:
2026-09-09 22:05:00 +08:00
commit b46b48eb33
947 changed files with 155471 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
/**
* 关于我们页 U42
*
* 展示平台信息、版本号、法律链接。
*/
Page({
data: {
platformName: getApp().globalData.platformName,
slogan: getApp().globalData.slogan,
version: 'v1.0.0-RC.1',
buildDate: '2026-09-01',
},
onLoad() {
wx.setNavigationBarTitle({ title: '关于我们' });
},
onViewPrivacy() {
wx.navigateTo({ url: '/pages/privacy-settings/privacy-settings' });
},
onCallService() {
wx.makePhoneCall({ phoneNumber: '0511-80000000' });
},
});
+1
View File
@@ -0,0 +1 @@
{"navigationBarTitleText":"about","usingComponents":{}}
+45
View File
@@ -0,0 +1,45 @@
<!-- 关于我们页 -->
<view class="page">
<view class="logo-section">
<view class="logo-icon">💕</view>
<view class="platform-name">{{platformName}}</view>
<view class="slogan">{{slogan}}</view>
<view class="version">版本 {{version}} · 构建于 {{buildDate}}</view>
</view>
<view class="section">
<view class="section-title">平台简介</view>
<view class="intro-text">
丹阳公益婚恋服务平台由丹阳市文明办、团委、妇联等多部门联合推动,面向丹阳市成年单身人士提供实名制、公益性的婚恋交友服务。
</view>
<view class="intro-text">
平台采用实名认证 + 人工审核机制,确保用户真实身份;所有服务免费,不收取任何费用。
</view>
</view>
<view class="section">
<view class="section-title">联系我们</view>
<view class="list-group">
<view class="list-row" bindtap="onCallService">
<view class="list-icon">📞</view>
<view class="list-info">
<text class="list-label">客服电话</text>
<text class="list-desc">0511-80000000(工作日 9:00-17:00)</text>
</view>
<text class="list-arrow">›</text>
</view>
<view class="list-row" bindtap="onViewPrivacy">
<view class="list-icon">🔒</view>
<view class="list-info">
<text class="list-label">隐私政策</text>
<text class="list-desc">了解我们如何保护您的个人信息</text>
</view>
<text class="list-arrow">›</text>
</view>
</view>
</view>
<view class="footer">
<text>© 2026 丹阳市文明办 · 共青团丹阳市委 · 丹阳市妇联</text>
</view>
</view>
+112
View File
@@ -0,0 +1,112 @@
/* 关于我们样式 */
.page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 48rpx;
}
.logo-section {
background: linear-gradient(135deg, #E96B6B 0%, #ff9a9a 100%);
padding: 80rpx 48rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.logo-icon {
font-size: 100rpx;
margin-bottom: 24rpx;
}
.platform-name {
font-size: 36rpx;
font-weight: 700;
color: #fff;
margin-bottom: 8rpx;
}
.slogan {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.85);
margin-bottom: 32rpx;
}
.version {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.7);
}
.section {
margin-top: 24rpx;
background: #fff;
}
.section-title {
padding: 24rpx 32rpx 16rpx;
font-size: 24rpx;
color: #999;
}
.intro-text {
padding: 0 32rpx 20rpx;
font-size: 28rpx;
color: #555;
line-height: 1.8;
}
.list-group {
padding: 0 32rpx;
}
.list-row {
display: flex;
align-items: center;
padding: 28rpx 0;
border-bottom: 1rpx solid #f5f5f5;
}
.list-row:last-child {
border-bottom: none;
}
.list-icon {
font-size: 36rpx;
margin-right: 24rpx;
width: 48rpx;
text-align: center;
}
.list-info {
flex: 1;
min-width: 0;
}
.list-label {
display: block;
font-size: 32rpx;
color: #2d2d2d;
line-height: 1.4;
}
.list-desc {
display: block;
font-size: 24rpx;
color: #999;
line-height: 1.4;
margin-top: 4rpx;
}
.list-arrow {
font-size: 32rpx;
color: #ccc;
margin-left: 16rpx;
}
.footer {
margin-top: 48rpx;
text-align: center;
font-size: 22rpx;
color: #ccc;
padding: 0 32rpx;
line-height: 1.8;
}
@@ -0,0 +1,61 @@
/**
* 注销账号页
* 在线便捷渠道,不设置障碍
*/
const api = require('../../utils/api');
Page({
data: {
canWithdraw: false,
blockers: [],
reason: '',
submitting: false,
submitted: false
},
onLoad() {
this.checkCanWithdraw();
},
onReasonInput(e) {
this.setData({ reason: e.detail.value });
},
async checkCanWithdraw() {
try {
const token = wx.getStorageSync('accessToken');
const res = await api.get('/account/can-withdraw', {}, { Authorization: `Bearer ${token}` });
this.setData({
canWithdraw: res.canWithdraw,
blockers: Object.values(res.blockers || {})
});
} catch (err) {
console.error(err);
}
},
async submitWithdraw() {
if (!this.data.canWithdraw) return;
this.setData({ submitting: true });
try {
const token = wx.getStorageSync('accessToken');
await api.post('/account/close', { reason: this.data.reason.trim() }, {
Authorization: `Bearer ${token}`
});
this.setData({ submitted: true });
wx.setStorageSync('accessToken', '');
wx.setStorageSync('userId', '');
} catch (err) {
wx.showToast({ title: err.message || '注销失败', icon: 'none' });
this.setData({ submitting: false });
}
},
goBack() {
if (this.data.submitted) {
wx.reLaunch({ url: '/pages/login/login' });
} else {
wx.navigateBack();
}
}
});
@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "注销账号",
"usingComponents": {}
}
@@ -0,0 +1,49 @@
<!-- 注销账号页 -->
<view class="page" wx:if="{{!submitted}}">
<view class="warning-banner">
<text class="warning-icon">⚠</text>
<text class="warning-title">注销账号</text>
</view>
<view class="info-section">
<text class="section-title">注销后将:</text>
<view class="info-list">
<view class="info-item">• 停止所有推荐</view>
<view class="info-item">• 资料停止向他人展示</view>
<view class="info-item">• 聊天停止</view>
<view class="info-item">• 红娘服务结束</view>
<view class="info-item">• 未开始的活动报名将取消</view>
<view class="info-item">• 必要信息脱敏保留(符合法律要求)</view>
<view class="info-item">• 所有数据不可恢复</view>
</view>
</view>
<view wx:if="{{!canWithdraw}}" class="blocker-section">
<text class="blocker-title">无法注销:存在进行中的业务</text>
<view class="blocker-item" wx:for="{{blockers}}" wx:key="index">{{item}}</view>
</view>
<view class="section">
<text class="section-title">注销原因(选填)</text>
<view class="reason-options">
<view class="reason-chip {{reason==='太少了'?'active':''}}" data-val="太少了" bindtap="setReason">太少了</view>
<view class="reason-chip {{reason==='找不到合适的'?'active':''}}" data-val="找不到合适的" bindtap="setReason">找不到合适的</view>
<view class="reason-chip {{reason==='暂时不需要'?'active':''}}" data-val="暂时不需要" bindtap="setReason">暂时不需要</view>
<view class="reason-chip {{reason==='其他原因'?'active':''}}" data-val="其他原因" bindtap="setReason">其他原因</view>
</view>
<textarea class="other-reason" placeholder="或其他原因(选填)..." value="{{reason}}" bindinput="onReasonInput" maxlength="200" />
</view>
<button class="btn-confirm" bindtap="submitWithdraw" loading="{{submitting}}" disabled="{{!canWithdraw}}">
确认注销账号
</button>
<text class="hint">平台不会要求您打电话或邮寄身份证,全程在线完成</text>
</view>
<!-- 注销成功 -->
<view class="page submitted" wx:if="{{submitted}}">
<view class="success-icon">✓</view>
<text class="success-title">账号已注销</text>
<text class="success-hint">感谢您的使用,期待您未来再次相遇。</text>
<button class="btn-back" bindtap="goBack">返回登录页</button>
</view>
@@ -0,0 +1,141 @@
.page {
padding: 32rpx;
background: #FFF8F7;
min-height: 100vh;
}
.warning-banner {
background: #FFF3F2;
border-radius: 20rpx;
padding: 32rpx;
text-align: center;
margin-bottom: 32rpx;
}
.warning-icon { font-size: 56rpx; display: block; }
.warning-title { font-size: 32rpx; font-weight: bold; color: #E96B6B; display: block; margin-top: 12rpx; }
.info-section {
background: #fff;
border-radius: 20rpx;
padding: 28rpx;
margin-bottom: 24rpx;
}
.section-title {
font-size: 28rpx;
font-weight: bold;
color: #1A1A1A;
display: block;
margin-bottom: 20rpx;
}
.info-list {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.info-item {
font-size: 26rpx;
color: #666;
line-height: 1.6;
}
.blocker-section {
background: #FFF8E1;
border-radius: 20rpx;
padding: 28rpx;
margin-bottom: 24rpx;
}
.blocker-title {
font-size: 26rpx;
font-weight: bold;
color: #E59A32;
display: block;
margin-bottom: 12rpx;
}
.blocker-item {
font-size: 24rpx;
color: #999;
display: block;
margin-bottom: 8rpx;
}
.reason-options {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin-bottom: 16rpx;
}
.reason-chip {
padding: 12rpx 24rpx;
background: #F5F5F5;
border-radius: 32rpx;
font-size: 24rpx;
color: #666;
border: 2rpx solid transparent;
}
.reason-chip.active {
background: #FFF3F2;
border-color: #E96B6B;
color: #E96B6B;
}
.other-reason {
width: 100%;
min-height: 120rpx;
font-size: 26rpx;
background: #F9F9F9;
border-radius: 16rpx;
padding: 20rpx;
box-sizing: border-box;
}
.btn-confirm {
width: 100%;
height: 88rpx;
line-height: 88rpx;
background: #E96B6B;
color: #fff;
border-radius: 44rpx;
font-size: 30rpx;
margin-top: 32rpx;
}
.btn-confirm[disabled] { background: #CCC; }
.hint {
display: block;
text-align: center;
font-size: 22rpx;
color: #999;
margin-top: 16rpx;
line-height: 1.5;
}
/* 成功状态 */
.submitted {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 32rpx;
}
.success-icon { font-size: 80rpx; color: #E96B6B; margin-bottom: 24rpx; }
.success-title { font-size: 36rpx; font-weight: bold; color: #1A1A1A; margin-bottom: 16rpx; }
.success-hint { font-size: 28rpx; color: #666; text-align: center; line-height: 1.6; margin-bottom: 48rpx; }
.btn-back {
width: 60%;
height: 88rpx;
line-height: 88rpx;
background: #E96B6B;
color: #fff;
border-radius: 44rpx;
font-size: 30rpx;
}
@@ -0,0 +1,86 @@
/**
* 我的婚恋状态页 U41
* 展示当前状态,提供退出服务/正在了解/成功牵手入口
*/
const api = require('../../utils/api');
Page({
data: {
relationshipStatus: 'searching', // searching, paused, getting_to_know, matched_successfully
recommendationEnabled: true,
matchCount: 0,
loading: false
},
onLoad() {
this.loadStatus();
},
onShow() {
this.loadStatus();
},
async loadStatus() {
try {
const token = wx.getStorageSync('accessToken');
const res = await api.get('/account/status', {}, { Authorization: `Bearer ${token}` });
this.setData({
relationshipStatus: res.relationshipStatus || 'searching',
recommendationEnabled: res.recommendationEnabled !== false,
matchCount: res.activeMatchCount || 0
});
} catch (err) {
console.error(err);
}
},
async withdrawService() {
wx.showModal({
title: '退出婚恋服务',
content: '退出后您的资料将不再展示,系统不再为您推荐对象,但账号保留。是否继续?',
success: async (ret) => {
if (ret.confirm) {
try {
const token = wx.getStorageSync('accessToken');
await api.post('/account/withdraw', {}, { Authorization: `Bearer ${token}` });
this.setData({ recommendationEnabled: false, relationshipStatus: 'paused' });
wx.showToast({ title: '已退出婚恋服务', icon: 'success' });
} catch (err) {
wx.showToast({ title: err.message || '操作失败', icon: 'none' });
}
}
}
});
},
async resumeService() {
try {
const token = wx.getStorageSync('accessToken');
await api.post('/account/resume', {}, { Authorization: `Bearer ${token}` });
this.setData({ recommendationEnabled: true, relationshipStatus: 'searching' });
wx.showToast({ title: '已重新开启', icon: 'success' });
} catch (err) {
wx.showToast({ title: err.message || '操作失败', icon: 'none' });
}
},
goSuccessConfirm() {
wx.navigateTo({ url: '/pages/success-confirm/success-confirm' });
},
goPrivacy() {
wx.navigateTo({ url: '/pages/privacy-settings/privacy-settings' });
},
goLogout() {
wx.showModal({
title: '注销账号',
content: '注销后所有数据将被删除且不可恢复,是否继续?',
success: (ret) => {
if (ret.confirm) {
wx.navigateTo({ url: '/pages/account-logout/account-logout' });
}
}
});
}
});
@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "我的婚恋状态",
"usingComponents": {}
}
@@ -0,0 +1,35 @@
<!-- 我的婚恋状态页 U41 -->
<view class="page">
<view class="status-card" wx:if="{{recommendationEnabled}}">
<view class="status-icon">💚</view>
<text class="status-title">正在寻找</text>
<text class="status-hint">您的资料正在推荐给潜在对象,共 {{matchCount}} 个活跃匹配</text>
<button class="btn-withdraw" bindtap="withdrawService">退出婚恋服务</button>
</view>
<view class="status-card paused" wx:if="{{!recommendationEnabled}}">
<view class="status-icon">⏸</view>
<text class="status-title">婚恋服务已暂停</text>
<text class="status-hint">您的资料已隐藏,系统不再为您推荐对象。您可随时重新开启。</text>
<button class="btn-resume" bindtap="resumeService">重新开启婚恋服务</button>
</view>
<view class="menu-list">
<view class="menu-item" bindtap="goSuccessConfirm">
<text class="menu-icon">💍</text>
<text class="menu-text">成功牵手确认</text>
<text class="menu-arrow">›</text>
</view>
<view class="menu-item" bindtap="goPrivacy">
<text class="menu-icon">🔒</text>
<text class="menu-text">隐私设置</text>
<text class="menu-arrow">›</text>
</view>
<view class="menu-item danger" bindtap="goLogout">
<text class="menu-icon">🗑</text>
<text class="menu-text">注销账号</text>
<text class="menu-hint">注销后数据不可恢复</text>
<text class="menu-arrow">›</text>
</view>
</view>
</view>
@@ -0,0 +1,59 @@
.page { padding: 32rpx; background: #FFF8F7; min-height: 100vh; }
.status-card {
background: #fff;
border-radius: 24rpx;
padding: 48rpx 32rpx;
text-align: center;
margin-bottom: 32rpx;
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04);
}
.status-card.paused {
background: #F9F9F9;
}
.status-icon { font-size: 72rpx; display: block; margin-bottom: 16rpx; }
.status-title { font-size: 32rpx; font-weight: bold; color: #1A1A1A; display: block; }
.status-hint { font-size: 24rpx; color: #999; display: block; margin-top: 12rpx; line-height: 1.5; }
.btn-withdraw, .btn-resume {
width: 80%;
height: 72rpx;
line-height: 72rpx;
border-radius: 36rpx;
font-size: 28rpx;
margin-top: 32rpx;
border: none;
}
.btn-withdraw {
background: #FFF3F2;
color: #E96B6B;
}
.btn-resume {
background: #E96B6B;
color: #fff;
}
.menu-list {
background: #fff;
border-radius: 24rpx;
overflow: hidden;
}
.menu-item {
display: flex;
align-items: center;
padding: 32rpx 28rpx;
border-bottom: 1rpx solid #f5f5f5;
}
.menu-item:last-child { border-bottom: none; }
.menu-item.danger .menu-text { color: #E96B6B; }
.menu-icon { font-size: 36rpx; margin-right: 20rpx; }
.menu-text { flex: 1; font-size: 28rpx; color: #1A1A1A; }
.menu-hint { font-size: 22rpx; color: #999; margin-right: 12rpx; }
.menu-arrow { font-size: 32rpx; color: #CCC; }
@@ -0,0 +1,71 @@
/**
* 账号受限页 U48
* 展示当前限制状态、原因,以及申诉入口
*/
const api = require('../../utils/api');
Page({
data: {
hasRestriction: false,
accountStatus: 'normal',
actions: [],
canAppeal: false,
appealEnabled: false,
loading: true
},
onLoad() {
this.loadStatus();
},
onShow() {
this.loadStatus();
},
async loadStatus() {
this.setData({ loading: true });
try {
const token = wx.getStorageSync('accessToken');
const res = await api.get('/appeals/account-status', {}, {
Authorization: `Bearer ${token}`
});
this.setData({
hasRestriction: res.hasRestriction,
accountStatus: res.accountStatus,
actions: res.actions || [],
canAppeal: res.canAppeal,
appealEnabled: res.appealEnabled,
loading: false
});
} catch (err) {
console.error(err);
this.setData({ loading: false });
}
},
goAppeal(e) {
const action = e.currentTarget.dataset.action;
wx.navigateTo({
url: `/pages/appeal/appeal?type=${action.appealType}&actionId=${action.id}`
});
},
goHelp() {
wx.navigateTo({ url: '/pages/help-center/help-center' });
},
statusText(s) {
const map = { banned: '已封禁', suspended: '临时暂停', limited: '功能限制' };
return map[s] || s;
},
actionTypeText(t) {
const map = { ban: '永久封禁', feature_limit: '功能限制', temporary_suspend: '临时暂停', warning: '警告' };
return map[t] || t;
},
formatTime(t) {
if (!t) return '-';
return new Date(t).toLocaleDateString('zh-CN');
}
});
@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "账号状态",
"usingComponents": {}
}
@@ -0,0 +1,47 @@
<!-- 账号受限页 U48 -->
<view class="page" wx:if="{{!loading}}">
<view class="status-banner" wx:if="{{hasRestriction}}">
<view class="banner-icon">⚠</view>
<text class="banner-title">账号受限</text>
<text class="banner-status">{{statusText(accountStatus)}}</text>
</view>
<view wx:if="{{!hasRestriction}}" class="normal-state">
<view class="normal-icon">✓</view>
<text class="normal-title">账号状态正常</text>
<text class="normal-hint">您的账号目前无限制,可以正常使用所有功能。</text>
</view>
<view class="actions-list" wx:if="{{hasRestriction && actions.length > 0}}">
<view class="action-card" wx:for="{{actions}}" wx:key="id">
<view class="action-header">
<text class="action-type">{{actionTypeText(item.actionType)}}</text>
<text class="action-time">{{formatTime(item.effectiveAt)}}</text>
</view>
<text class="action-reason">{{item.reason}}</text>
<text class="action-expiry" wx:if="{{item.expiresAt}}">
有效期至:{{formatTime(item.expiresAt)}}
</text>
<text class="action-expiry" wx:else>永久限制</text>
<button
wx:if="{{item.canAppeal && canAppeal}}"
class="btn-appeal"
data-action="{{item}}"
bindtap="goAppeal">
提交申诉
</button>
<text wx:if="{{item.canAppeal && !canAppeal && appealEnabled}}" class="appeal-hint">
该类型限制暂不支持在线申诉,请联系客服。
</text>
</view>
</view>
<view class="appeal-disabled" wx:if="{{hasRestriction && !appealEnabled}}">
<text class="disabled-hint">当前申诉功能暂未开放,如有疑问请联系客服。</text>
<button class="btn-contact" bindtap="goHelp">联系客服</button>
</view>
</view>
<view class="page loading-page" wx:if="{{loading}}">
<text>加载中...</text>
</view>
@@ -0,0 +1,156 @@
.page {
padding: 32rpx;
background: #FFF8F7;
min-height: 100vh;
}
.status-banner {
background: linear-gradient(135deg, #E96B6B 0%, #C44545 100%);
border-radius: 24rpx;
padding: 48rpx 32rpx;
text-align: center;
margin-bottom: 32rpx;
}
.banner-icon {
font-size: 72rpx;
display: block;
margin-bottom: 16rpx;
}
.banner-title {
font-size: 36rpx;
font-weight: bold;
color: #fff;
display: block;
}
.banner-status {
font-size: 26rpx;
color: rgba(255,255,255,0.85);
display: block;
margin-top: 12rpx;
}
.normal-state {
text-align: center;
padding: 80rpx 32rpx;
}
.normal-icon {
font-size: 80rpx;
color: #67C23A;
display: block;
margin-bottom: 24rpx;
}
.normal-title {
font-size: 32rpx;
font-weight: bold;
color: #1A1A1A;
display: block;
margin-bottom: 16rpx;
}
.normal-hint {
font-size: 26rpx;
color: #666;
display: block;
line-height: 1.6;
}
.actions-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.action-card {
background: #fff;
border-radius: 20rpx;
padding: 28rpx;
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04);
}
.action-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
}
.action-type {
font-size: 28rpx;
font-weight: bold;
color: #E96B6B;
}
.action-time {
font-size: 22rpx;
color: #BBB;
}
.action-reason {
font-size: 26rpx;
color: #666;
display: block;
margin-bottom: 12rpx;
line-height: 1.5;
}
.action-expiry {
font-size: 24rpx;
color: #E59A32;
display: block;
margin-bottom: 20rpx;
}
.btn-appeal {
width: 100%;
height: 72rpx;
line-height: 72rpx;
background: #FFF3F2;
color: #E96B6B;
border-radius: 36rpx;
font-size: 28rpx;
border: 1rpx solid #E96B6B;
}
.appeal-hint {
display: block;
font-size: 24rpx;
color: #999;
text-align: center;
margin-top: 16rpx;
}
.appeal-disabled {
text-align: center;
padding: 60rpx 32rpx;
background: #fff;
border-radius: 20rpx;
}
.disabled-hint {
font-size: 26rpx;
color: #999;
display: block;
margin-bottom: 24rpx;
}
.btn-contact {
width: 60%;
height: 72rpx;
line-height: 72rpx;
background: #E96B6B;
color: #fff;
border-radius: 36rpx;
font-size: 28rpx;
border: none;
}
.loading-page {
text-align: center;
padding: 100rpx;
color: #999;
}
@@ -0,0 +1,8 @@
/**
* 页面占位骨架
*/
Page({
data: {},
onLoad(options) {},
onShow() {}
});
@@ -0,0 +1 @@
{"navigationBarTitleText":"activity-detail","usingComponents":{}}
@@ -0,0 +1 @@
<view class="page"><text>页面: activity-detail</text></view>
@@ -0,0 +1 @@
.page{padding:32rpx;background:#FFF8F7;min-height:100vh;text-align:center;color:#999;padding-top:200rpx;}
@@ -0,0 +1,115 @@
/**
* 活动反馈页 U32
* 活动结束后开放,用户评价 + 表达进一步了解意向
*/
const api = require('../../utils/api');
Page({
data: {
activityId: '',
activityTitle: '',
activityEndAt: '',
overallExperience: '', // very_satisfied / satisfied / normal / dissatisfied
willingNextTime: '', // willing / maybe / not_now
metSomeoneInteresting: null, // true / false / null
otherParticipants: [],
selectedInterests: [],
submitting: false,
feedbackSubmitted: false
},
onLoad(options) {
if (options.id) {
this.setData({ activityId: options.id });
this.loadData();
}
},
async loadData() {
try {
const token = wx.getStorageSync('accessToken');
// 加载活动信息
const activityRes = await api.get(`/activities/${this.data.activityId}`, {}, {
Authorization: `Bearer ${token}`
});
this.setData({
activityTitle: activityRes.title,
activityEndAt: activityRes.endAt
});
// 加载同活动已签到用户列表(仅用于选择意向对象)
const partsRes = await api.get(`/activities/${this.data.activityId}/participants`, {}, {
Authorization: `Bearer ${token}`
});
this.setData({
otherParticipants: (partsRes || []).filter(p => p.userId !== wx.getStorageSync('userId'))
});
} catch (err) {
console.error('加载失败', err);
}
},
onOverallChange(e) {
this.setData({ overallExperience: e.currentTarget.dataset.value });
},
onWillingChange(e) {
this.setData({ willingNextTime: e.currentTarget.dataset.value });
},
onMetSomeoneChange(e) {
this.setData({ metSomeoneInteresting: e.currentTarget.dataset.value === 'yes' ? true : false });
},
toggleInterest(userId) {
const selected = this.data.selectedInterests;
const idx = selected.indexOf(userId);
if (idx >= 0) {
selected.splice(idx, 1);
} else {
selected.push(userId);
}
this.setData({ selectedInterests: selected });
},
async submitFeedback() {
const { overallExperience, willingNextTime, metSomeoneInteresting, selectedInterests, activityId } = this.data;
if (!overallExperience || !willingNextTime) {
wx.showToast({ title: '请填写必填项', icon: 'none' });
return;
}
this.setData({ submitting: true });
try {
const token = wx.getStorageSync('accessToken');
const body = {
overallExperience,
willingNextTime,
metSomeoneInteresting,
interestTargetUserIds: selectedInterests
};
const res = await api.post(`/activities/${activityId}/feedback`, body, {
Authorization: `Bearer ${token}`
});
this.setData({ feedbackSubmitted: true, submitting: false });
if ((res.newMutuals || []).length > 0) {
wx.showModal({
title: '双向匹配成功!',
content: `您与${res.newMutuals.length}位朋友互相表达了进一步了解意愿,快去缘分中心看看吧`,
showCancel: false,
success: () => {
wx.switchTab({ url: '/pages/fate-center/fate-center' });
}
});
}
} catch (err) {
wx.showToast({ title: err.message || '提交失败', icon: 'none' });
this.setData({ submitting: false });
}
},
skipFeedback() {
wx.navigateBack();
}
});
@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "活动反馈",
"usingComponents": {}
}
@@ -0,0 +1,74 @@
<!-- 活动反馈页 U32 -->
<view class="page" wx:if="{{!feedbackSubmitted}}">
<view class="header">
<text class="title">活动反馈</text>
<text class="activity-name">{{activityTitle}}</text>
</view>
<view class="section">
<text class="section-title">整体体验</text>
<view class="radio-group">
<view class="radio-item {{overallExperience==='very_satisfied'?'active':''}}" data-value="very_satisfied" bindtap="onOverallChange">
<text>😊 非常满意</text>
</view>
<view class="radio-item {{overallExperience==='satisfied'?'active':''}}" data-value="satisfied" bindtap="onOverallChange">
<text>🙂 满意</text>
</view>
<view class="radio-item {{overallExperience==='normal'?'active':''}}" data-value="normal" bindtap="onOverallChange">
<text>😐 一般</text>
</view>
<view class="radio-item {{overallExperience==='dissatisfied'?'active':''}}" data-value="dissatisfied" bindtap="onOverallChange">
<text>😕 不满意</text>
</view>
</view>
</view>
<view class="section">
<text class="section-title">是否愿意参加下一次活动</text>
<view class="radio-group">
<view class="radio-item {{willingNextTime==='willing'?'active':''}}" data-value="willing" bindtap="onWillingChange">
<text>💚 愿意</text>
</view>
<view class="radio-item {{willingNextTime==='maybe'?'active':''}}" data-value="maybe" bindtap="onWillingChange">
<text>🤔 看情况</text>
</view>
<view class="radio-item {{willingNextTime==='not_now'?'active':''}}" data-value="not_now" bindtap="onWillingChange">
<text>😶 暂不参加</text>
</view>
</view>
</view>
<view class="section" wx:if="{{activityEndAt}}">
<text class="section-title">是否在活动中认识了想进一步了解的人</text>
<view class="radio-group">
<view class="radio-item {{metSomeoneInteresting===true?'active':''}}" data-value="yes" bindtap="onMetSomeoneChange">
<text>是</text>
</view>
<view class="radio-item {{metSomeoneInteresting===false?'active':''}}" data-value="no" bindtap="onMetSomeoneChange">
<text>否</text>
</view>
</view>
<view class="participant-list" wx:if="{{metSomeoneInteresting && otherParticipants.length > 0}}">
<text class="list-hint">选择您想进一步了解的人(仅活动结束后开放)</text>
<view class="participant-item" wx:for="{{otherParticipants}}" wx:key="userId"
bindtap="toggleInterest" data-user-id="{{item.userId}}">
<image class="avatar" src="{{item.avatarFileKey || '/assets/default-avatar.png'}}" mode="aspectFill"/>
<text class="name">{{item.displayName}}</text>
<text class="checkmark" wx:if="{{selectedInterests.indexOf(item.userId) >= 0}}">✓</text>
</view>
</view>
</view>
<button class="btn-submit" bindtap="submitFeedback" loading="{{submitting}}" disabled="{{!overallExperience || !willingNextTime}}">
提交反馈
</button>
<button class="btn-skip" bindtap="skipFeedback">跳过反馈</button>
</view>
<!-- 已提交状态 -->
<view class="page submitted" wx:if="{{feedbackSubmitted}}">
<view class="success-icon">✓</view>
<text class="success-title">反馈已提交</text>
<text class="success-hint">感谢您的参与!</text>
<button class="btn-home" bindtap="goHome">返回首页</button>
</view>
@@ -0,0 +1,170 @@
.page {
padding: 32rpx;
background: #FFF8F7;
min-height: 100vh;
}
.header {
text-align: center;
margin-bottom: 32rpx;
}
.title {
font-size: 36rpx;
font-weight: bold;
color: #1A1A1A;
display: block;
}
.activity-name {
font-size: 24rpx;
color: #999;
display: block;
margin-top: 8rpx;
}
.section {
background: #fff;
border-radius: 20rpx;
padding: 32rpx;
margin-bottom: 24rpx;
}
.section-title {
font-size: 28rpx;
font-weight: bold;
color: #1A1A1A;
display: block;
margin-bottom: 20rpx;
}
.radio-group {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.radio-item {
padding: 24rpx;
background: #F9F9F9;
border-radius: 16rpx;
font-size: 28rpx;
color: #333;
border: 2rpx solid transparent;
transition: all 0.2s;
}
.radio-item.active {
background: #FFF3F2;
border-color: #E96B6B;
color: #E96B6B;
}
.participant-list {
margin-top: 24rpx;
padding-top: 24rpx;
border-top: 1rpx solid #f0f0f0;
}
.list-hint {
font-size: 24rpx;
color: #999;
display: block;
margin-bottom: 16rpx;
}
.participant-item {
display: flex;
align-items: center;
padding: 16rpx 0;
border-bottom: 1rpx solid #f5f5f5;
}
.participant-item:last-child {
border-bottom: none;
}
.avatar {
width: 64rpx;
height: 64rpx;
border-radius: 32rpx;
margin-right: 16rpx;
background: #EEE;
}
.name {
flex: 1;
font-size: 28rpx;
color: #333;
}
.checkmark {
color: #E96B6B;
font-size: 32rpx;
font-weight: bold;
}
.btn-submit {
width: 100%;
height: 88rpx;
line-height: 88rpx;
background: #E96B6B;
color: #fff;
border-radius: 44rpx;
font-size: 30rpx;
margin-top: 24rpx;
}
.btn-submit[disabled] {
background: #CCC;
}
.btn-skip {
width: 100%;
height: 88rpx;
line-height: 88rpx;
background: transparent;
color: #999;
border-radius: 44rpx;
font-size: 28rpx;
border: 1rpx solid #DDD;
margin-top: 16rpx;
}
/* 已提交状态 */
.submitted {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 32rpx;
}
.success-icon {
font-size: 80rpx;
color: #E96B6B;
margin-bottom: 24rpx;
}
.success-title {
font-size: 36rpx;
font-weight: bold;
color: #1A1A1A;
margin-bottom: 16rpx;
}
.success-hint {
font-size: 28rpx;
color: #999;
margin-bottom: 48rpx;
}
.btn-home {
width: 80%;
height: 88rpx;
line-height: 88rpx;
background: #E96B6B;
color: #fff;
border-radius: 44rpx;
font-size: 30rpx;
}
@@ -0,0 +1,58 @@
/**
* 活动互选结果页 U36
* 展示活动中双方互相表达的意向结果
*/
const api = require('../../utils/api');
Page({
data: {
activityId: '',
activityTitle: '',
matches: [],
loading: true
},
onLoad(options) {
if (options.activityId) {
this.setData({ activityId: options.activityId });
this.loadMatches();
}
},
async loadMatches() {
this.setData({ loading: true });
try {
const token = wx.getStorageSync('accessToken');
const res = await api.get(`/activities/${this.data.activityId}/interests/mutual`, {}, {
Authorization: `Bearer ${token}`
});
// 获取活动标题
const actRes = await api.get(`/activities/${this.data.activityId}`, {}, {
Authorization: `Bearer ${token}`
});
this.setData({
activityTitle: actRes.title,
matches: res || [],
loading: false
});
} catch (err) {
this.setData({ loading: false });
}
},
onRefresh() {
this.loadMatches();
},
goToProfile(userId) {
wx.navigateTo({
url: `/pages/profile-detail/profile-detail?id=${userId}&activityId=${this.data.activityId}`
});
},
startChat(userId) {
wx.navigateTo({
url: `/pages/chat/chat?userId=${userId}&activityId=${this.data.activityId}`
});
}
});
@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "活动缘分",
"usingComponents": {}
}
@@ -0,0 +1,33 @@
<!-- 活动互选结果页 U36 -->
<view class="page">
<view class="header">
<text class="title">活动缘分</text>
<text class="activity-name">{{activityTitle}}</text>
</view>
<view wx:if="{{loading}}" class="loading-state">
<text>加载中...</text>
</view>
<view wx:if="{{!loading && matches.length === 0}}" class="empty-state">
<text class="empty-icon">✦</text>
<text>暂无双向匹配</text>
<text class="empty-hint">活动结束后,与其他参与者互相表达意愿后可在此查看</text>
</view>
<view wx:if="{{!loading && matches.length > 0}}" class="match-list">
<view class="match-card" wx:for="{{matches}}" wx:key="targetUserId"
bindtap="goToProfile" data-user-id="{{item.targetUserId}}">
<image class="match-avatar" src="{{item.fileKey || '/assets/default-avatar.png'}}" mode="aspectFill"/>
<view class="match-info">
<text class="match-name">{{item.name}}</text>
<text class="match-meta">{{item.age}}岁 · {{item.locationName || ''}}</text>
</view>
<view class="match-actions" catchtap>
<button class="btn-chat" bindtap="startChat" data-user-id="{{item.targetUserId}}">聊天</button>
</view>
</view>
</view>
<button class="btn-refresh" bindtap="onRefresh" wx:if="{{!loading}}">刷新</button>
</view>
@@ -0,0 +1,107 @@
.page {
padding: 32rpx;
background: #FFF8F7;
min-height: 100vh;
}
.header {
text-align: center;
margin-bottom: 32rpx;
}
.title {
font-size: 36rpx;
font-weight: bold;
color: #1A1A1A;
display: block;
}
.activity-name {
font-size: 24rpx;
color: #999;
display: block;
margin-top: 8rpx;
}
.loading-state, .empty-state {
text-align: center;
padding: 100rpx 32rpx;
color: #999;
}
.empty-icon {
font-size: 80rpx;
color: #E96B6B;
display: block;
margin-bottom: 24rpx;
}
.empty-hint {
font-size: 24rpx;
color: #BBB;
display: block;
margin-top: 16rpx;
}
.match-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.match-card {
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
display: flex;
align-items: center;
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04);
}
.match-avatar {
width: 100rpx;
height: 100rpx;
border-radius: 50rpx;
background: #F0F0F0;
flex-shrink: 0;
}
.match-info {
flex: 1;
margin-left: 24rpx;
}
.match-name {
font-size: 30rpx;
font-weight: bold;
color: #1A1A1A;
display: block;
}
.match-meta {
font-size: 24rpx;
color: #999;
display: block;
margin-top: 8rpx;
}
.btn-chat {
background: #E96B6B;
color: #fff;
font-size: 24rpx;
padding: 12rpx 28rpx;
border-radius: 24rpx;
border: none;
line-height: 1.4;
}
.btn-refresh {
margin-top: 32rpx;
background: transparent;
color: #999;
border: 1rpx solid #DDD;
border-radius: 44rpx;
height: 72rpx;
line-height: 72rpx;
font-size: 26rpx;
}
@@ -0,0 +1,66 @@
// pages/activity-mutual-result/activity-mutual-result.js
Page({
/**
* 页面的初始数据
*/
data: {
},
/**
* 生命周期函数--监听页面加载
*/
onLoad(options) {
},
/**
* 生命周期函数--监听页面初次渲染完成
*/
onReady() {
},
/**
* 生命周期函数--监听页面显示
*/
onShow() {
},
/**
* 生命周期函数--监听页面隐藏
*/
onHide() {
},
/**
* 生命周期函数--监听页面卸载
*/
onUnload() {
},
/**
* 页面相关事件处理函数--监听用户下拉动作
*/
onPullDownRefresh() {
},
/**
* 页面上拉触底事件的处理函数
*/
onReachBottom() {
},
/**
* 用户点击右上角分享
*/
onShareAppMessage() {
}
})
@@ -0,0 +1,4 @@
{
"usingComponents": {},
"navigationBarTitleText": "互选结果"
}
@@ -0,0 +1,24 @@
const app = getApp<{ globalData: { apiBase: string; token: string } }>();
Page({
data: { loading: true, results: [] as any[] },
onLoad() { this.loadResults(); },
onShow() { this.loadResults(); },
async loadResults() {
try {
const res = await wx.request({
url: `${app.globalData.apiBase}/activities/mutual/my-results`,
header: { 'Authorization': `Bearer ${app.globalData.token}` }
});
if (res.statusCode === 200 && res.data.success) {
this.setData({ results: res.data.data || [], loading: false });
}
} catch (e) { console.error(e); this.setData({ loading: false }); }
},
openChat(e: any) {
const partnerId = e.currentTarget.dataset.partnerid;
wx.navigateTo({ url: `/pages/chat/chat?partnerId=${partnerId}` });
}
});
@@ -0,0 +1,34 @@
<view class="container">
<block wx:if="{{loading}}">
<view class="loading">加载中...</view>
</block>
<block wx:elif="{{results.length === 0}}">
<view class="empty">
<text class="empty-icon">🎯</text>
<text class="empty-text">暂无互选结果</text>
<text class="empty-hint">参加活动时可以向感兴趣的人表达意向</text>
</view>
</block>
<block wx:else>
<view class="result-card" wx:for="{{results}}" wx:key="partnerId" wx:for-item="item">
<view class="card-header">
<image class="avatar" src="{{item.partnerAvatar || '/images/default-avatar.png'}}" mode="aspectFill" />
<view class="info">
<text class="name">{{item.partnerName || '新朋友'}}</text>
<text class="status-badge {{item.bothConfirmed ? 'badge-confirmed' : 'badge-waiting'}}">
{{item.bothConfirmed ? '双方已确认' : '等待对方确认'}}
</text>
</view>
</view>
<view class="card-body">
<text class="activity-name">来自活动:{{item.activityTitle}}</text>
</view>
<view class="card-actions" wx:if="{{!item.bothConfirmed}}">
<button class="btn-waiting" disabled>等待对方确认</button>
</view>
<view class="card-actions" wx:if="{{item.bothConfirmed}}">
<button class="btn-chat" bindtap="openChat" data-partnerid="{{item.partnerId}}">开启聊天</button>
</view>
</view>
</block>
</view>
@@ -0,0 +1,19 @@
.container { padding: 24rpx; min-height: 100vh; background: #f5f5f5; }
.loading { text-align: center; padding: 80rpx; color: #999; }
.empty { text-align: center; padding: 80rpx 0; }
.empty-icon { font-size: 80rpx; display: block; margin-bottom: 20rpx; }
.empty-text { font-size: 32rpx; color: #666; display: block; }
.empty-hint { font-size: 26rpx; color: #999; display: block; margin-top: 12rpx; }
.result-card { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 20rpx; box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05); }
.card-header { display: flex; align-items: center; margin-bottom: 16rpx; }
.avatar { width: 80rpx; height: 80rpx; border-radius: 50rpx; background: #eee; }
.info { flex: 1; margin-left: 20rpx; }
.name { font-size: 30rpx; font-weight: bold; color: #333; display: block; }
.status-badge { font-size: 22rpx; padding: 4rpx 12rpx; border-radius: 12rpx; margin-top: 8rpx; display: inline-block; }
.badge-confirmed { background: #e8f5e9; color: #2e7d32; }
.badge-waiting { background: #fff3e0; color: #e65100; }
.card-body { margin-bottom: 16rpx; }
.activity-name { font-size: 24rpx; color: #999; }
.card-actions { display: flex; justify-content: flex-end; }
.btn-waiting { background: #f5f5f5; color: #999; font-size: 26rpx; padding: 12rpx 32rpx; border-radius: 24rpx; border: none; }
.btn-chat { background: linear-gradient(135deg, #FF6B6B, #EE5A24); color: #fff; font-size: 26rpx; padding: 12rpx 32rpx; border-radius: 24rpx; border: none; }
@@ -0,0 +1,66 @@
// pages/activity-mutual-waiting/activity-mutual-waiting.js
Page({
/**
* 页面的初始数据
*/
data: {
},
/**
* 生命周期函数--监听页面加载
*/
onLoad(options) {
},
/**
* 生命周期函数--监听页面初次渲染完成
*/
onReady() {
},
/**
* 生命周期函数--监听页面显示
*/
onShow() {
},
/**
* 生命周期函数--监听页面隐藏
*/
onHide() {
},
/**
* 生命周期函数--监听页面卸载
*/
onUnload() {
},
/**
* 页面相关事件处理函数--监听用户下拉动作
*/
onPullDownRefresh() {
},
/**
* 页面上拉触底事件的处理函数
*/
onReachBottom() {
},
/**
* 用户点击右上角分享
*/
onShareAppMessage() {
}
})
@@ -0,0 +1,4 @@
{
"usingComponents": {},
"navigationBarTitleText": "等待确认"
}
@@ -0,0 +1,81 @@
const app = getApp<{ globalData: { apiBase: string; token: string } }>();
Page({
data: {
activityId: '', partnerId: '',
partnerName: '', partnerAvatar: '',
myConfirmed: false, bothConfirmed: false, partnerDeclined: false
},
onLoad(options: any) {
this.setData({ activityId: options.activityId, partnerId: options.partnerId });
this.loadStatus();
},
async loadStatus() {
try {
const res = await wx.request({
url: `${app.globalData.apiBase}/activities/${this.data.activityId}/mutual/status/${this.data.partnerId}`,
header: { 'Authorization': `Bearer ${app.globalData.token}` }
});
if (res.statusCode === 200 && res.data.success) {
const d = res.data.data;
this.setData({
myConfirmed: d.myStatus === 'accepted',
bothConfirmed: d.bothConfirmed,
partnerDeclined: d.partnerStatus === 'declined'
});
}
// 加载对方信息
this.loadPartnerInfo();
} catch (e) { console.error(e); }
},
async loadPartnerInfo() {
try {
const res = await wx.request({
url: `${app.globalData.apiBase}/profiles/${this.data.partnerId}`,
header: { 'Authorization': `Bearer ${app.globalData.token}` }
});
if (res.statusCode === 200 && res.data.success) {
this.setData({ partnerName: res.data.data?.displayName || '新朋友',
partnerAvatar: res.data.data?.avatarFileId ? `${app.globalData.uploadBase}/files/${res.data.data.avatarFileId}` : '' });
}
} catch (e) { /* ignore */ }
},
async onConfirm() {
try {
const res = await wx.request({
url: `${app.globalData.apiBase}/activities/${this.data.activityId}/mutual/confirm`,
method: 'POST',
header: { 'Authorization': `Bearer ${app.globalData.token}`, 'Content-Type': 'application/json' },
data: { partnerId: this.data.partnerId }
});
if (res.statusCode === 200 && res.data.success) {
this.setData({ myConfirmed: true, bothConfirmed: res.data.data.bothConfirmed });
if (res.data.data.bothConfirmed) {
wx.showToast({ title: '双方已确认!', icon: 'success' });
setTimeout(() => this.openChat(), 1500);
}
}
} catch (e) { wx.showToast({ title: '操作失败', icon: 'none' }); }
},
async onDecline() {
try {
await wx.request({
url: `${app.globalData.apiBase}/activities/${this.data.activityId}/mutual/decline`,
method: 'POST',
header: { 'Authorization': `Bearer ${app.globalData.token}`, 'Content-Type': 'application/json' },
data: { partnerId: this.data.partnerId }
});
this.setData({ myConfirmed: false, bothConfirmed: false });
wx.navigateBack();
} catch (e) { wx.showToast({ title: '操作失败', icon: 'none' }); }
},
openChat() {
wx.navigateTo({ url: `/pages/chat/chat?partnerId=${this.data.partnerId}` });
}
});
@@ -0,0 +1,27 @@
<view class="container">
<view class="header">
<image class="avatar" src="{{partnerAvatar || '/images/default-avatar.png'}}" mode="aspectFill" />
<text class="partner-name">{{partnerName || '新朋友'}}</text>
</view>
<view class="status-section" wx:if="{{bothConfirmed}}">
<view class="success-icon">✅</view>
<text class="success-title">双方已确认!</text>
<text class="success-hint">你们可以开始聊天了</text>
<button class="chat-btn" bindtap="openChat">开启聊天</button>
</view>
<view class="status-section" wx:elif="{{myConfirmed}}">
<view class="waiting-icon">⏳</view>
<text class="waiting-title">已发送邀请</text>
<text class="waiting-hint">对方确认后你们可以开始聊天</text>
<button class="decline-btn" bindtap="onDecline">暂不联系</button>
</view>
<view class="status-section" wx:else>
<view class="pending-icon">💬</view>
<text class="pending-title">向Ta表达兴趣</text>
<text class="pending-hint">双方确认后才能开启线上沟通</text>
<button class="confirm-btn" bindtap="onConfirm">确认开启沟通</button>
</view>
</view>
@@ -0,0 +1,10 @@
.container { padding: 40rpx 24rpx; min-height: 100vh; background: #f5f5f5; display: flex; flex-direction: column; align-items: center; }
.header { text-align: center; margin-bottom: 40rpx; }
.avatar { width: 120rpx; height: 120rpx; border-radius: 60rpx; background: #eee; }
.partner-name { font-size: 36rpx; font-weight: bold; color: #333; display: block; margin-top: 16rpx; }
.status-section { background: #fff; border-radius: 20rpx; padding: 48rpx 40rpx; width: 100%; text-align: center; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06); }
.success-icon, .waiting-icon, .pending-icon { font-size: 72rpx; margin-bottom: 20rpx; }
.success-title, .waiting-title, .pending-title { font-size: 34rpx; font-weight: bold; color: #333; display: block; margin-bottom: 12rpx; }
.success-hint, .waiting-hint, .pending-hint { font-size: 26rpx; color: #888; display: block; margin-bottom: 32rpx; }
.chat-btn, .confirm-btn { background: linear-gradient(135deg, #FF6B6B, #EE5A24); color: #fff; border: none; border-radius: 44rpx; font-size: 30rpx; padding: 16rpx 60rpx; }
.decline-btn { background: #f5f5f5; color: #999; border: none; border-radius: 44rpx; font-size: 28rpx; padding: 16rpx 60rpx; margin-top: 20rpx; }
@@ -0,0 +1,8 @@
/**
* 页面占位骨架
*/
Page({
data: {},
onLoad(options) {},
onShow() {}
});
@@ -0,0 +1 @@
{"navigationBarTitleText":"activity-register","usingComponents":{}}
@@ -0,0 +1 @@
<view class="page"><text>页面: activity-register</text></view>
@@ -0,0 +1 @@
.page{padding:32rpx;background:#FFF8F7;min-height:100vh;text-align:center;color:#999;padding-top:200rpx;}
+115
View File
@@ -0,0 +1,115 @@
/**
* 公益活动列表页 U31
*
* 重构要点(遵循 AGENTS.md 不变量):
* - 数据:ActivityStore.fetchActivities / loadMoreActivities(游标分页)
* - 列表项:ui-activity-card 组件,"公益免费"为主视觉标识(AGENTS 第 1 条:用户永久免费)
* - 跳转:点击卡片 → activity-detail,不在列表页处理报名
* - 单向意向保密:本页不接入互选意向提交(仅在 activity-matches 页由用户主动操作,AGENTS 第 6 条)
* - 下拉刷新 + onReachBottom 加载更多
*/
const { activityStore, appStore, authStore } = require('../../stores');
const { to } = require('../../constants');
Page({
data: {
activities: [],
loading: false,
loadingMore: false,
hasMore: true,
firstLoaded: false,
/** @type {{title:string, description:string, illustration:string, actions:Array}} */
emptyConfig: {
title: '暂无进行中的活动',
description: '近期暂无报名中的公益活动,请稍后再来',
illustration: 'activity',
actions: [
{ text: '我的活动', event: 'my', variant: 'secondary' },
],
},
},
onLoad() {
if (!authStore.state.isLoggedIn) return;
this._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._unsub) this._unsub();
},
/** 首次或下拉刷新拉取活动列表 */
async fetchActivities(first = false) {
if (this.data.loading) return;
this.setData({ loading: true });
try {
await activityStore.fetchActivities();
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.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();
},
/** 点击活动卡片 → 跳转详情 */
onActivityTap(e) {
const activityId = e.detail.activityId;
if (!activityId) return;
wx.navigateTo({ url: to.activityDetail(activityId) });
},
/** 空状态:去我的活动 */
onEmptyAction(e) {
const { event } = e.detail;
if (event === 'my') {
wx.navigateTo({ url: '/pages/my-activities/my-activities' });
}
},
/** 顶部"我的活动"入口 */
onMyActivities() {
wx.navigateTo({ url: '/pages/my-activities/my-activities' });
},
});
+10
View File
@@ -0,0 +1,10 @@
{
"navigationBarTitleText": "公益活动",
"enablePullDownRefresh": true,
"usingComponents": {
"ui-section-header": "/components/section-header/index",
"ui-activity-card": "/components/activity-card/index",
"ui-loading": "/components/loading/index",
"ui-empty": "/components/empty/index"
}
}
+43
View File
@@ -0,0 +1,43 @@
<!-- 公益活动列表页 U31 -->
<view class="act-page">
<!-- 顶部区:标题 + 我的活动入口 -->
<view class="act-page__topbar">
<ui-section-header
title="公益活动"
subtitle="丹阳本地公益组织主办"
action="我的活动"
bind:action="onMyActivities"
/>
</view>
<!-- 首次加载 -->
<view wx:if="{{loading && !firstLoaded}}" class="act-loading">
<ui-loading mode="skeleton" rows="3" />
</view>
<!-- 列表 -->
<view wx:elif="{{activities.length}}" class="act-list">
<ui-activity-card
wx:for="{{activities}}"
wx:key="activityId"
activity="{{item}}"
bind:tap="onActivityTap"
/>
<!-- 加载更多 -->
<view wx:if="{{loadingMore}}" class="act-list__more">
<ui-loading mode="spinner" inline text="加载更多活动…" />
</view>
<view wx:elif="{{!hasMore}}" class="act-list__end">已到底部</view>
</view>
<!-- 空状态 -->
<view wx:else class="act-empty">
<ui-empty
title="{{emptyConfig.title}}"
description="{{emptyConfig.description}}"
illustration="{{emptyConfig.illustration}}"
actions="{{emptyConfig.actions}}"
bind:action="onEmptyAction"
/>
</view>
</view>
+44
View File
@@ -0,0 +1,44 @@
/**
* 公益活动列表页 U31
*/
.act-page {
min-height: 100vh;
background: var(--color-bg-page);
padding-bottom: var(--spacing-section);
}
.act-page__topbar {
padding: var(--spacing-3) var(--spacing-page-x) var(--spacing-1);
}
/* ============ 加载骨架 ============ */
.act-loading {
padding: var(--spacing-3) var(--spacing-page-x);
}
/* ============ 列表 ============ */
.act-list {
padding: var(--spacing-2) var(--spacing-page-x) 0;
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.act-list__more {
padding: var(--spacing-3) 0;
display: flex;
justify-content: center;
}
.act-list__end {
padding: var(--spacing-3) 0;
text-align: center;
font-size: var(--font-size-xs);
color: var(--color-text-placeholder);
}
/* ============ 空状态 ============ */
.act-empty {
padding-top: 160rpx;
}
+141
View File
@@ -0,0 +1,141 @@
/**
* 申诉提交页(重构:用 appealService.submitAppeal + ui-nav-bar + ui-textarea)
*
* 用户针对封禁/暂停处置提交申诉。类型由入口决定(ban_appeal / suspend_appeal)。
*
* 不变量(AGENTS.md):
* - 第 8 条:敏感数据默认拒绝——本页只接收申诉原因 + 证据截图 + 联系方式,
* 不接受身份证号等敏感字段;联系方式为可选。
* - 第 4 条:Block 优先级最高——申诉不会自动解封或解除拉黑,由运营人工处置。
* 幂等:appealService.submitAppeal 内部以 appealType+relatedActionId+秒级时间生成 Idempotency-Key。
*/
const { appStore } = require('../../stores');
const { appealService } = require('../../services');
const TYPE_LABELS = {
ban_appeal: '封禁申诉',
suspend_appeal: '暂停申诉',
};
const MIN_REASON_LEN = 10;
const MAX_EVIDENCE = 5;
Page({
data: {
appealType: '',
actionId: '',
typeLabel: '申诉',
reason: '',
reasonError: '',
evidenceFiles: [],
contactMethod: '',
submitting: false,
submitted: false,
statusBarHeight: 20,
navBarHeight: 44,
},
onLoad(options) {
try {
const info = (wx.getWindowInfo && wx.getWindowInfo()) || wx.getSystemInfoSync();
this.setData({
statusBarHeight: info.statusBarHeight || 20,
navBarHeight: info.platform === 'android' ? 48 : 44,
});
} catch (e) { /* 使用默认值 */ }
const appealType = options.type ? decodeURIComponent(options.type) : '';
const actionId = options.actionId ? decodeURIComponent(options.actionId) : '';
this.setData({
appealType,
actionId,
typeLabel: TYPE_LABELS[appealType] || '申诉',
});
},
onReasonInput(e) {
this.setData({ reason: e.detail.value, reasonError: '' });
},
onContactInput(e) {
this.setData({ contactMethod: e.detail.value });
},
// ============ 证据上传 ============
chooseEvidence() {
const remain = MAX_EVIDENCE - this.data.evidenceFiles.length;
if (remain <= 0) {
appStore.toast('最多上传 5 张证据');
return;
}
wx.chooseMedia({
count: remain,
mediaType: ['image'],
sourceType: ['album', 'camera'],
success: (res) => {
const files = res.tempFiles.map((f) => f.tempFilePath);
this.setData({ evidenceFiles: [...this.data.evidenceFiles, ...files] });
},
});
},
previewImage(e) {
wx.previewImage({
current: e.currentTarget.dataset.url,
urls: this.data.evidenceFiles,
});
},
removeImage(e) {
const idx = e.currentTarget.dataset.index;
const files = this.data.evidenceFiles;
files.splice(idx, 1);
this.setData({ evidenceFiles: files });
},
// ============ 提交 ============
async submitAppeal() {
const { appealType, actionId, reason, contactMethod } = this.data;
const trimmedReason = (reason || '').trim();
if (!appealType) {
appStore.toast('缺少申诉类型');
return;
}
if (!trimmedReason) {
this.setData({ reasonError: '请填写申诉原因' });
return;
}
if (trimmedReason.length < MIN_REASON_LEN) {
this.setData({ reasonError: `申诉原因请至少 ${MIN_REASON_LEN} 个字` });
return;
}
this.setData({ submitting: true });
try {
await appealService.submitAppeal({
appealType,
relatedActionId: actionId || undefined,
reason: trimmedReason,
// 证据上传至 OSS 后回填 fileId;当前阶段先以本地路径占位,由后端证据服务接入后补全
evidenceFileIds: [],
contactMethod: (contactMethod || '').trim() || undefined,
});
this.setData({ submitted: true, submitting: false });
appStore.toastSuccess('申诉已提交');
} catch (err) {
appStore.toastError(err);
this.setData({ submitting: false });
}
},
goBack() {
wx.navigateBack();
},
onBack() {
wx.navigateBack();
},
});
+11
View File
@@ -0,0 +1,11 @@
{
"navigationStyle": "custom",
"navigationBarTitleText": "提交申诉",
"backgroundColor": "#FFF8F7",
"backgroundTextStyle": "dark",
"usingComponents": {
"ui-nav-bar": "/components/nav-bar/index",
"ui-section-header": "/components/section-header/index",
"ui-textarea": "/components/textarea/index"
}
}
+82
View File
@@ -0,0 +1,82 @@
<!-- 申诉提交页(重构:appealService + ui-nav-bar + ui-textarea) -->
<view class="page">
<ui-nav-bar
title="{{typeLabel}}"
show-back="{{true}}"
status-bar-height="{{statusBarHeight}}"
nav-bar-height="{{navBarHeight}}"
bind:back="onBack"
/>
<!-- 提交成功态 -->
<view wx:if="{{submitted}}" class="body" style="padding-top: {{statusBarHeight + navBarHeight}}px;">
<view class="success-wrap">
<view class="success-icon">✓</view>
<text class="success-title">申诉已提交</text>
<text class="success-hint">工作人员将在 1-3 个工作日内审核您的申诉结果,请耐心等待。</text>
<button class="btn-primary" bindtap="goBack">返回</button>
</view>
</view>
<!-- 表单态 -->
<view wx:else class="body" style="padding-top: {{statusBarHeight + navBarHeight}}px;">
<view class="intro-card">
<text class="intro-title">{{typeLabel}}</text>
<text class="intro-subtitle">请如实描述您的情况,工作人员将认真审核</text>
</view>
<!-- 申诉原因 -->
<view class="card">
<ui-section-header title="申诉原因" subtitle="必填,至少10字" />
<ui-textarea
value="{{reason}}"
placeholder="请详细说明您认为处置不当的原因,以及您希望恢复账号的理由..."
maxlength="500"
error="{{reasonError}}"
bind:input="onReasonInput"
/>
</view>
<!-- 补充证据 -->
<view class="card">
<ui-section-header title="补充证据" subtitle="选填,最多5张" />
<view class="evidence-list">
<view
class="evidence-item"
wx:for="{{evidenceFiles}}"
wx:key="index"
data-url="{{item}}"
bindtap="previewImage"
>
<image src="{{item}}" mode="aspectFill" />
<view class="remove-btn" catchtap="removeImage" data-index="{{index}}">✕</view>
</view>
<view class="evidence-add" bindtap="chooseEvidence" wx:if="{{evidenceFiles.length < 5}}">
<text class="add-icon">+</text>
<text class="add-text">添加证据</text>
</view>
</view>
</view>
<!-- 联系方式 -->
<view class="card">
<ui-section-header title="联系方式" subtitle="选填" />
<input
class="contact-input"
placeholder="请输入手机号或邮箱,便于我们联系您"
value="{{contactMethod}}"
bindinput="onContactInput"
/>
</view>
<button class="btn-submit" bindtap="submitAppeal" loading="{{submitting}}" disabled="{{!reason}}">
提交申诉
</button>
<text class="hint">提交后工作人员将在 1-3 个工作日内审核,请勿重复提交</text>
<!-- 公益免费标识 -->
<view class="free-badge">
<text>本平台为公益服务,申诉受理永久免费</text>
</view>
</view>
</view>
+209
View File
@@ -0,0 +1,209 @@
/* 申诉提交页 — Design Tokens */
.page {
min-height: 100vh;
background: #FFF8F7;
}
.body {
padding: 24rpx;
box-sizing: border-box;
}
/* 介绍卡片 */
.intro-card {
background: #fff;
border-radius: 20rpx;
padding: 32rpx;
margin-bottom: 24rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
border-left: 8rpx solid #E96B6B;
}
.intro-title {
display: block;
font-size: 34rpx;
font-weight: bold;
color: #1A1A1A;
margin-bottom: 12rpx;
}
.intro-subtitle {
display: block;
font-size: 26rpx;
color: #999;
line-height: 1.5;
}
/* 卡片 */
.card {
background: #fff;
border-radius: 20rpx;
padding: 24rpx 32rpx;
margin-bottom: 24rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
/* 证据 */
.evidence-list {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin-top: 16rpx;
}
.evidence-item {
position: relative;
width: 160rpx;
height: 160rpx;
border-radius: 12rpx;
overflow: hidden;
}
.evidence-item image {
width: 100%;
height: 100%;
}
.remove-btn {
position: absolute;
top: 4rpx;
right: 4rpx;
width: 36rpx;
height: 36rpx;
line-height: 32rpx;
text-align: center;
background: rgba(0, 0, 0, 0.5);
color: #fff;
border-radius: 50%;
font-size: 22rpx;
}
.evidence-add {
width: 160rpx;
height: 160rpx;
border-radius: 12rpx;
background: #F7F8FA;
border: 2rpx dashed #D0D0D0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #999;
}
.add-icon {
font-size: 48rpx;
color: #B0B0B0;
line-height: 1;
}
.add-text {
font-size: 22rpx;
margin-top: 8rpx;
}
/* 联系方式输入 */
.contact-input {
margin-top: 16rpx;
background: #F7F8FA;
border-radius: 12rpx;
padding: 24rpx;
font-size: 28rpx;
min-height: 40rpx;
}
/* 提交按钮 */
.btn-submit {
width: 100%;
height: 88rpx;
line-height: 88rpx;
border-radius: 44rpx;
background: #E96B6B;
color: #fff;
font-size: 32rpx;
border: none;
margin-top: 8rpx;
}
.btn-submit::after {
border: none;
}
.btn-submit[disabled] {
opacity: 0.5;
}
.btn-primary {
width: 80%;
height: 88rpx;
line-height: 88rpx;
border-radius: 44rpx;
background: #E96B6B;
color: #fff;
font-size: 32rpx;
border: none;
margin-top: 48rpx;
}
.btn-primary::after {
border: none;
}
.hint {
display: block;
text-align: center;
font-size: 22rpx;
color: #B0B0B0;
margin-top: 20rpx;
padding: 0 24rpx;
}
/* 成功态 */
.success-wrap {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 48rpx;
}
.success-icon {
width: 120rpx;
height: 120rpx;
line-height: 120rpx;
text-align: center;
border-radius: 50%;
background: #E8F5E9;
color: #1A6B3E;
font-size: 64rpx;
margin-bottom: 32rpx;
}
.success-title {
font-size: 36rpx;
font-weight: bold;
color: #1A1A1A;
margin-bottom: 16rpx;
}
.success-hint {
font-size: 26rpx;
color: #999;
text-align: center;
line-height: 1.6;
}
/* 公益免费标识 */
.free-badge {
text-align: center;
padding: 24rpx;
margin-top: 16rpx;
}
.free-badge text {
display: inline-block;
font-size: 22rpx;
color: #1A6B3E;
background: #E8F5E9;
padding: 8rpx 24rpx;
border-radius: 24rpx;
}
+1
View File
@@ -0,0 +1 @@
Page({ data: {}, onLoad() {}, onShow() {} })
+1
View File
@@ -0,0 +1 @@
{"navigationBarTitleText":"bind-phone","usingComponents":{}}
+1
View File
@@ -0,0 +1 @@
<view class="page"><text>bind-phone</text></view>
+1
View File
@@ -0,0 +1 @@
.page{padding:32rpx;background:#FFF8F7;min-height:100vh;text-align:center;padding-top:200rpx;color:#999;}
+124
View File
@@ -0,0 +1,124 @@
/**
* 黑名单管理页(重构:用 matchStore + ui-nav-bar + DangerActionDialog)
*
* 列表展示已拉黑用户(脱敏字段),支持解除拉黑。
*
* 不变量(AGENTS.md):
* - 第 4 条:Block 优先级最高——解除拉黑后对方可重新与你互动;
* 解除需 DangerActionDialog 二次确认。
* - 第 8 条:敏感数据默认拒绝——列表仅展示后端返回的脱敏字段(昵称/拉黑时间/原因),
* 不展示手机号/身份证等敏感信息。
* - 第 9 条:PostgreSQL 是 Source of Truth——列表状态以 matchStore 同步服务端返回为准。
*/
const { matchStore, appStore } = require('../../stores');
const { DANGER_ACTION } = require('../../constants');
const PAGE_SIZE = 20;
Page({
data: {
blockedList: [],
blockedHasMore: true,
loading: false,
loadingMore: false,
actionLoadingUserId: '',
// DangerActionDialog
showDangerDialog: false,
dangerAction: DANGER_ACTION.UNBLOCK,
dangerTargetName: '',
pendingUnblockUserId: '',
statusBarHeight: 20,
navBarHeight: 44,
},
onLoad() {
try {
const info = (wx.getWindowInfo && wx.getWindowInfo()) || wx.getSystemInfoSync();
this.setData({
statusBarHeight: info.statusBarHeight || 20,
navBarHeight: info.platform === 'android' ? 48 : 44,
});
} catch (e) { /* 使用默认值 */ }
this._unsub = matchStore.bindToPage(this, {
blockedList: 'blockedList',
blockedHasMore: 'blockedHasMore',
});
this.fetchList(true);
},
onUnload() {
if (this._unsub) this._unsub();
},
onShow() {
this.fetchList(true);
},
async fetchList(first) {
if (this.data.loading) return;
this.setData({ loading: true });
try {
await matchStore.fetchBlockedList({ pageSize: PAGE_SIZE });
} catch (err) {
appStore.toastError(err);
} finally {
this.setData({ loading: false });
}
},
onPullDownRefresh() {
this.fetchList(true).finally(() => wx.stopPullDownRefresh());
},
onReachBottom() {
if (this.data.loadingMore || !this.data.blockedHasMore) return;
this.setData({ loadingMore: true });
matchStore.loadMoreBlockedList()
.catch((err) => appStore.toastError(err))
.finally(() => this.setData({ loadingMore: false }));
},
// ============ 解除拉黑 ============
onUnblock(e) {
const userId = e.currentTarget.dataset.userId;
const userName = e.currentTarget.dataset.userName || '对方';
if (!userId || this.data.actionLoadingUserId) return;
this.setData({
showDangerDialog: true,
dangerTargetName: userName,
pendingUnblockUserId: userId,
});
},
async onDangerConfirm() {
const userId = this.data.pendingUnblockUserId;
this.setData({ showDangerDialog: false, pendingUnblockUserId: '' });
if (!userId) return;
this.setData({ actionLoadingUserId: userId });
try {
await matchStore.unblock(userId);
// 从本地列表移除(Store 的 unblock 不自动移除,需页面处理)
matchStore.setState({
blockedList: matchStore.state.blockedList.filter((u) => u.userId !== userId),
});
appStore.toastSuccess('已解除拉黑');
} catch (err) {
appStore.toastError(err);
} finally {
this.setData({ actionLoadingUserId: '' });
}
},
onDangerCancel() {
this.setData({ showDangerDialog: false, pendingUnblockUserId: '' });
},
onBack() {
wx.navigateBack();
},
});
+14
View File
@@ -0,0 +1,14 @@
{
"navigationStyle": "custom",
"navigationBarTitleText": "黑名单管理",
"enablePullDownRefresh": true,
"backgroundColor": "#FFF8F7",
"backgroundTextStyle": "dark",
"usingComponents": {
"ui-nav-bar": "/components/nav-bar/index",
"ui-loading": "/components/loading/index",
"ui-empty": "/components/empty/index",
"ui-avatar": "/components/avatar/index",
"ui-danger-action-dialog": "/components/danger-action-dialog/index"
}
}
+55
View File
@@ -0,0 +1,55 @@
<!-- 黑名单管理页(重构:matchStore + ui-nav-bar + DangerActionDialog) -->
<view class="page">
<ui-nav-bar
title="黑名单管理"
show-back="{{true}}"
status-bar-height="{{statusBarHeight}}"
nav-bar-height="{{navBarHeight}}"
bind:back="onBack"
/>
<view class="body" style="padding-top: {{statusBarHeight + navBarHeight}}px;">
<ui-loading wx:if="{{loading}}" mode="skeleton" rows="4" />
<block wx:elif="{{blockedList.length > 0}}">
<view class="list">
<view class="item" wx:for="{{blockedList}}" wx:key="userId">
<ui-avatar
src="{{item.avatarUrl}}"
size="lg"
default-text="{{item.userName}}"
/>
<view class="info">
<text class="name">{{item.userName || '用户'}}</text>
<text class="meta">拉黑于 {{item.blockedAtText || '未知时间'}}</text>
<text class="reason" wx:if="{{item.reason}}">原因:{{item.reason}}</text>
</view>
<button
class="btn-unblock {{actionLoadingUserId === item.userId ? 'disabled' : ''}}"
catchtap="onUnblock"
data-user-id="{{item.userId}}"
data-user-name="{{item.userName}}"
disabled="{{actionLoadingUserId === item.userId}}"
>解除拉黑</button>
</view>
</view>
<ui-loading wx:if="{{loadingMore}}" mode="skeleton" rows="2" />
</block>
<ui-empty
wx:else
illustration="default"
title="黑名单是空的"
description="你还没有拉黑过任何人。拉黑后对方将无法与你互动。"
/>
</view>
<!-- 解除拉黑二次确认 -->
<ui-danger-action-dialog
visible="{{showDangerDialog}}"
action="{{dangerAction}}"
target-name="{{dangerTargetName}}"
bind:confirm="onDangerConfirm"
bind:cancel="onDangerCancel"
/>
</view>
+72
View File
@@ -0,0 +1,72 @@
/* 黑名单管理页 — Design Tokens */
.page {
min-height: 100vh;
background: #FFF8F7;
}
.body {
padding: 24rpx;
box-sizing: border-box;
}
.list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.item {
display: flex;
align-items: center;
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
gap: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.info {
flex: 1;
min-width: 0;
}
.name {
display: block;
font-size: 30rpx;
font-weight: bold;
color: #1A1A1A;
margin-bottom: 6rpx;
}
.meta {
display: block;
font-size: 24rpx;
color: #999;
margin-bottom: 4rpx;
}
.reason {
display: block;
font-size: 22rpx;
color: #B0B0B0;
}
.btn-unblock {
flex-shrink: 0;
height: 64rpx;
line-height: 60rpx;
padding: 0 28rpx;
border-radius: 32rpx;
font-size: 24rpx;
background: #fff;
color: #E96B6B;
border: 2rpx solid #E96B6B;
}
.btn-unblock::after {
border: none;
}
.btn-unblock.disabled {
opacity: 0.5;
}
+280
View File
@@ -0,0 +1,280 @@
/**
* 聊天详情页 U24(重构:用 messageStore + message-bubble)
*
* 不变量(AGENTS.md):
* - 第 3 条:Mutual Match 后才能聊天——sendMessage 若后端返回 MATCH_REQUIRED,
* UI 给出引导文案;不在客户端做硬阻断,由后端守门。
* - 第 4 条:Block/Unmatch 后会话 canSendMessage=false,输入栏禁用并显示"已停用"。
* - 第 6 条:单向活动意向不泄露——本页只展示已建立的 Mutual 会话消息。
* - 第 7 条:普通工作人员不能查看私人聊天——本页仅服务用户端。
* - 第 9 条:PostgreSQL 是 Source of Truth——本地仅缓存最近活跃会话消息,冷启动从服务端拉取。
*/
const { messageStore, appStore, blockUser } = require('../../stores');
const { messageService } = require('../../services');
const { to, ROUTE, DANGER_ACTION, BUSINESS_ERROR_CODE } = require('../../constants');
/** 超过该间隔则显示时间分隔(5 分钟) */
const TIME_GAP_MS = 5 * 60 * 1000;
Page({
data: {
conversationId: '',
otherDisplayName: '',
otherUserId: '',
otherAvatarUrl: '',
canSend: true,
messages: [],
hasMore: true,
loading: false,
loadingMore: false,
sending: false,
inputText: '',
scrollIntoView: '',
statusBarHeight: 20,
navBarHeight: 44,
showDangerDialog: false,
dangerAction: DANGER_ACTION.BLOCK,
connectionState: 'disconnected',
},
onLoad(options) {
const conversationId = options.id || '';
if (!conversationId) {
appStore.toast('会话不存在');
wx.navigateBack();
return;
}
// 注入状态栏高度供 ui-nav-bar 使用
try {
const info = (wx.getWindowInfo && wx.getWindowInfo()) || wx.getSystemInfoSync();
this.setData({
statusBarHeight: info.statusBarHeight || 20,
navBarHeight: info.platform === 'android' ? 48 : 44,
});
} catch (e) { /* 使用默认值 */ }
this.setData({ conversationId });
// 绑定 messageStore.connectionState 到页面
this._unsubConn = messageStore.bindToPage(this, {
connectionState: 'connectionState',
});
// 订阅消息列表 / 分页 / loading 变化
this._unsubList = messageStore.subscribe((state) => this._syncFromStore(state));
// 注册实时消息监听:收到对方消息立即标记已读(会话已处于前台)
this._unsubRealtime = messageStore.onMessage(conversationId, (msg) => {
if (msg.bubbleType === 'other') {
messageStore.markConversationRead(conversationId).catch(() => {});
}
});
// 打开会话:拉取最近消息 + 清未读(AGENTS 第 9 条,DB 为准)
messageStore.openConversation(conversationId).catch((err) => appStore.toastError(err));
// 拉取会话元数据(displayName / canSend / targetUserId / 头像)
this.loadConversationMeta();
},
onUnload() {
this._unsubConn && this._unsubConn();
this._unsubList && this._unsubList();
this._unsubRealtime && this._unsubRealtime();
if (this.data.conversationId) {
// 关闭会话:activeConversationId 置空,后续收到对方消息将累计未读
messageStore.closeConversation();
}
},
/** 拉取会话元数据(canSend 以服务端为准) */
async loadConversationMeta() {
try {
const conv = await messageService.getConversation(this.data.conversationId);
if (conv) {
this.setData({
otherDisplayName: conv.targetDisplayName || conv.otherDisplayName || '用户',
otherUserId: conv.targetUserId || '',
otherAvatarUrl: conv.targetAvatarUrl || '',
canSend: conv.canSendMessage !== false,
});
}
} catch (err) {
// 兜底:从 store 会话列表查找
const local = messageStore.state.conversations.find(
(c) => c.conversationId === this.data.conversationId,
);
if (local) {
this.setData({
otherDisplayName: local.targetDisplayName || '用户',
otherUserId: local.targetUserId || '',
otherAvatarUrl: local.targetAvatarUrl || '',
canSend: local.canSendMessage !== false,
});
} else {
appStore.toastError(err);
}
}
},
/** 把 store 状态同步到页面 */
_syncFromStore(state) {
const cid = this.data.conversationId;
const rawList = state.messagesByConversation[cid] || [];
const hasMore = state.hasMoreByConversation[cid] !== false;
const loading = state.conversationsLoading;
const messages = this._formatMessageList(rawList);
const patch = { messages, hasMore };
if (loading !== this.data.loading) patch.loading = loading;
this.setData(patch);
// 加载更早消息时,保持滚动位置(定位到加载前的第一条)
if (this._loadingMore && this._preserveMsgId) {
const idx = messages.findIndex((m) => m.messageId === this._preserveMsgId);
if (idx >= 0) this.setData({ scrollIntoView: `msg-${idx}` });
this._loadingMore = false;
this._preserveMsgId = null;
} else {
this._scrollToLast(messages);
}
},
/** 派生时间分隔 / 头像 / 是否显示头像 */
_formatMessageList(list) {
const otherAvatar = this.data.otherAvatarUrl;
const result = [];
let lastTs = 0;
for (let i = 0; i < list.length; i++) {
const m = list[i];
const ts = m.sentAt || 0;
const showTime = i === 0 || (ts && ts - lastTs > TIME_GAP_MS);
result.push({
...m,
showTime,
sentAtText: showTime ? this._formatTime(ts) : '',
showAvatar: m.bubbleType === 'other',
avatarUrl: m.bubbleType === 'other' ? otherAvatar : '',
});
lastTs = ts;
}
return result;
},
_formatTime(ts) {
if (!ts) return '';
const d = new Date(ts);
const now = new Date();
const hm = d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
if (d.toDateString() === now.toDateString()) return hm;
return `${d.getMonth() + 1}/${d.getDate()} ${hm}`;
},
_scrollToLast(list) {
if (!list.length) return;
const id = `msg-${list.length - 1}`;
if (id !== this.data.scrollIntoView) {
this.setData({ scrollIntoView: id });
}
},
onBack() {
const pages = getCurrentPages();
if (pages.length > 1) {
wx.navigateBack();
} else {
wx.switchTab({ url: ROUTE.TAB.MESSAGES });
}
},
onInput(e) {
this.setData({ inputText: e.detail.value });
},
async onSend() {
const { inputText, conversationId, sending, canSend } = this.data;
if (!canSend || !inputText.trim() || sending) return;
this.setData({ sending: true, inputText: '' });
try {
await messageStore.sendMessage(conversationId, inputText.trim());
} catch (err) {
// MATCH_REQUIRED:双方互相喜欢后才能聊天(AGENTS 第 3 条,后端守门)
if (err && err.code === BUSINESS_ERROR_CODE.MATCH_REQUIRED) {
appStore.toast('双方互相喜欢后才能开始聊天');
} else {
appStore.toastError(err, '发送失败');
}
} finally {
this.setData({ sending: false });
}
},
/** 重发失败消息 */
onRetrySend(e) {
const messageId = e.currentTarget.dataset.msgId;
if (!messageId) return;
messageStore
.retrySend(this.data.conversationId, messageId)
.catch((err) => appStore.toastError(err, '重发失败'));
},
/** 滚动到顶部:加载更早消息(保持滚动位置) */
async onScrollToUpper() {
const { conversationId, hasMore, loadingMore } = this.data;
if (!hasMore || loadingMore) return;
const first = this.data.messages[0];
this._loadingMore = true;
this._preserveMsgId = first ? first.messageId : null;
this.setData({ loadingMore: true });
try {
await messageStore.loadMoreMessages(conversationId);
} catch (err) {
appStore.toastError(err);
this._loadingMore = false;
this._preserveMsgId = null;
} finally {
this.setData({ loadingMore: false });
}
},
/** 右上角更多:查看资料 / 举报 / 拉黑 */
onMore() {
const { otherUserId } = this.data;
if (!otherUserId) {
appStore.toast('暂无更多信息');
return;
}
wx.showActionSheet({
itemList: ['查看 Ta 的资料', '举报 Ta', '拉黑 Ta'],
success: (res) => {
if (res.tapIndex === 0) {
wx.navigateTo({ url: to.detail(otherUserId) });
} else if (res.tapIndex === 1) {
wx.navigateTo({ url: to.report(otherUserId) });
} else if (res.tapIndex === 2) {
this.setData({ showDangerDialog: true, dangerAction: DANGER_ACTION.BLOCK });
}
},
});
},
/** 危险动作确认(拉黑) */
async onConfirmDanger(e) {
const action = e.detail.action;
const { otherUserId, conversationId } = this.data;
this.setData({ showDangerDialog: false });
if (action !== DANGER_ACTION.BLOCK) return;
try {
appStore.showLoading('处理中…');
// 拉黑:触发 MatchStore + MessageStore 同步副作用(AGENTS 第 4 条即时停用)
await blockUser(otherUserId, '用户主动拉黑');
appStore.hideLoading();
this.setData({ canSend: false });
appStore.toastSuccess('已拉黑');
} catch (err) {
appStore.hideLoading();
appStore.toastError(err);
}
},
onCancelDanger() {
this.setData({ showDangerDialog: false });
},
});
+11
View File
@@ -0,0 +1,11 @@
{
"navigationStyle": "custom",
"navigationBarTitleText": "聊天",
"usingComponents": {
"ui-nav-bar": "/components/nav-bar/index",
"ui-message-bubble": "/components/message-bubble/index",
"ui-loading": "/components/loading/index",
"ui-button": "/components/button/index",
"ui-danger-action-dialog": "/components/danger-action-dialog/index"
}
}
+282
View File
@@ -0,0 +1,282 @@
/**
* 聊天详情页 U24(TypeScript)
*
* 不变量(AGENTS.md):
* - 第 3 条:Mutual Match 后才能聊天
* - 第 4 条:Block/Unmatch 后会话 canSendMessage=false
* - 第 6 条:单向活动意向不泄露
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { messageStore, appStore, blockUser } from '../../stores';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { messageService } from '../../services';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { to, ROUTE, DANGER_ACTION, BUSINESS_ERROR_CODE } from '../../constants';
import type { Message } from '../../types';
/** 超过该间隔则显示时间分隔(5 分钟) */
const TIME_GAP_MS = 5 * 60 * 1000;
interface ChatData {
conversationId: string;
otherDisplayName: string;
otherUserId: string;
otherAvatarUrl: string;
canSend: boolean;
messages: Array<Message & { showTime: boolean; sentAtText: string; showAvatar: boolean; avatarUrl: string }>;
hasMore: boolean;
loading: boolean;
loadingMore: boolean;
sending: boolean;
inputText: string;
scrollIntoView: string;
statusBarHeight: number;
navBarHeight: number;
showDangerDialog: boolean;
dangerAction: string;
connectionState: string;
}
Page({
data: {
conversationId: '',
otherDisplayName: '',
otherUserId: '',
otherAvatarUrl: '',
canSend: true,
messages: [],
hasMore: true,
loading: false,
loadingMore: false,
sending: false,
inputText: '',
scrollIntoView: '',
statusBarHeight: 20,
navBarHeight: 44,
showDangerDialog: false,
dangerAction: DANGER_ACTION.BLOCK,
connectionState: 'disconnected',
},
onLoad(options: Record<string, string | undefined>) {
const conversationId = options.id || '';
if (!conversationId) {
appStore.toast('会话不存在');
wx.navigateBack();
return;
}
try {
const info = (wx.getWindowInfo && wx.getWindowInfo()) || wx.getSystemInfoSync();
this.setData({
statusBarHeight: (info as any).statusBarHeight || 20,
navBarHeight: (info as any).platform === 'android' ? 48 : 44,
});
} catch { /* 默认值 */ }
this.setData({ conversationId });
this._unsubConn = messageStore.bindToPage(this, { connectionState: 'connectionState' });
this._unsubList = messageStore.subscribe((state: any) => this._syncFromStore(state));
this._unsubRealtime = messageStore.onMessage(conversationId, (msg: Message) => {
if (msg.bubbleType === 'other') {
messageStore.markConversationRead(conversationId).catch(() => {});
}
});
messageStore.openConversation(conversationId).catch((err: unknown) => {
appStore.toastError(err instanceof Error ? err.message : '加载失败');
});
this.loadConversationMeta();
},
onUnload() {
this._unsubConn && this._unsubConn();
this._unsubList && this._unsubList();
this._unsubRealtime && this._unsubRealtime();
if (this.data.conversationId) {
messageStore.closeConversation();
}
},
async loadConversationMeta() {
try {
// @ts-ignore
const conv = await messageService.getConversation(this.data.conversationId);
if (conv) {
this.setData({
otherDisplayName: (conv as any).targetDisplayName || (conv as any).otherDisplayName || '用户',
otherUserId: (conv as any).targetUserId || '',
otherAvatarUrl: (conv as any).targetAvatarUrl || '',
canSend: (conv as any).canSendMessage !== false,
});
}
} catch (err: unknown) {
const local = (messageStore.state as any).conversations?.find(
(c: any) => c.conversationId === this.data.conversationId,
);
if (local) {
this.setData({
otherDisplayName: local.targetDisplayName || '用户',
otherUserId: local.targetUserId || '',
otherAvatarUrl: local.targetAvatarUrl || '',
canSend: local.canSendMessage !== false,
});
} else {
appStore.toastError(err instanceof Error ? err.message : '加载失败');
}
}
},
_syncFromStore(state: any) {
const cid = this.data.conversationId;
const rawList: Message[] = state.messagesByConversation?.[cid] || [];
const hasMore = state.hasMoreByConversation?.[cid] !== false;
const loading = state.conversationsLoading;
const messages = this._formatMessageList(rawList);
const patch: Partial<ChatData> = { messages, hasMore };
if (loading !== this.data.loading) patch.loading = loading;
this.setData(patch);
if (this._loadingMore && this._preserveMsgId) {
const idx = messages.findIndex((m) => m.messageId === this._preserveMsgId);
if (idx >= 0) this.setData({ scrollIntoView: `msg-${idx}` });
this._loadingMore = false;
this._preserveMsgId = null;
} else {
this._scrollToLast(messages);
}
},
_formatMessageList(list: Message[]) {
const otherAvatar = this.data.otherAvatarUrl;
const result: Array<Message & { showTime: boolean; sentAtText: string; showAvatar: boolean; avatarUrl: string }> = [];
let lastTs = 0;
for (let i = 0; i < list.length; i++) {
const m = list[i];
const ts = (m as any).sentAt || 0;
const showTime = i === 0 || (ts && ts - lastTs > TIME_GAP_MS);
result.push({
...m,
showTime,
sentAtText: showTime ? this._formatTime(ts) : '',
showAvatar: m.bubbleType === 'other',
avatarUrl: m.bubbleType === 'other' ? otherAvatar : '',
});
lastTs = ts;
}
return result;
},
_formatTime(ts: number | string): string {
if (!ts) return '';
const d = new Date(Number(ts));
const now = new Date();
const hm = d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
if (d.toDateString() === now.toDateString()) return hm;
return `${d.getMonth() + 1}/${d.getDate()} ${hm}`;
},
_scrollToLast(list: typeof this.data.messages) {
if (!list.length) return;
const id = `msg-${list.length - 1}`;
if (id !== this.data.scrollIntoView) this.setData({ scrollIntoView: id });
},
onBack() {
const pages = getCurrentPages();
if (pages.length > 1) wx.navigateBack();
else wx.switchTab({ url: ROUTE.TAB.MESSAGES });
},
onInput(e: any) {
this.setData({ inputText: (e as any).detail.value });
},
async onSend() {
const { inputText, conversationId, sending, canSend } = this.data;
if (!canSend || !inputText.trim() || sending) return;
this.setData({ sending: true, inputText: '' });
try {
// @ts-ignore
await messageStore.sendMessage(conversationId, inputText.trim());
} catch (err: unknown) {
const code = (err as any)?.code;
if (code === BUSINESS_ERROR_CODE.MATCH_REQUIRED) {
appStore.toast('双方互相喜欢后才能开始聊天');
} else {
appStore.toastError(err instanceof Error ? err.message : '发送失败');
}
} finally {
this.setData({ sending: false });
}
},
onRetrySend(e: any) {
const messageId = (e.currentTarget.dataset as any).msgId as string | undefined;
if (!messageId) return;
// @ts-ignore
messageStore.retrySend(this.data.conversationId, messageId).catch((err: unknown) => {
appStore.toastError(err instanceof Error ? err.message : '重发失败');
});
},
async onScrollToUpper() {
const { conversationId, hasMore, loadingMore } = this.data;
if (!hasMore || loadingMore) return;
const first = this.data.messages[0];
this._loadingMore = true;
this._preserveMsgId = first?.messageId || null;
this.setData({ loadingMore: true });
try {
// @ts-ignore
await messageStore.loadMoreMessages(conversationId);
} catch (err: unknown) {
appStore.toastError(err instanceof Error ? err.message : '加载失败');
this._loadingMore = false;
this._preserveMsgId = null;
} finally {
this.setData({ loadingMore: false });
}
},
onMore() {
const { otherUserId } = this.data;
if (!otherUserId) { appStore.toast('暂无更多信息'); return; }
wx.showActionSheet({
itemList: ['查看 Ta 的资料', '举报 Ta', '拉黑 Ta'],
success: (res) => {
if (res.tapIndex === 0) wx.navigateTo({ url: to.detail(otherUserId) });
else if (res.tapIndex === 1) wx.navigateTo({ url: to.report(otherUserId) });
else if (res.tapIndex === 2) this.setData({ showDangerDialog: true, dangerAction: DANGER_ACTION.BLOCK });
},
});
},
async onConfirmDanger(e: WechatMiniprogram.CustomEvent) {
const action = (e.detail as any)?.action as string | undefined;
const { otherUserId } = this.data;
this.setData({ showDangerDialog: false });
if (action !== DANGER_ACTION.BLOCK) return;
try {
appStore.showLoading('处理中…');
await blockUser(otherUserId, '用户主动拉黑');
appStore.hideLoading();
this.setData({ canSend: false });
appStore.toastSuccess('已拉黑');
} catch (err: unknown) {
appStore.hideLoading();
appStore.toastError(err instanceof Error ? err.message : '操作失败');
}
},
onCancelDanger() {
this.setData({ showDangerDialog: false });
},
_unsubConn: (() => {}) as () => void,
_unsubList: (() => {}) as () => void,
_unsubRealtime: (() => {}) as () => void,
_loadingMore: false,
_preserveMsgId: null as string | null,
});
+106
View File
@@ -0,0 +1,106 @@
<!-- 聊天详情页 U24(重构:ui-nav-bar + ui-message-bubble + MessageStore) -->
<view class="page">
<!-- 顶部导航 -->
<ui-nav-bar
title="{{otherDisplayName || '聊天'}}"
status-bar-height="{{statusBarHeight}}"
nav-bar-height="{{navBarHeight}}"
bind:back="onBack"
>
<view slot="right" class="more-btn" bindtap="onMore">⋯</view>
</ui-nav-bar>
<!-- 安全提示 -->
<view class="safety-tip">
<text>请注意保护个人隐私,谨慎涉及转账、借款、投资等资金往来。</text>
</view>
<!-- 消息列表 -->
<scroll-view
scroll-y
class="msg-list"
scroll-into-view="{{scrollIntoView}}"
scroll-with-animation
bindscrolltoupper="onScrollToUpper"
upper-threshold="80"
>
<!-- 加载更早消息 -->
<ui-loading
wx:if="{{loadingMore}}"
mode="spinner"
inline
text="加载更多消息…"
/>
<!-- 消息气泡 -->
<view
wx:for="{{messages}}"
wx:key="messageId"
id="msg-{{index}}"
>
<ui-message-bubble
bubble-type="{{item.bubbleType}}"
content="{{item.content}}"
sent-at="{{item.sentAt}}"
sent-at-text="{{item.sentAtText}}"
show-time="{{item.showTime}}"
show-avatar="{{item.showAvatar}}"
avatar-url="{{item.avatarUrl}}"
send-status="{{item.sendStatus || 'sent'}}"
data-msg-id="{{item.messageId}}"
bind:retry="onRetrySend"
/>
</view>
<!-- 首次加载 -->
<ui-loading
wx:if="{{loading && messages.length === 0}}"
mode="spinner"
text="加载中…"
/>
<!-- 没有更多 -->
<view wx:if="{{!hasMore && messages.length > 0}}" class="load-more-hint">
<text>没有更多消息了</text>
</view>
</scroll-view>
<!-- 输入栏 -->
<view wx:if="{{canSend}}" class="input-bar">
<view class="input-wrapper">
<input
class="input"
placeholder="输入消息..."
value="{{inputText}}"
bindinput="onInput"
confirm-type="send"
bindconfirm="onSend"
disabled="{{sending}}"
maxlength="500"
adjust-position="{{true}}"
hold-keyboard="{{true}}"
/>
<ui-button
variant="primary"
size="sm"
disabled="{{sending || !inputText.trim()}}"
loading="{{sending}}"
bind:tap="onSend"
>发送</ui-button>
</view>
</view>
<!-- 会话已停用 -->
<view wx:elif="{{!canSend}}" class="blocked-tip">
<text>该对话已停用,无法继续发送消息</text>
</view>
<!-- 危险动作确认(拉黑) -->
<ui-danger-action-dialog
visible="{{showDangerDialog}}"
action="{{dangerAction}}"
target-name="{{otherDisplayName}}"
bind:confirm="onConfirmDanger"
bind:cancel="onCancelDanger"
/>
</view>
+78
View File
@@ -0,0 +1,78 @@
/**
* 聊天详情页样式(重构后大幅简化,气泡主体样式封装在 ui-message-bubble 内)
*/
.page {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--color-bg-page);
box-sizing: border-box;
}
/* 右上角更多按钮 */
.more-btn {
font-size: 40rpx;
color: var(--color-text-secondary);
padding: 0 var(--spacing-2);
line-height: 1;
}
/* 安全提示 */
.safety-tip {
background: var(--color-warning-light);
padding: var(--spacing-2) var(--spacing-page-x);
font-size: var(--font-size-xs);
color: var(--color-warning);
text-align: center;
line-height: var(--line-height-normal);
}
/* 消息列表 */
.msg-list {
flex: 1;
min-height: 0;
box-sizing: border-box;
padding: var(--spacing-2) 0;
}
.load-more-hint {
text-align: center;
padding: var(--spacing-2);
color: var(--color-text-tertiary);
font-size: var(--font-size-xs);
}
/* 输入栏 */
.input-bar {
background: var(--color-bg-card);
padding: var(--spacing-2) var(--spacing-page-x);
padding-bottom: calc(var(--spacing-2) + env(safe-area-inset-bottom));
border-top: var(--border-width) solid var(--color-border);
}
.input-wrapper {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.input {
flex: 1;
height: 72rpx;
background: var(--color-bg-page);
border-radius: var(--radius-full);
padding: 0 var(--spacing-3);
font-size: var(--font-size-md);
color: var(--color-text-primary);
}
/* 会话已停用 */
.blocked-tip {
background: var(--color-bg-card);
text-align: center;
padding: var(--spacing-5) var(--spacing-page-x);
padding-bottom: calc(var(--spacing-5) + env(safe-area-inset-bottom));
color: var(--color-text-tertiary);
font-size: var(--font-size-sm);
border-top: var(--border-width) solid var(--color-border);
}
+60
View File
@@ -0,0 +1,60 @@
/**
* 签到码页 U31
*/
const api = require('../../utils/api');
Page({
data: {
activityId: '',
activityTitle: '',
locationName: '',
token: '',
expiresAt: '',
loading: true,
error: ''
},
onLoad(options) {
if (options.id) {
this.setData({ activityId: options.id });
this.loadCheckinToken();
}
},
onShow() {
// 每次显示时刷新Token
if (this.data.activityId) {
this.loadCheckinToken();
}
},
async loadCheckinToken() {
this.setData({ loading: true, error: '' });
try {
const token = wx.getStorageSync('accessToken');
const res = await api.post(`/activities/${this.data.activityId}/checkin-token`, {}, {
Authorization: `Bearer ${token}`
});
this.setData({
token: res.token,
activityTitle: res.activityTitle,
locationName: res.locationName,
expiresAt: res.expiresAt,
loading: false
});
} catch (err) {
this.setData({ loading: false, error: err.message || '加载失败' });
}
},
onRefresh() {
this.loadCheckinToken();
},
copyToken() {
wx.setClipboardData({
data: this.data.token,
success: () => wx.showToast({ title: '签到码已复制', icon: 'success' })
});
}
});
+4
View File
@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "活动签到",
"usingComponents": {}
}
+41
View File
@@ -0,0 +1,41 @@
<!-- 签到码页 U31 -->
<view class="page">
<view class="header">
<text class="title">活动签到</text>
</view>
<view wx:if="{{loading}}" class="loading-state">
<text>生成签到码中...</text>
</view>
<view wx:if="{{error}}" class="error-state">
<text>{{error}}</text>
<button class="btn-refresh" bindtap="onRefresh">重试</button>
</view>
<view wx:if="{{!loading && !error}}" class="checkin-card">
<view class="activity-info">
<text class="activity-title">{{activityTitle}}</text>
<text class="activity-location">📍 {{locationName}}</text>
</view>
<view class="qr-section">
<view class="qr-placeholder">
<text class="qr-icon">▣</text>
</view>
<text class="qr-hint">请向工作人员展示此签到码</text>
</view>
<view class="token-section">
<text class="token-label">签到码</text>
<text class="token-value" selectable="true">{{token}}</text>
<button class="btn-copy" bindtap="copyToken">复制签到码</button>
</view>
<view class="expire-info">
<text>有效期:{{formatExpiresAt(expiresAt)}}</text>
</view>
</view>
<button class="btn-refresh-page" bindtap="onRefresh">刷新签到码</button>
</view>
+140
View File
@@ -0,0 +1,140 @@
.page {
padding: 32rpx;
background: #FFF8F7;
min-height: 100vh;
}
.header {
text-align: center;
margin-bottom: 32rpx;
}
.title {
font-size: 36rpx;
font-weight: bold;
color: #1A1A1A;
}
.loading-state, .error-state {
text-align: center;
padding: 80rpx;
color: #999;
}
.checkin-card {
background: #fff;
border-radius: 24rpx;
padding: 40rpx;
margin-bottom: 24rpx;
}
.activity-info {
text-align: center;
margin-bottom: 32rpx;
}
.activity-title {
display: block;
font-size: 32rpx;
font-weight: bold;
color: #1A1A1A;
margin-bottom: 8rpx;
}
.activity-location {
display: block;
font-size: 26rpx;
color: #999;
}
.qr-section {
text-align: center;
padding: 32rpx 0;
border-top: 1rpx solid #f5f5f5;
border-bottom: 1rpx solid #f5f5f5;
margin-bottom: 32rpx;
}
.qr-placeholder {
width: 300rpx;
height: 300rpx;
margin: 0 auto 20rpx;
background: #F5F5F5;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
}
.qr-icon {
font-size: 120rpx;
color: #CCC;
}
.qr-hint {
font-size: 26rpx;
color: #999;
}
.token-section {
margin-bottom: 24rpx;
}
.token-label {
font-size: 26rpx;
color: #666;
display: block;
margin-bottom: 12rpx;
}
.token-value {
display: block;
font-size: 24rpx;
color: #333;
background: #F5F5F5;
padding: 20rpx;
border-radius: 12rpx;
word-break: break-all;
margin-bottom: 16rpx;
}
.btn-copy {
width: 100%;
height: 72rpx;
line-height: 72rpx;
background: #FFF3F2;
color: #E96B6B;
border-radius: 36rpx;
font-size: 26rpx;
border: none;
}
.expire-info {
text-align: center;
font-size: 24rpx;
color: #E59A32;
padding: 16rpx;
background: #FFF8E1;
border-radius: 12rpx;
}
.btn-refresh-page {
background: #E96B6B;
color: #fff;
border-radius: 44rpx;
height: 88rpx;
line-height: 88rpx;
font-size: 30rpx;
margin-top: 24rpx;
}
.btn-refresh {
margin-top: 24rpx;
background: #E96B6B;
color: #fff;
border-radius: 44rpx;
height: 72rpx;
line-height: 72rpx;
font-size: 28rpx;
border: none;
}
@@ -0,0 +1,66 @@
// pages/counseling-intro/counseling-intro.js
Page({
/**
* 页面的初始数据
*/
data: {
},
/**
* 生命周期函数--监听页面加载
*/
onLoad(options) {
},
/**
* 生命周期函数--监听页面初次渲染完成
*/
onReady() {
},
/**
* 生命周期函数--监听页面显示
*/
onShow() {
},
/**
* 生命周期函数--监听页面隐藏
*/
onHide() {
},
/**
* 生命周期函数--监听页面卸载
*/
onUnload() {
},
/**
* 页面相关事件处理函数--监听用户下拉动作
*/
onPullDownRefresh() {
},
/**
* 页面上拉触底事件的处理函数
*/
onReachBottom() {
},
/**
* 用户点击右上角分享
*/
onShareAppMessage() {
}
})
@@ -0,0 +1,4 @@
{
"usingComponents": {},
"navigationBarTitleText": "公益婚恋咨询"
}
@@ -0,0 +1,30 @@
Page({
data: {
services: [] as Array<{ type: string; name: string; description: string; icon: string }>
},
onLoad() {
this.loadServices();
},
async loadServices() {
try {
const res = await wx.request({
url: `${app.globalData.apiBase}/counseling/services/intro`,
header: { 'Authorization': `Bearer ${app.globalData.token}` }
});
if (res.statusCode === 200 && res.data.success) {
this.setData({ services: res.data.data });
}
} catch (e) {
console.error('加载咨询介绍失败', e);
}
},
onSelectService(e: any) {
const type = e.currentTarget.dataset.type;
wx.navigateTo({
url: `/pages/counseling-request/counseling-request?type=${type}`
});
}
});
@@ -0,0 +1,21 @@
<view class="container">
<view class="header">
<text class="title">公益婚恋咨询</text>
<text class="subtitle">专业红娘为您提供情感支持</text>
</view>
<view class="service-list">
<view class="service-item" wx:for="{{services}}" wx:key="type" bindtap="onSelectService" data-type="{{item.type}}">
<view class="service-icon">{{item.icon}}</view>
<view class="service-info">
<text class="service-name">{{item.name}}</text>
<text class="service-desc">{{item.description}}</text>
</view>
<text class="arrow">›</text>
</view>
</view>
<view class="notice">
<text>所有咨询服务均为公益免费,无需支付任何费用。</text>
</view>
</view>
@@ -0,0 +1,14 @@
.container { padding: 24rpx; }
.header { text-align: center; margin-bottom: 32rpx; }
.title { font-size: 36rpx; font-weight: bold; color: #333; }
.subtitle { font-size: 26rpx; color: #888; margin-top: 8rpx; display: block; }
.service-list { background: #fff; border-radius: 16rpx; overflow: hidden; }
.service-item { display: flex; align-items: center; padding: 28rpx 24rpx; border-bottom: 1rpx solid #f0f0f0; }
.service-item:last-child { border-bottom: none; }
.service-icon { font-size: 40rpx; margin-right: 20rpx; }
.service-info { flex: 1; }
.service-name { font-size: 30rpx; color: #333; display: block; }
.service-desc { font-size: 24rpx; color: #999; margin-top: 6rpx; display: block; }
.arrow { font-size: 36rpx; color: #ccc; }
.notice { margin-top: 24rpx; padding: 20rpx; background: #fff8e1; border-radius: 12rpx; text-align: center; }
.notice text { font-size: 24rpx; color: #f57c00; }
@@ -0,0 +1,66 @@
// pages/counseling-request/counseling-request.js
Page({
/**
* 页面的初始数据
*/
data: {
},
/**
* 生命周期函数--监听页面加载
*/
onLoad(options) {
},
/**
* 生命周期函数--监听页面初次渲染完成
*/
onReady() {
},
/**
* 生命周期函数--监听页面显示
*/
onShow() {
},
/**
* 生命周期函数--监听页面隐藏
*/
onHide() {
},
/**
* 生命周期函数--监听页面卸载
*/
onUnload() {
},
/**
* 页面相关事件处理函数--监听用户下拉动作
*/
onPullDownRefresh() {
},
/**
* 页面上拉触底事件的处理函数
*/
onReachBottom() {
},
/**
* 用户点击右上角分享
*/
onShareAppMessage() {
}
})
@@ -0,0 +1,4 @@
{
"usingComponents": {},
"navigationBarTitleText": "申请咨询"
}
@@ -0,0 +1,52 @@
const app = getApp<{ globalData: { apiBase: string; token: string } }>();
Page({
data: {
serviceType: '',
serviceTypeLabel: '',
modes: ['线上视频', '线下面谈', '电话沟通'],
pModeIndex: 0,
preferredTime: '',
summary: '',
submitting: false
},
onLoad(options: any) {
if (options.type) {
const typeMap: Record<string, string> = {
relationship_guidance: '恋爱困惑指导',
emotional_support: '情感支持',
premarital_guidance: '婚前辅导',
anti_fraud_guidance: '婚恋反诈指导'
};
this.setData({ serviceType: options.type, serviceTypeLabel: typeMap[options.type] || options.type });
}
},
onModeChange(e: any) {
this.setData({ pModeIndex: e.detail.value });
},
onTimeInput(e: any) { this.setData({ preferredTime: e.detail.value }); },
onSummaryInput(e: any) { this.setData({ summary: e.detail.value }); },
async onSubmit() {
const { serviceType, pModeIndex, preferredTime, summary } = this.data;
if (!serviceType) return wx.showToast({ title: '请选择服务类型', icon: 'none' });
this.setData({ submitting: true });
try {
await wx.request({
url: `${app.globalData.apiBase}/counseling/services`,
method: 'POST',
header: { 'Authorization': `Bearer ${app.globalData.token}`, 'Content-Type': 'application/json' },
data: { serviceType, preferredServiceMode: this.data.modes[pModeIndex], preferredTime, userRequestSummary: summary }
});
wx.showToast({ title: '申请已提交', icon: 'success' });
setTimeout(() => wx.redirectTo({ url: '/pages/my-counseling/my-counseling' }), 1500);
} catch (e) {
wx.showToast({ title: '提交失败,请重试', icon: 'none' });
} finally {
this.setData({ submitting: false });
}
}
});
@@ -0,0 +1,25 @@
<view class="container">
<view class="form-section">
<view class="form-group">
<text class="label">服务类型</text>
<text class="value">{{serviceTypeLabel}}</text>
</view>
<view class="form-group">
<text class="label">沟通方式</text>
<picker mode="selector" range="{{modes}}" bindchange="onModeChange">
<view class="picker-value">{{modes[pModeIndex] || '请选择'}}</view>
</picker>
</view>
<view class="form-group">
<text class="label">期望时间</text>
<input class="input" placeholder="如:工作日晚上7点后" value="{{preferredTime}}" bindinput="onTimeInput" />
</view>
<view class="form-group">
<text class="label">问题描述</text>
<textarea class="textarea" placeholder="请简要描述您的需求(选填)" value="{{summary}}" bindinput="onSummaryInput" maxlength="500" />
</view>
</view>
<button class="submit-btn" bindtap="onSubmit" loading="{{submitting}}">提交申请</button>
<text class="tip">提交后工作人员将在1-3个工作日内与您联系</text>
</view>
@@ -0,0 +1,11 @@
.container { padding: 24rpx; }
.form-section { background: #fff; border-radius: 16rpx; padding: 24rpx; }
.form-group { padding: 20rpx 0; border-bottom: 1rpx solid #f5f5f5; }
.form-group:last-child { border-bottom: none; }
.label { font-size: 28rpx; color: #666; display: block; margin-bottom: 12rpx; }
.value { font-size: 30rpx; color: #333; }
.picker-value { font-size: 30rpx; color: #333; }
.input { font-size: 30rpx; color: #333; }
.textarea { font-size: 30rpx; color: #333; width: 100%; height: 160rpx; }
.submit-btn { margin-top: 40rpx; background: linear-gradient(135deg, #FF6B6B, #EE5A24); color: #fff; border: none; border-radius: 44rpx; font-size: 32rpx; }
.tip { display: block; text-align: center; font-size: 24rpx; color: #999; margin-top: 20rpx; }
+237
View File
@@ -0,0 +1,237 @@
/**
* 找对象页 U19-U20(重构:用 ui-user-card + matchStore.like + recommendationService.discover)
*
* 不变量(AGENTS.md):
* - 第 2 条:Required 不可自动放宽——Discover 搜索仅透传筛选条件到后端,
* 由后端按 PartnerPreference 做硬约束过滤,前端不做任何"放宽再查"。
* - 第 4 条:Block 即时禁止互动——本页 Like 后由 matchStore 从列表移除。
* - 第 6 条:单向意向不泄露——Pass/Like 未 Mutual 时对方不收到通知。
* - 第 9 条:PostgreSQL 是 Source of Truth——搜索结果不在 Store 持久化,
* 列表状态为页面局部状态(BaseStore 原则 2)。
*/
const { matchStore, appStore, authStore } = require('../../stores');
const { recommendationService } = require('../../services');
const { to } = require('../../constants');
const DEFAULT_AGE_MIN = 18;
const DEFAULT_AGE_MAX = 60;
const PAGE_SIZE = 20;
Page({
data: {
users: [],
loading: false,
loadingMore: false,
cursor: null,
hasMore: true,
// 搜索栏关键词(编辑中)
keywordInput: '',
showFilter: false,
// 临时筛选(编辑中)
tempRegion: '',
tempAgeMin: DEFAULT_AGE_MIN,
tempAgeMax: DEFAULT_AGE_MAX,
// 已应用筛选
applied: { keyword: '', region: '', ageMin: DEFAULT_AGE_MIN, ageMax: DEFAULT_AGE_MAX },
hasActiveFilter: false,
regions: ['丹阳', '镇江', '南京'],
actionLoadingUserId: '',
emptyActions: [
{ text: '清空筛选', event: 'reset', variant: 'primary' },
{ text: '去今日推荐', event: 'home', variant: 'secondary' },
],
},
onLoad() {
if (!authStore.state.isLoggedIn) return;
this.fetchDiscover(true);
},
/** 拉取搜索结果(首页) */
async fetchDiscover(first) {
if (this.data.loading) return;
this.setData({ loading: true });
try {
const result = await recommendationService.discover(this._buildParams());
const items = result.items || [];
this.setData({
users: first ? items : [...this.data.users, ...items],
cursor: result.nextCursor || null,
hasMore: !!result.nextCursor,
loading: false,
});
} catch (err) {
this.setData({ loading: false });
appStore.toastError(err);
}
},
/** 加载更多 */
async loadMore() {
if (this.data.loadingMore || !this.data.hasMore || !this.data.cursor) return;
this.setData({ loadingMore: true });
try {
const result = await recommendationService.discover({
...this._buildParams(),
cursor: this.data.cursor,
});
const items = result.items || [];
this.setData({
users: [...this.data.users, ...items],
cursor: result.nextCursor || null,
hasMore: !!result.nextCursor,
});
} catch (err) {
appStore.toastError(err);
} finally {
this.setData({ loadingMore: false });
}
},
/** 构造请求参数(仅透传,后端守 Required 硬约束) */
_buildParams() {
const { applied } = this.data;
const params = { pageSize: PAGE_SIZE };
if (applied.keyword) params.keyword = applied.keyword;
if (applied.region) params.region = applied.region;
if (applied.ageMin !== DEFAULT_AGE_MIN) params.ageMin = applied.ageMin;
if (applied.ageMax !== DEFAULT_AGE_MAX) params.ageMax = applied.ageMax;
return params;
},
// ============ 搜索栏 ============
onKeywordInput(e) {
this.setData({ keywordInput: e.detail.value });
},
/** 关键词搜索(键盘确认或点搜索图标) */
onSearch() {
const keyword = (this.data.keywordInput || '').trim();
const applied = { ...this.data.applied, keyword };
this.setData({
applied,
hasActiveFilter: this._hasActiveFilter(applied),
});
this.fetchDiscover(true);
},
// ============ 筛选面板 ============
toggleFilter() {
this.setData({ showFilter: !this.data.showFilter });
},
onRegionTap(e) {
const val = e.currentTarget.dataset.val;
this.setData({ tempRegion: this.data.tempRegion === val ? '' : val });
},
onMinAgeChange(e) {
let min = e.detail.value;
if (min > this.data.tempAgeMax) min = this.data.tempAgeMax;
this.setData({ tempAgeMin: min });
},
onMaxAgeChange(e) {
let max = e.detail.value;
if (max < this.data.tempAgeMin) max = this.data.tempAgeMin;
this.setData({ tempAgeMax: max });
},
onApplyFilter() {
const { tempRegion, tempAgeMin, tempAgeMax, keywordInput } = this.data;
const applied = {
keyword: (keywordInput || '').trim(),
region: tempRegion,
ageMin: tempAgeMin,
ageMax: tempAgeMax,
};
this.setData({
applied,
hasActiveFilter: this._hasActiveFilter(applied),
showFilter: false,
});
this.fetchDiscover(true);
},
onResetFilter() {
const applied = { keyword: '', region: '', ageMin: DEFAULT_AGE_MIN, ageMax: DEFAULT_AGE_MAX };
this.setData({
keywordInput: '',
tempRegion: '',
tempAgeMin: DEFAULT_AGE_MIN,
tempAgeMax: DEFAULT_AGE_MAX,
applied,
hasActiveFilter: false,
showFilter: false,
});
this.fetchDiscover(true);
},
_hasActiveFilter(applied) {
return !!(
applied.keyword ||
applied.region ||
applied.ageMin !== DEFAULT_AGE_MIN ||
applied.ageMax !== DEFAULT_AGE_MAX
);
},
onEmptyAction(e) {
const { event } = e.detail || {};
if (event === 'reset') {
this.onResetFilter();
} else if (event === 'home') {
wx.switchTab({ url: '/pages/index/index' });
}
},
// ============ 卡片操作 ============
/** 卡片点击:查看资料 */
onViewDetail(e) {
const { userId } = e.detail || {};
if (!userId) return;
wx.navigateTo({ url: to.detail(userId) });
},
/** 喜欢(actions slot 按钮,catchtap 阻止冒泡到卡片) */
async onLike(e) {
const userId = e.currentTarget.dataset.userId;
if (!userId || this.data.actionLoadingUserId) return;
this.setData({ actionLoadingUserId: userId });
try {
const result = await matchStore.like(userId);
// 从列表移除(无论是否 Mutual,已做出判断)
this.setData({ users: this.data.users.filter((u) => u.userId !== userId) });
if (result && result.mutualMatch) {
appStore.toastSuccess('双向喜欢!可以开始聊天了');
if (result.matchedConversationId) {
wx.navigateTo({ url: to.chat(result.matchedConversationId) });
}
} else {
appStore.toastSuccess('已表达喜欢');
}
} catch (err) {
appStore.toastError(err);
} finally {
this.setData({ actionLoadingUserId: '' });
}
},
// ============ 列表交互 ============
onPullDownRefresh() {
this.fetchDiscover(true).finally(() => wx.stopPullDownRefresh());
},
onReachBottom() {
this.loadMore();
},
});
+13
View File
@@ -0,0 +1,13 @@
{
"navigationBarTitleText": "找对象",
"enablePullDownRefresh": true,
"backgroundColor": "#F7F8FA",
"backgroundTextStyle": "dark",
"usingComponents": {
"ui-user-card": "/components/user-card/index",
"ui-button": "/components/button/index",
"ui-loading": "/components/loading/index",
"ui-empty": "/components/empty/index",
"ui-section-header": "/components/section-header/index"
}
}
+214
View File
@@ -0,0 +1,214 @@
/**
* 找对象页 U19-U20(TypeScript)
*
* 不变量(AGENTS.md):
* - 第 2 条:Required 不可自动放宽——Discover 搜索仅透传筛选条件到后端
* - 第 4 条:Block 即时禁止互动
* - 第 6 条:单向意向不泄露
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { matchStore, appStore, authStore } from '../../stores';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { recommendationService } from '../../services';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { to } from '../../constants';
import type { RecommendationCandidate } from '../../types';
interface DiscoverData {
users: RecommendationCandidate[];
loading: boolean;
loadingMore: boolean;
cursor: string | null;
hasMore: boolean;
keywordInput: string;
showFilter: boolean;
tempRegion: string;
tempAgeMin: number;
tempAgeMax: number;
applied: { keyword: string; region: string; ageMin: number; ageMax: number };
hasActiveFilter: boolean;
regions: string[];
actionLoadingUserId: string;
emptyActions: Array<{ text: string; event: string; variant: string }>;
}
const DEFAULT_AGE_MIN = 18;
const DEFAULT_AGE_MAX = 60;
const PAGE_SIZE = 20;
Page({
data: {
users: [],
loading: false,
loadingMore: false,
cursor: null,
hasMore: true,
keywordInput: '',
showFilter: false,
tempRegion: '',
tempAgeMin: DEFAULT_AGE_MIN,
tempAgeMax: DEFAULT_AGE_MAX,
applied: { keyword: '', region: '', ageMin: DEFAULT_AGE_MIN, ageMax: DEFAULT_AGE_MAX },
hasActiveFilter: false,
regions: ['丹阳', '镇江', '南京'],
actionLoadingUserId: '',
emptyActions: [
{ text: '清空筛选', event: 'reset', variant: 'primary' },
{ text: '去今日推荐', event: 'home', variant: 'secondary' },
],
},
onLoad() {
if (!authStore.state.isLoggedIn) return;
this.fetchDiscover(true);
},
async fetchDiscover(first: boolean) {
if (this.data.loading) return;
this.setData({ loading: true });
try {
// @ts-ignore - recommendationService 接口待 TypeScript 化
const result = await recommendationService.discover(this._buildParams());
const items: RecommendationCandidate[] = (result?.items as RecommendationCandidate[]) || [];
this.setData({
users: first ? items : [...this.data.users, ...items],
cursor: result?.nextCursor || null,
hasMore: !!result?.nextCursor,
loading: false,
});
} catch (err: unknown) {
this.setData({ loading: false });
appStore.toastError(err instanceof Error ? err.message : '加载失败');
}
},
async loadMore() {
if (this.data.loadingMore || !this.data.hasMore || !this.data.cursor) return;
this.setData({ loadingMore: true });
try {
// @ts-ignore
const result = await recommendationService.discover({
...this._buildParams(),
cursor: this.data.cursor,
});
const items: RecommendationCandidate[] = (result?.items as RecommendationCandidate[]) || [];
this.setData({
users: [...this.data.users, ...items],
cursor: result?.nextCursor || null,
hasMore: !!result?.nextCursor,
});
} catch (err: unknown) {
appStore.toastError(err instanceof Error ? err.message : '加载失败');
} finally {
this.setData({ loadingMore: false });
}
},
_buildParams() {
const { applied } = this.data;
const params: Record<string, unknown> = { pageSize: PAGE_SIZE };
if (applied.keyword) params.keyword = applied.keyword;
if (applied.region) params.region = applied.region;
if (applied.ageMin !== DEFAULT_AGE_MIN) params.ageMin = applied.ageMin;
if (applied.ageMax !== DEFAULT_AGE_MAX) params.ageMax = applied.ageMax;
return params;
},
onKeywordInput(e: any) {
this.setData({ keywordInput: e.detail.value });
},
onSearch() {
const keyword = (this.data.keywordInput || '').trim();
const applied = { ...this.data.applied, keyword };
this.setData({ applied, hasActiveFilter: this._hasActiveFilter(applied) });
this.fetchDiscover(true);
},
toggleFilter() {
this.setData({ showFilter: !this.data.showFilter });
},
onRegionTap(e: WechatMiniprogram.TouchEvent) {
const val = (e.currentTarget.dataset as any).val as string;
this.setData({ tempRegion: this.data.tempRegion === val ? '' : val });
},
onMinAgeChange(e: any) {
let min = Number((e as any).detail.value);
if (min > this.data.tempAgeMax) min = this.data.tempAgeMax;
this.setData({ tempAgeMin: min });
},
onMaxAgeChange(e: any) {
let max = Number((e as any).detail.value);
if (max < this.data.tempAgeMin) max = this.data.tempAgeMin;
this.setData({ tempAgeMax: max });
},
onApplyFilter() {
const { tempRegion, tempAgeMin, tempAgeMax, keywordInput } = this.data;
const applied = { keyword: (keywordInput || '').trim(), region: tempRegion, ageMin: tempAgeMin, ageMax: tempAgeMax };
this.setData({ applied, hasActiveFilter: this._hasActiveFilter(applied), showFilter: false });
this.fetchDiscover(true);
},
onResetFilter() {
const applied = { keyword: '', region: '', ageMin: DEFAULT_AGE_MIN, ageMax: DEFAULT_AGE_MAX };
this.setData({
keywordInput: '', tempRegion: '', tempAgeMin: DEFAULT_AGE_MIN, tempAgeMax: DEFAULT_AGE_MAX,
applied, hasActiveFilter: false, showFilter: false,
});
this.fetchDiscover(true);
},
_hasActiveFilter(applied: { keyword: string; region: string; ageMin: number; ageMax: number }) {
return !!(
applied.keyword || applied.region ||
applied.ageMin !== DEFAULT_AGE_MIN || applied.ageMax !== DEFAULT_AGE_MAX
);
},
onEmptyAction(e: WechatMiniprogram.TouchEvent) {
const { event } = (e.detail as any) || {};
if (event === 'reset') this.onResetFilter();
else if (event === 'home') wx.switchTab({ url: '/pages/index/index' });
},
onViewDetail(e: WechatMiniprogram.TouchEvent) {
const { userId } = (e.detail as any) || {};
if (!userId) return;
wx.navigateTo({ url: to.detail(userId) });
},
async onLike(e: WechatMiniprogram.TouchEvent) {
const userId = (e.currentTarget.dataset as any).userId as string | undefined;
if (!userId || this.data.actionLoadingUserId) return;
this.setData({ actionLoadingUserId: userId });
try {
// @ts-ignore
const result = await matchStore.like(userId);
this.setData({ users: this.data.users.filter((u) => u.userId !== userId) });
if (result?.mutualMatch) {
appStore.toastSuccess('双向喜欢!可以开始聊天了');
if (result.matchedConversationId) {
wx.navigateTo({ url: to.chat(result.matchedConversationId) });
}
} else {
appStore.toastSuccess('已表达喜欢');
}
} catch (err: unknown) {
appStore.toastError(err instanceof Error ? err.message : '操作失败');
} finally {
this.setData({ actionLoadingUserId: '' });
}
},
onPullDownRefresh() {
this.fetchDiscover(true).finally(() => wx.stopPullDownRefresh());
},
onReachBottom() {
this.loadMore();
},
});
+131
View File
@@ -0,0 +1,131 @@
<!-- 找对象页 U19-U20(重构:ui-user-card + matchStore + recommendationService.discover) -->
<view class="page">
<!-- 搜索栏 -->
<view class="search-bar">
<view class="search-input">
<text class="search-icon">🔍</text>
<input
class="search-field"
placeholder="搜索符合条件的对象"
placeholder-class="search-placeholder"
value="{{keywordInput}}"
bindinput="onKeywordInput"
confirm-type="search"
bindconfirm="onSearch"
/>
</view>
<view class="filter-btn {{showFilter ? 'active' : ''}} {{hasActiveFilter ? 'has-filter' : ''}}" bindtap="toggleFilter">
<text>筛选</text>
<view wx:if="{{hasActiveFilter}}" class="filter-dot"></view>
</view>
</view>
<!-- 筛选面板 -->
<view wx:if="{{showFilter}}" class="filter-panel">
<view class="filter-section">
<text class="filter-label">年龄范围</text>
<view class="range-row">
<text class="range-value">{{tempAgeMin}} 岁</text>
<slider
class="range-slider"
min="{{18}}"
max="{{60}}"
value="{{tempAgeMin}}"
activeColor="{{'#E96B6B'}}"
backgroundColor="{{'#F0F0F0'}}"
block-size="{{20}}"
bindchange="onMinAgeChange"
/>
<text class="range-value">{{tempAgeMax}} 岁</text>
</view>
<slider
class="range-slider"
min="{{18}}"
max="{{60}}"
value="{{tempAgeMax}}"
activeColor="{{'#E96B6B'}}"
backgroundColor="{{'#F0F0F0'}}"
block-size="{{20}}"
bindchange="onMaxAgeChange"
/>
</view>
<view class="filter-section">
<text class="filter-label">地区</text>
<view class="tag-group">
<view
wx:for="{{regions}}"
wx:key="*this"
class="tag {{tempRegion === item ? 'selected' : ''}}"
bindtap="onRegionTap"
data-val="{{item}}"
>{{item}}</view>
</view>
</view>
<view class="filter-actions">
<ui-button variant="ghost" size="md" block bind:tap="onResetFilter">重置</ui-button>
<ui-button variant="primary" size="md" block bind:tap="onApplyFilter">应用筛选</ui-button>
</view>
</view>
<!-- 区块标题 -->
<ui-section-header
wx:if="{{users.length > 0 || loading}}"
title="找对象"
subtitle="{{hasActiveFilter ? '已应用筛选' : '共 ' + users.length + ' 位'}}"
/>
<!-- 首次加载(骨架屏) -->
<ui-loading
wx:if="{{loading && users.length === 0}}"
mode="skeleton"
rows="5"
/>
<!-- 用户列表 -->
<block wx:elif="{{users.length > 0}}">
<view class="user-list">
<ui-user-card
wx:for="{{users}}"
wx:key="userId"
user="{{item}}"
bind:tap="onViewDetail"
>
<view
slot="actions"
class="card-action"
catchtap="onLike"
data-user-id="{{item.userId}}"
>
<ui-button
variant="primary"
size="sm"
loading="{{actionLoadingUserId === item.userId}}"
>喜欢</ui-button>
</view>
</ui-user-card>
<!-- 加载更多 -->
<ui-loading
wx:if="{{loadingMore}}"
mode="spinner"
inline
text="加载更多…"
/>
<view wx:if="{{!hasMore && users.length > 0}}" class="list-end-hint">
<text>没有更多了</text>
</view>
</view>
</block>
<!-- 空状态 -->
<ui-empty
wx:elif="{{!loading}}"
illustration="search"
title="暂无符合条件的人选"
description="尝试调整筛选条件,或看看今天为你推荐的人。"
actions="{{emptyActions}}"
bind:action="onEmptyAction"
/>
</view>
+157
View File
@@ -0,0 +1,157 @@
/**
* 找对象页样式(重构后大幅简化,用户卡片主体样式封装在 ui-user-card 内)
*/
.page {
min-height: 100vh;
background: var(--color-bg-page);
}
/* ============ 搜索栏 ============ */
.search-bar {
display: flex;
align-items: center;
padding: var(--spacing-3) var(--spacing-page-x);
background: var(--color-bg-card);
gap: var(--spacing-2);
border-bottom: var(--border-width) solid var(--color-divider);
}
.search-input {
flex: 1;
height: 72rpx;
background: var(--color-bg-page);
border-radius: var(--radius-full);
display: flex;
align-items: center;
padding: 0 var(--spacing-3);
gap: var(--spacing-2);
}
.search-icon {
font-size: var(--font-size-md);
color: var(--color-text-placeholder);
}
.search-field {
flex: 1;
font-size: var(--font-size-md);
color: var(--color-text-primary);
}
.search-placeholder {
color: var(--color-text-placeholder);
font-size: var(--font-size-md);
}
.filter-btn {
min-width: 120rpx;
height: 72rpx;
background: var(--color-primary-light);
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-sm);
color: var(--color-primary);
position: relative;
padding: 0 var(--spacing-3);
}
.filter-btn.active {
background: var(--color-primary);
color: #FFFFFF;
}
.filter-dot {
width: 12rpx;
height: 12rpx;
background: var(--color-primary);
border-radius: 50%;
position: absolute;
top: 12rpx;
right: 12rpx;
}
.filter-btn.active .filter-dot {
background: #FFFFFF;
}
/* ============ 筛选面板 ============ */
.filter-panel {
background: var(--color-bg-card);
padding: var(--spacing-4) var(--spacing-page-x);
border-bottom: var(--border-width) solid var(--color-divider);
}
.filter-section {
margin-bottom: var(--spacing-4);
}
.filter-label {
font-size: var(--font-size-md);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
display: block;
margin-bottom: var(--spacing-2);
}
.range-row {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.range-value {
min-width: 96rpx;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
text-align: center;
}
.range-slider {
flex: 1;
}
.tag-group {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
}
.tag {
padding: var(--spacing-1) var(--spacing-3);
border-radius: var(--radius-full);
font-size: var(--font-size-sm);
background: var(--color-bg-page);
color: var(--color-text-secondary);
border: 2rpx solid transparent;
}
.tag.selected {
background: var(--color-primary);
color: #FFFFFF;
}
.filter-actions {
display: flex;
gap: var(--spacing-3);
margin-top: var(--spacing-2);
}
/* ============ 用户列表 ============ */
.user-list {
background: var(--color-bg-card);
margin-top: var(--spacing-2);
}
.card-action {
display: flex;
align-items: center;
}
.list-end-hint {
text-align: center;
padding: var(--spacing-3);
color: var(--color-text-tertiary);
font-size: var(--font-size-xs);
}
+190
View File
@@ -0,0 +1,190 @@
/**
* 我的缘分页 U22(重构:用 matchStore + ui-user-card + ui-nav-bar)
*
* 3 Tab:互相喜欢(Mutual)/ 我喜欢的 / 喜欢我的(脱敏)
* 游标分页:下拉刷新 + onReachBottom 加载更多
* like-back:喜欢我的人 → 表达喜欢 → 若 Mutual 自动跳聊天
*
* 不变量(AGENTS.md):
* - 第 3 条:Mutual Match 后才能聊天——Mutual Tab 点击进入会话;未 Mutual 不可发起会话。
* - 第 6 条:单向意向不泄露——"喜欢我的"仅展示后端返回的脱敏字段(昵称/年龄/地区/是否已 Mutual),
* Store 与页面均不做二次身份还原。
* - 第 5 条:红娘不可替用户 Like——like-back 仅当前用户本人操作。
* - 第 9 条:PostgreSQL 是 Source of Truth——列表状态以 matchStore 同步服务端返回为准。
*/
const { matchStore, appStore, likeUser } = require('../../stores');
const { to, ROUTE } = require('../../constants');
const TAB_MUTUAL = 'mutual';
const TAB_LIKED = 'liked';
const TAB_LIKERS = 'likers';
const PAGE_SIZE = 20;
Page({
data: {
activeTab: TAB_MUTUAL,
// 来自 matchStore 的列表(bindToPage 同步)
mutualList: [],
mutualHasMore: true,
likedList: [],
likedHasMore: true,
likersList: [],
likersHasMore: true,
likersCount: 0,
// 页面局部状态
loading: false,
loadingMore: false,
actionLoadingUserId: '',
statusBarHeight: 20,
navBarHeight: 44,
},
onLoad() {
try {
const info = (wx.getWindowInfo && wx.getWindowInfo()) || wx.getSystemInfoSync();
this.setData({
statusBarHeight: info.statusBarHeight || 20,
navBarHeight: info.platform === 'android' ? 48 : 44,
});
} catch (e) { /* 使用默认值 */ }
// 绑定 matchStore 列表字段到页面
this._unsub = matchStore.bindToPage(this, {
mutualList: 'mutualList',
mutualHasMore: 'mutualHasMore',
likedList: 'likedList',
likedHasMore: 'likedHasMore',
likersList: 'likersList',
likersHasMore: 'likersHasMore',
likersCount: 'likersCount',
});
this._fetchTab(TAB_MUTUAL, true);
},
onUnload() {
if (this._unsub) this._unsub();
},
onShow() {
// 返回页面时刷新当前 Tab(状态可能已变化)
this._fetchTab(this.data.activeTab, true);
},
// ============ Tab 切换 ============
onTabChange(e) {
const tab = e.currentTarget.dataset.tab;
if (!tab || tab === this.data.activeTab) return;
this.setData({ activeTab: tab });
this._fetchTab(tab, true);
},
/** 按当前 Tab 拉取首页 */
async _fetchTab(tab, first) {
if (this.data.loading) return;
this.setData({ loading: true });
try {
const params = { pageSize: PAGE_SIZE };
if (tab === TAB_MUTUAL) {
await matchStore.fetchMutualList(params);
} else if (tab === TAB_LIKED) {
await matchStore.fetchLikedList(params);
} else if (tab === TAB_LIKERS) {
await matchStore.fetchLikersList(params);
}
} catch (err) {
appStore.toastError(err);
} finally {
this.setData({ loading: false });
}
},
// ============ 列表交互 ============
onPullDownRefresh() {
this._fetchTab(this.data.activeTab, true).finally(() => wx.stopPullDownRefresh());
},
onReachBottom() {
if (this.data.loadingMore) return;
const tab = this.data.activeTab;
const hasMore = tab === TAB_MUTUAL ? this.data.mutualHasMore
: tab === TAB_LIKED ? this.data.likedHasMore
: this.data.likersHasMore;
if (!hasMore) return;
this.setData({ loadingMore: true });
(async () => {
try {
if (tab === TAB_MUTUAL) await matchStore.loadMoreMutualList();
else if (tab === TAB_LIKED) await matchStore.loadMoreLikedList();
else await matchStore.loadMoreLikersList();
} catch (err) {
appStore.toastError(err);
} finally {
this.setData({ loadingMore: false });
}
})();
},
// ============ 卡片操作 ============
/**
* 互相喜欢 / 我喜欢的 → 查看资料详情
* 互相喜欢还可直接进入会话(已 Mutual,允许聊天)
*/
onViewDetail(e) {
const userId = e.detail && e.detail.userId;
if (!userId) return;
wx.navigateTo({ url: to.detail(userId) });
},
/** 互相喜欢 → 开始聊天(已 Mutual,符合第 3 条) */
onStartChat(e) {
const conversationId = e.currentTarget.dataset.conversationId;
if (!conversationId) return;
wx.navigateTo({ url: to.chat(conversationId) });
},
/**
* 喜欢我的人 → 喜欢 TA(like-back)
* 若达成 Mutual,likeUser 会自动刷新会话列表,此处跳转到聊天
*/
async onLikeBack(e) {
const userId = e.currentTarget.dataset.userId;
if (!userId || this.data.actionLoadingUserId) return;
this.setData({ actionLoadingUserId: userId });
try {
const result = await likeUser(userId);
// 从"喜欢我的"列表移除(已做出判断)
matchStore.setState({
likersList: matchStore.state.likersList.filter((u) => u.userId !== userId),
});
if (result && result.mutualMatch) {
appStore.toastSuccess('双向喜欢!可以开始聊天了');
// 刷新互相喜欢列表
try { await matchStore.fetchMutualList({ pageSize: PAGE_SIZE }); } catch (e2) { /* ignore */ }
if (result.matchedConversationId) {
wx.navigateTo({ url: to.chat(result.matchedConversationId) });
}
} else {
appStore.toastSuccess('已表达喜欢');
}
} catch (err) {
appStore.toastError(err);
} finally {
this.setData({ actionLoadingUserId: '' });
}
},
onBack() {
wx.navigateBack();
},
/** 空状态动作 */
onEmptyAction() {
wx.switchTab({ url: ROUTE.TAB.DISCOVER });
},
});
@@ -0,0 +1,13 @@
{
"navigationStyle": "custom",
"navigationBarTitleText": "我的缘分",
"enablePullDownRefresh": true,
"backgroundColor": "#FFF8F7",
"backgroundTextStyle": "dark",
"usingComponents": {
"ui-nav-bar": "/components/nav-bar/index",
"ui-user-card": "/components/user-card/index",
"ui-loading": "/components/loading/index",
"ui-empty": "/components/empty/index"
}
}
+105
View File
@@ -0,0 +1,105 @@
<!-- 我的缘分页 U22(重构:ui-nav-bar + ui-user-card + matchStore) -->
<view class="page">
<ui-nav-bar
title="我的缘分"
show-back="{{true}}"
status-bar-height="{{statusBarHeight}}"
nav-bar-height="{{navBarHeight}}"
bind:back="onBack"
/>
<!-- Tab 切换 -->
<view class="tabs" style="top: {{statusBarHeight + navBarHeight}}px;">
<view class="tab {{activeTab === 'mutual' ? 'active' : ''}}" data-tab="mutual" bindtap="onTabChange">
互相喜欢
<text class="tab-count" wx:if="{{mutualList.length > 0}}">{{mutualList.length}}</text>
</view>
<view class="tab {{activeTab === 'liked' ? 'active' : ''}}" data-tab="liked" bindtap="onTabChange">
我喜欢的
<text class="tab-count" wx:if="{{likedList.length > 0}}">{{likedList.length}}</text>
</view>
<view class="tab {{activeTab === 'likers' ? 'active' : ''}}" data-tab="likers" bindtap="onTabChange">
喜欢我的
<text class="tab-count" wx:if="{{likersCount > 0}}">{{likersCount}}</text>
</view>
</view>
<view class="list-wrap">
<!-- 首次加载 -->
<ui-loading wx:if="{{loading}}" mode="skeleton" rows="4" />
<block wx:elif="{{activeTab === 'mutual'}}">
<block wx:if="{{mutualList.length > 0}}">
<view class="list">
<view class="item" wx:for="{{mutualList}}" wx:key="conversationId">
<ui-user-card user="{{item}}" bind:tap="onViewDetail">
<view slot="actions">
<button class="btn-primary" catchtap="onStartChat" data-conversation-id="{{item.conversationId}}">开始聊天</button>
</view>
</ui-user-card>
</view>
</view>
<ui-loading wx:if="{{loadingMore}}" mode="skeleton" rows="2" />
</block>
<ui-empty
wx:else
illustration="heart"
title="还没有互相喜欢的人"
description="去「找对象」遇见合适的人,互相喜欢就能聊天啦"
actions="{{[{text:'去「找对象', event:'discover', variant:'primary'}]}}"
bind:action="onEmptyAction"
/>
</block>
<block wx:elif="{{activeTab === 'liked'}}">
<block wx:if="{{likedList.length > 0}}">
<view class="list">
<view class="item" wx:for="{{likedList}}" wx:key="userId">
<ui-user-card user="{{item}}">
<view slot="actions">
<view class="status-waiting">已表达喜欢,等待回应</view>
</view>
</ui-user-card>
</view>
</view>
<ui-loading wx:if="{{loadingMore}}" mode="skeleton" rows="2" />
</block>
<ui-empty
wx:else
illustration="heart"
title="还没有喜欢过任何人"
description="去「找对象」看看,遇到心仪的人就表达喜欢吧"
actions="{{[{text:'去「找对象', event:'discover', variant:'primary'}]}}"
bind:action="onEmptyAction"
/>
</block>
<block wx:elif="{{activeTab === 'likers'}}">
<block wx:if="{{likersList.length > 0}}">
<view class="list">
<view class="item" wx:for="{{likersList}}" wx:key="userId">
<ui-user-card user="{{item}}">
<view slot="actions">
<button
class="btn-like-back {{actionLoadingUserId === item.userId ? 'disabled' : ''}}"
catchtap="onLikeBack"
data-user-id="{{item.userId}}"
disabled="{{actionLoadingUserId === item.userId}}"
>喜欢TA</button>
</view>
</ui-user-card>
</view>
</view>
<ui-loading wx:if="{{loadingMore}}" mode="skeleton" rows="2" />
</block>
<ui-empty
wx:else
illustration="heart"
title="还没有人喜欢你"
description="完善资料、上传真实生活照,更容易被合适的人看到"
actions="{{[{text:'去「找对象', event:'discover', variant:'primary'}]}}"
bind:action="onEmptyAction"
/>
</block>
</view>
</view>
+119
View File
@@ -0,0 +1,119 @@
/* 我的缘分页 U22 — Design Tokens */
.page {
min-height: 100vh;
background: #FFF8F7;
}
/* Tab 切换栏(粘性吸附在自定义导航栏下方) */
.tabs {
display: flex;
background: #fff;
border-bottom: 1rpx solid #F0F0F0;
position: sticky;
z-index: 10;
}
.tab {
flex: 1;
text-align: center;
padding: 28rpx 0;
font-size: 28rpx;
color: #999;
position: relative;
transition: color 0.2s;
}
.tab.active {
color: #E96B6B;
font-weight: bold;
}
.tab.active::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 48rpx;
height: 4rpx;
background: #E96B6B;
border-radius: 2rpx;
}
.tab-count {
display: inline-block;
background: #E96B6B;
color: #fff;
font-size: 20rpx;
padding: 2rpx 10rpx;
border-radius: 20rpx;
margin-left: 8rpx;
vertical-align: middle;
}
/* 列表容器 */
.list-wrap {
padding: 24rpx;
box-sizing: border-box;
}
.list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.item {
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
/* 操作按钮 */
.btn-primary {
flex-shrink: 0;
height: 64rpx;
line-height: 64rpx;
padding: 0 28rpx;
border-radius: 32rpx;
font-size: 24rpx;
background: #E96B6B;
color: #fff;
border: none;
}
.btn-primary::after {
border: none;
}
.btn-like-back {
flex-shrink: 0;
height: 64rpx;
line-height: 64rpx;
padding: 0 28rpx;
border-radius: 32rpx;
font-size: 24rpx;
background: #FFF3F2;
color: #E96B6B;
border: 2rpx solid #E96B6B;
}
.btn-like-back::after {
border: none;
}
.btn-like-back.disabled {
opacity: 0.5;
}
/* 等待回应状态 */
.status-waiting {
flex-shrink: 0;
font-size: 22rpx;
color: #E96B6B;
padding: 8rpx 16rpx;
background: #FFF3F2;
border-radius: 8rpx;
white-space: nowrap;
}
+78
View File
@@ -0,0 +1,78 @@
/**
* 帮助中心页 U41
*
* 静态 FAQ,无后端依赖。
*/
const { ROUTE } = require('../../constants');
const FAQ_DATA = [
{
category: '账号与登录',
items: [
{ q: '如何注册账号?', a: '点击「微信一键登录」,系统将自动创建账号并完成实名认证。' },
{ q: '忘记登录密码怎么办?', a: '本平台使用微信授权登录,无需设置密码。如遇到问题请重新登录。' },
{ q: '如何绑定手机号?', a: '登录后在「我的」页面完成实名认证即可绑定手机号。' },
],
},
{
category: '推荐与匹配',
items: [
{ q: '推荐列表是怎么生成的?', a: '系统根据您的择偶条件和已有用户资料,按照匹配算法每日生成推荐列表。' },
{ q: '为什么收不到推荐?', a: '请检查您的资料完整度是否高于60%,以及择偶条件是否合理(不宜过窄)。' },
{ q: '如何暂停推荐?', a: '在「设置」中切换关系状态为「已暂停寻找」即可停止推荐。' },
{ q: '可以查看谁喜欢过我吗?', a: '可以,在首页右上角点击「喜欢我的人」即可查看。' },
],
},
{
category: '活动参与',
items: [
{ q: '如何报名参加活动?', a: '在活动详情页点击「立即报名」,填写紧急联系人信息后提交。' },
{ q: '活动报名后能否取消?', a: '可以,在「我的活动」中找到对应活动并取消报名。' },
{ q: '活动是否需要审核?', a: '部分活动需要公益组织审核后参与,审核结果将在活动开始前通知您。' },
],
},
{
category: '隐私与安全',
items: [
{ q: '平台如何保护我的隐私?', a: '所有敏感信息(身份证、手机号)均加密存储,仅授权工作人员在案件上下文中可有限访问。' },
{ q: '如何设置隐私权限?', a: '在「隐私设置」中可以控制是否在推荐中可见、是否允许活动邀请等。' },
{ q: '如何拉黑某人?', a: '在推荐或缘分列表中点击该用户卡片,选择「拉黑」即可。拉黑后对方将不再看到您。' },
],
},
{
category: '公益红娘',
items: [
{ q: '什么是公益红娘服务?', a: '公益红娘是由丹阳市公益组织指派的专业人士,为您提供一对一的婚恋咨询服务。' },
{ q: '如何申请红娘服务?', a: '在首页点击「申请公益红娘」,填写服务需求后等待工作人员联系。' },
{ q: '红娘服务收费吗?', a: '不收费。本平台是公益性质的免费婚恋服务平台。' },
],
},
];
Page({
data: {
platformName: getApp().globalData.platformName,
activeCategory: '',
faqData: FAQ_DATA,
expandedIds: {},
},
onLoad() {
wx.setNavigationBarTitle({ title: '帮助中心' });
if (FAQ_DATA.length > 0) {
this.setData({ activeCategory: FAQ_DATA[0].category });
}
},
onCategoryTap(e) {
const category = e.currentTarget.dataset.category;
this.setData({ activeCategory: category });
},
onToggleItem(e) {
const id = e.currentTarget.dataset.id;
const expanded = Object.assign({}, this.data.expandedIds);
expanded[id] = !expanded[id];
this.setData({ expandedIds: expanded });
},
});
@@ -0,0 +1 @@
{"navigationBarTitleText":"help-center","usingComponents":{}}
@@ -0,0 +1,39 @@
<!-- 帮助中心页 -->
<view class="page">
<!-- 分类导航 -->
<scroll-view scroll-x class="category-bar">
<view
wx:for="{{faqData}}"
wx:key="category"
class="category-item {{activeCategory === item.category ? 'active' : ''}}"
data-category="{{item.category}}"
bindtap="onCategoryTap"
>
{{item.category}}
</view>
</scroll-view>
<!-- FAQ 列表 -->
<scroll-view scroll-y class="faq-list">
<block wx:for="{{faqData}}" wx:key="category" wx:for-item="cat">
<view wx:if="{{activeCategory === cat.category}}">
<view
wx:for="{{cat.items}}"
wx:key="q"
wx:for-item="faq"
class="faq-item"
data-id="{{cat.category + '-' + faq.q}}"
bindtap="onToggleItem"
>
<view class="faq-question">
<text>{{faq.q}}</text>
<text class="faq-arrow {{expandedIds[(cat.category + '-' + faq.q)] ? 'up' : ''}}">▼</text>
</view>
<view class="faq-answer" wx:if="{{expandedIds[cat.category + '-' + faq.q]}}">
<text>{{faq.a}}</text>
</view>
</view>
</view>
</block>
</scroll-view>
</view>
@@ -0,0 +1,70 @@
/* 帮助中心样式 */
.page {
min-height: 100vh;
background: #f5f5f5;
display: flex;
flex-direction: column;
}
.category-bar {
white-space: nowrap;
background: #fff;
padding: 0 24rpx;
border-bottom: 1rpx solid #f0f0f0;
flex-shrink: 0;
}
.category-item {
display: inline-block;
padding: 24rpx 32rpx;
font-size: 28rpx;
color: #666;
border-bottom: 4rpx solid transparent;
}
.category-item.active {
color: #E96B6B;
border-bottom-color: #E96B6B;
font-weight: 600;
}
.faq-list {
flex: 1;
padding: 24rpx 32rpx;
}
.faq-item {
background: #fff;
border-radius: 16rpx;
margin-bottom: 24rpx;
padding: 28rpx 32rpx;
}
.faq-question {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 30rpx;
color: #2d2d2d;
font-weight: 500;
}
.faq-arrow {
font-size: 20rpx;
color: #ccc;
transition: transform 0.2s;
}
.faq-arrow.up {
transform: rotate(180deg);
color: #E96B6B;
}
.faq-answer {
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid #f5f5f5;
font-size: 26rpx;
color: #666;
line-height: 1.7;
}
+172
View File
@@ -0,0 +1,172 @@
/**
* 首页 - 每日推荐(重构:用 matchStore + recommend-card + appStore 统一编排)
*
* 关键不变量(AGENTS.md):
* - 第 2 条:Required 不可自动放宽——推荐列表完全由后端按 PartnerPreference 过滤,
* 前端只渲染后端返回的 recommendations,不做任何"放宽条件再查"的逻辑。
* - 第 5 条:红娘不可替用户操作——所有 Like/Pass 由当前用户触发。
* - 第 6 条:单向意向不泄露——likersCount 仅展示计数,不暴露具体身份。
*/
const { matchStore, appStore, userStore, authStore, blockUser, likeUser } = require('../../stores');
const { ROUTE, to, RECOMMENDATION_STATE } = require('../../constants');
Page({
data: {
greeting: '',
userName: '',
recommendations: [],
recState: RECOMMENDATION_STATE.IDLE,
likersCount: 0,
actionLoadingUserId: '', // 正在操作的用户 id(Like/Pass 进行中)
emptyActions: [
{ text: '去「找对象」', event: 'discover', variant: 'primary' },
{ text: '申请公益红娘', event: 'matchmaker', variant: 'secondary' },
],
},
onLoad() {
this.setGreeting();
// 绑定 matchStore 关键字段到 page data
this._unsubMatch = matchStore.bindToPage(this, {
recommendations: 'recommendations',
recState: 'recState',
});
// 绑定 likersCount
this._unsubMatchSub = matchStore.subscribe((state) => {
if (state.likersCount !== this.data.likersCount) {
this.setData({ likersCount: state.likersCount });
}
});
// 绑定 userStore 用户名(用于问候语)
this._unsubUser = userStore.subscribe((state) => {
const name = state.profile?.displayName || '';
if (name !== this.data.userName) this.setData({ userName: name });
});
this.setData({ userName: userStore.state.profile?.displayName || '' });
},
onShow() {
// 未登录则不请求推荐(bootstrap 已跳转登录页,此处为保险兜底)
if (!authStore.state.isLoggedIn) return;
// 每次显示刷新推荐(用户从聊天页返回后可能已 Like/Pass,需要新批次)
this.loadRecommendations();
// 异步刷新 likersCount(Tab 徽标也会更新)
matchStore.fetchLikersCount().catch(() => {});
},
onUnload() {
this._unsubMatch && this._unsubMatch();
this._unsubMatchSub && this._unsubMatchSub();
this._unsubUser && this._unsubUser();
},
onPullDownRefresh() {
this.loadRecommendations().finally(() => wx.stopPullDownRefresh());
},
onReachBottom() {
// 加载更多推荐
matchStore.loadMoreRecommendations().catch(() => {});
},
setGreeting() {
const hour = new Date().getHours();
let greeting = '早上好';
if (hour >= 12 && hour < 17) greeting = '下午好';
else if (hour >= 17) greeting = '晚上好';
this.setData({ greeting });
},
async loadRecommendations() {
try {
await matchStore.fetchRecommendations();
} catch (err) {
appStore.toastError(err);
}
},
onViewDetail(e) {
const { userId } = e.detail;
wx.navigateTo({ url: to.detail(userId) });
},
async onLike(e) {
const { userId } = e.detail;
if (this.data.actionLoadingUserId) return;
this.setData({ actionLoadingUserId: userId });
try {
const result = await likeUser(userId);
if (result && result.mutualMatch) {
// 双向匹配达成:弹祝福 + 引导去聊天
appStore.toastSuccess('双向喜欢!可以开始聊天了');
setTimeout(() => {
wx.showModal({
title: '互相喜欢',
content: '你们已经互相喜欢,是否现在开始聊天?',
confirmText: '开始聊天',
cancelText: '稍后',
confirmColor: '#E96B6B',
success: (res) => {
if (res.confirm && result.matchedConversationId) {
wx.navigateTo({ url: to.chat(result.matchedConversationId) });
}
},
});
}, 800);
} else {
appStore.toastSuccess('已表达喜欢');
}
} catch (err) {
appStore.toastError(err);
} finally {
this.setData({ actionLoadingUserId: '' });
}
},
async onPass(e) {
const { userId } = e.detail;
if (this.data.actionLoadingUserId) return;
this.setData({ actionLoadingUserId: userId });
try {
await matchStore.pass(userId);
// Pass 是单向意向,不打扰用户(AGENTS 第 6 条隐式)
} catch (err) {
// Pass 失败不提示,避免打扰
} finally {
this.setData({ actionLoadingUserId: '' });
}
},
async onBlock(e) {
const { userId } = e.detail;
const target = this.data.recommendations.find((r) => r.userId === userId);
const name = target?.displayName || '该用户';
// 危险动作二次确认(AGENTS 第 4 条)
const confirmed = await appStore.confirm({
title: '拉黑该用户',
content: `拉黑后 ${name} 将无法与你互动,你也不会再收到对方的推荐与消息。此操作可由你随时解除。`,
confirmText: '确认拉黑',
danger: true,
});
if (!confirmed) return;
try {
await blockUser(userId, '用户主动拉黑');
appStore.toastSuccess('已拉黑');
} catch (err) {
appStore.toastError(err);
}
},
onViewLikers() {
wx.navigateTo({ url: ROUTE.DETAIL.LIKERS });
},
onEmptyAction(e) {
const { event } = e.detail;
if (event === 'discover') {
wx.switchTab({ url: ROUTE.TAB.DISCOVER });
} else if (event === 'matchmaker') {
wx.navigateTo({ url: ROUTE.DETAIL.MATCHMAKER_APPLY });
}
},
});
+14
View File
@@ -0,0 +1,14 @@
{
"navigationBarTitleText": "善缘丹阳",
"navigationStyle": "custom",
"enablePullDownRefresh": true,
"backgroundTextStyle": "dark",
"backgroundColor": "#FAFAF8",
"usingComponents": {
"ui-section-header": "/components/section-header/index",
"ui-recommend-card": "/components/recommend-card/index",
"ui-empty": "/components/empty/index",
"ui-loading": "/components/loading/index",
"ui-status-badge": "/components/status-badge/index"
}
}
+239
View File
@@ -0,0 +1,239 @@
/**
* 首页 - V1.1 新版(U18)
* 结构:品牌区 → 用户状态条 → Banner → 8入口 → 推荐 → 服务建议 → 活动 → 服务站 → 安全提醒
*/
const authStore = require('../../stores/authStore')
const api = require('../../utils/api')
Page({
data: {
unreadCount: 0,
userState: null,
isOnboarding: false,
isVerified: false,
isPaused: false,
isSuccessful: false,
onboardingMessage: '',
onboardingActions: [],
statusChecks: [],
banners: [],
currentBannerIndex: 0,
quickEntries: [],
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() {
this.checkAuthAndLoad()
},
onShow() {
if (authStore.state.isLoggedIn) {
this.loadHomeData()
}
},
onPullDownRefresh() {
this.loadHomeData().finally(() => wx.stopPullDownRefresh())
},
// ==================== 核心加载 ====================
async checkAuthAndLoad() {
if (!authStore.state.isLoggedIn) {
this.setData({
isOnboarding: true,
onboardingMessage: '登录后即可开始寻找合适的人',
onboardingActions: [{ text: '去登录', event: 'login', variant: 'primary' }],
})
return
}
await this.loadHomeData()
},
async loadHomeData() {
try {
const res = await api.get('/home')
this.processHomeData(res)
} catch (err) {
console.error('首页数据加载失败', err)
this.setData({ 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 || profileStatus !== 'approved'
const isPaused = relStatus === 'paused'
const isSuccessful = relStatus === 'matched_successfully'
const isVerified = eligStatus === 'verified'
const checks = [
{ label: '已实名', done: userState?.verified || false },
{ label: '本地资格', done: eligStatus === 'verified' || eligStatus === 'pending_review' },
{ label: '资料已审核', done: profileStatus === 'approved' || profileStatus === 'completed' },
]
this.setData({
userState,
isOnboarding, isVerified, isPaused, isSuccessful,
onboardingMessage: this.buildOnboardingMessage(userState, profileStatus, eligStatus),
onboardingActions: this.buildOnboardingActions(userState, profileStatus, eligStatus),
statusChecks: checks,
banners, quickEntries,
recommendations: recommendations || [],
recState: (recommendations && recommendations.length > 0) ? 'loaded' : 'empty',
serviceRecommendations, activities, serviceLocation,
safetyNotice: safetyNotice || this.data.safetyNotice,
})
},
buildOnboardingMessage(state, profileStatus, eligStatus) {
if (!state) 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 if (targetType === 'service_location' && targetValue) {
wx.navigateTo({ url: `/pages/local-eligibility/local-eligibility?from=service_station&id=${targetValue}` })
}
},
onEntryTap(e) {
const { code, enabled, targetPage } = e.currentTarget.dataset.entry
if (!enabled) {
wx.showToast({ title: '完成资料后可使用此功能', icon: 'none' })
return
}
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}` })
},
onLike(e) {
const userId = e.detail?.userId
if (!userId) return
this.setData({ actionLoadingUserId: userId })
setTimeout(() => this.setData({ actionLoadingUserId: '' }), 500)
},
onPass(e) {
const userId = e.detail?.userId
if (!userId) return
this.setData({ actionLoadingUserId: userId })
setTimeout(() => this.setData({ actionLoadingUserId: '' }), 500)
},
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.showToast({ title: '功能开发中', icon: 'none' })
},
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/local-eligibility/local-eligibility?from=service_station' })
},
onServiceTap(e) {
const type = e.currentTarget.dataset.type
switch (type) {
case 'matchmaker': wx.navigateTo({ url: '/pages/matchmaker/matchmaker' })
break
case 'counseling':
case 'premarital':
wx.navigateTo({ url: `/pages/local-eligibility/local-eligibility?from=${type}` })
break
}
},
onSafetyTap() {
wx.navigateTo({ url: '/pages/local-eligibility/local-eligibility?from=anti_fraud' })
},
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')}`
},
})
+214
View File
@@ -0,0 +1,214 @@
<!-- U18 首页 - V1.1 新版结构 -->
<view class="page">
<!-- ========== 品牌区 ========== -->
<view class="brand-bar">
<view class="brand-info">
<text class="brand-name">善缘丹阳</text>
<text class="brand-slogan">丹阳公益婚恋服务平台</text>
</view>
<view class="brand-actions">
<view class="bell-btn" bindtap="onMessages">
<text class="bell-icon">🔔</text>
<block wx:if="{{unreadCount > 0}}">
<text class="badge">{{unreadCount > 9 ? '9+' : unreadCount}}</text>
</block>
</view>
</view>
</view>
<!-- ========== 用户状态条 ========== -->
<view class="status-strip" wx:if="{{userState}}">
<!-- 未完成准入 -->
<block wx:if="{{isOnboarding}}">
<view class="status-prompt">
<text class="prompt-text">{{onboardingMessage}}</text>
<view class="prompt-actions" wx:if="{{onboardingActions.length > 0}}">
<view
wx:for="{{onboardingActions}}"
wx:key="event"
class="prompt-btn {{item.variant}}"
bindtap="onEmptyAction"
data-event="{{item.event}}"
>{{item.text}}</view>
</view>
</view>
</block>
<!-- 已验证 -->
<block wx:elif="{{isVerified}}">
<view class="status-checks">
<text class="status-check {{item.done ? 'done' : 'pending'}}"
wx:for="{{statusChecks}}" wx:key="label">
{{item.done ? '✓' : '○'}} {{item.label}}
</text>
</view>
</block>
<!-- 已暂停 -->
<block wx:elif="{{isPaused}}">
<view class="paused-hint">
<text>你已暂停新的对象推荐</text>
<view class="resume-btn" bindtap="onResume">恢复寻找</view>
</view>
</block>
<!-- 已成功牵手 -->
<block wx:elif="{{isSuccessful}}">
<view class="successful-hint">
<text>祝福你们正在认真了解彼此</text>
</view>
</block>
</view>
<!-- ========== Banner 轮播 ========== -->
<view class="banner-section" wx:if="{{banners.length > 0}}">
<swiper
class="banner-swiper"
indicator-dots
autoplay
circular
interval="4500"
indicator-active-color="#E96B6B"
bindchange="onBannerChange"
>
<swiper-item wx:for="{{banners}}" wx:key="id">
<view class="banner-card" bindtap="onBannerTap" data-item="{{item}}">
<image wx:if="{{item.imageFileId}}"
src="{{item.imageFileId}}"
mode="aspectFill"
class="banner-img" />
<view class="banner-overlay">
<text class="banner-title">{{item.title}}</text>
<text class="banner-subtitle" wx:if="{{item.subtitle}}">{{item.subtitle}}</text>
<view class="banner-btn" wx:if="{{item.buttonText}}">{{item.buttonText}}</view>
</view>
</view>
</swiper-item>
</swiper>
</view>
<!-- ========== 8个核心入口 ========== -->
<view class="entries-section">
<view class="entries-grid">
<view
wx:for="{{quickEntries}}"
wx:key="code"
class="entry-item {{!item.enabled ? 'disabled' : ''}}"
bindtap="onEntryTap"
data-entry="{{item}}"
>
<view class="entry-icon-bg {{item.iconClass}}">
<text class="entry-icon">{{item.icon}}</text>
</view>
<text class="entry-label">{{item.label}}</text>
</view>
</view>
</view>
<!-- ========== 今日推荐 ========== -->
<view class="section" wx:if="{{recommendations.length > 0 || recState === 'loading'}}">
<ui-section-header title="今日推荐"
subtitle="根据双方条件,为你筛选更合适的人"
more="{{true}}"
bind:more="onSeeMore" />
<block wx:if="{{recState === 'loading' && recommendations.length === 0}}">
<view class="skeleton-card" wx:for="{{3}}" wx:key="index">
<view class="skeleton-avatar"></view>
<view class="skeleton-info">
<view class="skeleton-line w60"></view>
<view class="skeleton-line w40"></view>
<view class="skeleton-line w80"></view>
</view>
</view>
</block>
<block wx:elif="{{recommendations.length > 0}}">
<view wx:for="{{recommendations}}" wx:key="userId" class="rec-card">
<ui-recommend-card
recommendation="{{item}}"
action-loading="{{actionLoadingUserId === item.userId}}"
bind:view="onViewDetail"
bind:like="onLike"
bind:pass="onPass"
/>
</view>
</block>
<view wx:if="{{recState === 'finished' && recommendations.length > 0}}" class="end-tip">
已展示今日全部推荐 · 明天再来看看
</view>
</view>
<!-- ========== 无推荐空状态 ========== -->
<view class="section" wx:if="{{!isOnboarding && recommendations.length === 0 && recState !== 'loading'}}">
<ui-empty
illustration="heart"
title="暂时没有找到完全符合双方条件的人"
description="你可以调整择偶条件,或申请公益红娘帮你推荐"
actions="{{emptyActions}}"
bind:action="onEmptyAction"
/>
</view>
<!-- ========== 善缘公益服务 ========== -->
<view class="section service-reco-section" wx:if="{{serviceRecommendations.length > 0}}">
<ui-section-header title="善缘公益服务" />
<view wx:for="{{serviceRecommendations}}" wx:key="type"
class="service-card" bindtap="onServiceTap" data-type="{{item.type}}">
<view class="service-card-inner">
<text class="service-title">{{item.title}}</text>
<text class="service-desc">{{item.description}}</text>
<view class="service-cta">{{item.ctaText}}</view>
</view>
</view>
</view>
<!-- ========== 近期公益活动 ========== -->
<view class="section activity-section" wx:if="{{activities.length > 0}}">
<ui-section-header title="近期公益活动" more="{{true}}"
bind:more="onSeeAllActivities" />
<scroll-view scroll-x class="activity-scroll">
<view wx:for="{{activities}}" wx:key="id"
class="activity-h-card" bindtap="onActivityTap" data-id="{{item.id}}">
<image wx:if="{{item.coverFileId}}" src="{{item.coverFileId}}"
mode="aspectFill" class="activity-cover" />
<view class="activity-info">
<text class="activity-title">{{item.title}}</text>
<text class="activity-meta" wx:if="{{item.startAt}}">{{formatDate(item.startAt)}}</text>
<text class="activity-meta" wx:if="{{item.locationName}}">{{item.locationName}}</text>
</view>
</view>
</scroll-view>
</view>
<!-- ========== 善缘服务站 ========== -->
<view class="section" wx:if="{{serviceLocation}}">
<view class="station-card" bindtap="onStationTap">
<view class="station-card-inner">
<view class="station-icon-area">
<text class="station-icon">📍</text>
</view>
<view class="station-info">
<text class="station-name">{{serviceLocation.name}}</text>
<text class="station-short">{{serviceLocation.shortName}}</text>
<view class="station-services">
<text wx:for="{{serviceLocation.services}}" wx:key="*this"
class="station-tag">{{item}}</text>
</view>
</view>
<text class="station-arrow">›</text>
</view>
</view>
</view>
<!-- ========== 安全提醒 ========== -->
<view class="safety-notice">
<view class="safety-notice-inner">
<text class="safety-icon">🛡</text>
<view class="safety-text-area">
<text class="safety-title">善缘提醒</text>
<text class="safety-body">{{safetyNotice.text}}</text>
</view>
<text class="safety-cta" bindtap="onSafetyTap">{{safetyNotice.ctaText}}</text>
</view>
</view>
<!-- 底部安全区 -->
<view class="safe-area-bottom"></view>
</view>
+246
View File
@@ -0,0 +1,246 @@
/* U18 首页 - V1.1 新版样式 */
.page {
min-height: 100vh;
background: #FAFAF8;
padding-bottom: 0;
}
/* ========== 品牌区 ========== */
.brand-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 24rpx 16rpx;
background: #fff;
position: sticky;
top: 0;
z-index: 100;
}
.brand-info { display: flex; flex-direction: column; }
.brand-name {
font-size: 36rpx;
font-weight: 700;
color: #E96B6B;
line-height: 1.2;
}
.brand-slogan {
font-size: 22rpx;
color: #888;
margin-top: 4rpx;
}
.bell-btn {
position: relative;
width: 64rpx; height: 64rpx;
display: flex; align-items: center; justify-content: center;
border-radius: 50%;
background: #f5f5f5;
}
.bell-icon { font-size: 32rpx; }
.badge {
position: absolute; top: -4rpx; right: -4rpx;
min-width: 28rpx; height: 28rpx; line-height: 28rpx;
padding: 0 6rpx;
background: #E96B6B; color: #fff;
font-size: 20rpx; border-radius: 14rpx;
text-align: center;
}
/* ========== 用户状态条 ========== */
.status-strip {
background: #fff;
padding: 16rpx 24rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.status-checks { display: flex; gap: 24rpx; flex-wrap: wrap; }
.status-check {
font-size: 24rpx; color: #666;
}
.status-check.done { color: #2E7D32; }
.status-check.pending { color: #F57C00; }
.status-prompt { padding: 16rpx 0; }
.prompt-text {
font-size: 26rpx; color: #555;
display: block; margin-bottom: 16rpx;
}
.prompt-actions { display: flex; gap: 16rpx; }
.prompt-btn {
padding: 12rpx 28rpx; border-radius: 8rpx;
font-size: 26rpx; font-weight: 500;
}
.prompt-btn.primary { background: #E96B6B; color: #fff; }
.prompt-btn.secondary { background: #fff; color: #E96B6B; border: 1rpx solid #E96B6B; }
.paused-hint, .successful-hint { padding: 16rpx 0; text-align: center; }
.paused-hint text { font-size: 26rpx; color: #888; display: block; margin-bottom: 12rpx; }
.resume-btn {
display: inline-block; padding: 10rpx 32rpx;
background: #E96B6B; color: #fff;
border-radius: 8rpx; font-size: 26rpx;
}
/* ========== Banner ========== */
.banner-section { padding: 16rpx 24rpx; }
.banner-swiper {
width: 100%; height: 340rpx;
border-radius: 16rpx; overflow: hidden;
}
.banner-card { width: 100%; height: 100%; position: relative; }
.banner-img { width: 100%; height: 100%; object-fit: cover; }
.banner-overlay {
position: absolute; bottom: 0; left: 0; right: 0;
padding: 24rpx;
background: linear-gradient(transparent, rgba(0,0,0,0.6));
color: #fff;
}
.banner-title { font-size: 30rpx; font-weight: 600; display: block; margin-bottom: 4rpx; }
.banner-subtitle { font-size: 22rpx; opacity: 0.9; display: block; margin-bottom: 8rpx; }
.banner-btn {
display: inline-block; padding: 8rpx 20rpx;
background: rgba(255,255,255,0.25); border: 1rpx solid rgba(255,255,255,0.6);
border-radius: 20rpx; font-size: 22rpx; color: #fff;
}
/* ========== 8个核心入口 ========== */
.entries-section {
background: #fff;
margin: 0 24rpx; border-radius: 16rpx;
padding: 24rpx 16rpx; margin-top: -8rpx;
position: relative; z-index: 10;
}
.entries-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16rpx;
}
.entry-item {
display: flex; flex-direction: column; align-items: center;
padding: 12rpx 0;
}
.entry-item.disabled { opacity: 0.5; }
.entry-icon-bg {
width: 72rpx; height: 72rpx;
border-radius: 16rpx;
display: flex; align-items: center; justify-content: center;
margin-bottom: 8rpx;
}
.entry-icon-bg.icon-dating { background: #FFF0F0; }
.entry-icon-bg.icon-matchmaker { background: #FFF8E6; }
.entry-icon-bg.icon-activity { background: #E8F5E9; }
.entry-icon-bg.icon-counseling { background: #E3F2FD; }
.entry-icon-bg.icon-class { background: #F3E5F5; }
.entry-icon-bg.icon-premarital { background: #FFF3E0; }
.entry-icon-bg.icon-shield { background: #E8EAF6; }
.entry-icon-bg.icon-location { background: #E0F2F1; }
.entry-icon { font-size: 32rpx; }
.entry-label { font-size: 22rpx; color: #333; text-align: center; line-height: 1.3; }
/* ========== 通用 Section ========== */
.section { margin-top: 24rpx; padding: 0 24rpx; }
/* ========== 推荐卡片 ========== */
.rec-card { margin-bottom: 16rpx; }
.end-tip { text-align: center; font-size: 24rpx; color: #999; padding: 16rpx 0; }
/* ========== 骨架屏 ========== */
.skeleton-card {
display: flex; align-items: center;
padding: 16rpx 0; margin-bottom: 12rpx;
}
.skeleton-avatar {
width: 88rpx; height: 112rpx;
background: #eee; border-radius: 8rpx; margin-right: 16rpx;
}
.skeleton-info { flex: 1; }
.skeleton-line {
height: 24rpx; background: #eee;
border-radius: 4rpx; margin-bottom: 8rpx;
}
.skeleton-line.w60 { width: 60%; }
.skeleton-line.w40 { width: 40%; }
.skeleton-line.w80 { width: 80%; }
/* ========== 善缘公益服务 ========== */
.service-card {
background: linear-gradient(135deg, #FFF8F0 0%, #FFF0F0 100%);
border-radius: 16rpx; padding: 24rpx; margin-bottom: 12rpx;
}
.service-card-inner { display: flex; flex-direction: column; gap: 8rpx; }
.service-title { font-size: 28rpx; font-weight: 600; color: #333; }
.service-desc { font-size: 24rpx; color: #666; line-height: 1.5; }
.service-cta {
display: inline-block; margin-top: 8rpx;
padding: 8rpx 20rpx; background: #E96B6B;
color: #fff; border-radius: 20rpx;
font-size: 24rpx; width: fit-content;
}
/* ========== 近期活动 ========== */
.activity-scroll { white-space: nowrap; margin: 0 -4rpx; }
.activity-h-card {
display: inline-block; width: 480rpx; margin: 0 4rpx 12rpx;
background: #fff; border-radius: 12rpx; overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
}
.activity-cover { width: 100%; height: 270rpx; background: #f0f0f0; }
.activity-info { padding: 12rpx 16rpx; }
.activity-title { font-size: 26rpx; font-weight: 600; color: #333; display: block; margin-bottom: 4rpx; }
.activity-meta { font-size: 22rpx; color: #888; display: block; }
/* ========== 善缘服务站 ========== */
.station-card {
background: #fff; border-radius: 16rpx; overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06); margin-top: 24rpx;
}
.station-card-inner {
display: flex; align-items: center; padding: 24rpx; gap: 16rpx;
}
.station-icon-area {
width: 80rpx; height: 80rpx; background: #E8F5E9;
border-radius: 16rpx; display: flex; align-items: center;
justify-content: center; flex-shrink: 0;
}
.station-icon { font-size: 40rpx; }
.station-info { flex: 1; min-width: 0; }
.station-name { font-size: 28rpx; font-weight: 600; color: #333; display: block; }
.station-short { font-size: 22rpx; color: #888; display: block; margin-top: 2rpx; }
.station-services { display: flex; flex-wrap: wrap; gap: 8rpx; margin-top: 8rpx; }
.station-tag {
font-size: 20rpx; padding: 2rpx 12rpx;
background: #F5F5F5; border-radius: 12rpx; color: #666;
}
.station-arrow { font-size: 36rpx; color: #ccc; flex-shrink: 0; }
/* ========== 安全提醒 ========== */
.safety-notice {
margin: 24rpx 24rpx 0;
background: #FFF9E6; border-radius: 12rpx;
padding: 20rpx 24rpx;
}
.safety-notice-inner { display: flex; align-items: flex-start; gap: 12rpx; }
.safety-icon { font-size: 32rpx; flex-shrink: 0; margin-top: 2rpx; }
.safety-text-area { flex: 1; }
.safety-title { font-size: 24rpx; font-weight: 600; color: #8B6914; display: block; margin-bottom: 4rpx; }
.safety-body { font-size: 22rpx; color: #8B6914; line-height: 1.5; display: block; }
.safety-cta { font-size: 24rpx; color: #E96B6B; flex-shrink: 0; padding-left: 12rpx; }
/* ========== 底部安全区 ========== */
.safe-area-bottom { height: env(safe-area-inset-bottom); }
@@ -0,0 +1,66 @@
// pages/local-eligibility-supplement/local-eligibility-supplement.js
Page({
/**
* 页面的初始数据
*/
data: {
},
/**
* 生命周期函数--监听页面加载
*/
onLoad(options) {
},
/**
* 生命周期函数--监听页面初次渲染完成
*/
onReady() {
},
/**
* 生命周期函数--监听页面显示
*/
onShow() {
},
/**
* 生命周期函数--监听页面隐藏
*/
onHide() {
},
/**
* 生命周期函数--监听页面卸载
*/
onUnload() {
},
/**
* 页面相关事件处理函数--监听用户下拉动作
*/
onPullDownRefresh() {
},
/**
* 页面上拉触底事件的处理函数
*/
onReachBottom() {
},
/**
* 用户点击右上角分享
*/
onShareAppMessage() {
}
})
@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "补充本地资格佐证",
"usingComponents": {}
}
@@ -0,0 +1,76 @@
// 本地资格补充核验页 U63
// 用户上传佐证材料(工作证明/居住证明等)
Page({
data: {
eligibilityType: '',
evidenceTypes: [
{ value: 'employment_proof', label: '工作证明', desc: '劳动合同/工牌/在职证明等' },
{ value: 'residence_proof', label: '居住证明', desc: '房产证/租赁合同/水电账单等' },
{ value: 'household_proof', label: '户籍证明', desc: '户口本/户籍页等' },
{ value: 'other', label: '其他材料', desc: '能证明您在丹阳的材料' }
],
selectedType: '',
uploadedFileIds: [] as string[]
},
onLoad(options: Record<string, string>) {
if (options.type) {
this.setData({ eligibilityType: options.type })
}
},
onShow() {
// TODO: P04-V11 加载当前用户的已上传佐证
},
// 选择佐证类型
onEvidenceTypeSelect(e: WechatMiniprogram.TouchEvent) {
this.setData({ selectedType: e.currentTarget.dataset.type })
},
// 上传图片
onChooseImage() {
wx.chooseMedia({
count: 6,
mediaType: ['image'],
sourceType: ['album', 'camera'],
success: (res) => {
// TODO: P04-V11 上传到对象存储,获取fileId
res.tempFiles.forEach(file => {
console.log('选择的图片:', file.tempFilePath)
})
wx.showToast({ title: '上传成功', icon: 'success' })
}
})
},
// 提交审核
onSubmit() {
if (!this.data.selectedType) {
wx.showToast({ title: '请选择佐证类型', icon: 'none' })
return
}
if (this.data.uploadedFileIds.length === 0) {
wx.showToast({ title: '请上传佐证材料', icon: 'none' })
return
}
// TODO: P04-V11 调用API提交
wx.showLoading({ title: '提交中...' })
setTimeout(() => {
wx.hideLoading()
wx.showToast({ title: '已提交审核', icon: 'success' })
setTimeout(() => {
wx.navigateBack()
}, 1500)
}, 1000)
},
// 删除已上传图片
onRemoveImage(e: WechatMiniprogram.TouchEvent) {
const index = e.currentTarget.dataset.index
const ids = this.data.uploadedFileIds
ids.splice(index, 1)
this.setData({ uploadedFileIds: ids })
}
})
@@ -0,0 +1,69 @@
<!-- U63 本地资格补充核验页 -->
<view class="page">
<!-- 说明文案 -->
<view class="header">
<text class="title">为了完成本地资格核验</text>
<text class="desc">
请补充一项能够说明您在丹阳工作或居住的材料。
材料仅用于资格审核,不对其他用户展示。
</text>
</view>
<!-- 已声明的资格类型 -->
<view class="type-tag" wx:if="{{eligibilityType}}">
<text>已声明:{{getEligibilityLabel(eligibilityType)}}</text>
</view>
<!-- 佐证类型选择 -->
<view class="section">
<text class="section-title">选择佐证类型</text>
<view class="evidence-list">
<view
wx:for="{{evidenceTypes}}"
wx:key="value"
class="evidence-card {{selectedType === item.value ? 'active' : ''}}"
bindtap="onEvidenceTypeSelect"
data-type="{{item.value}}"
>
<text class="evidence-label">{{item.label}}</text>
<text class="evidence-desc">{{item.desc}}</text>
</view>
</view>
</view>
<!-- 上传区域 -->
<view class="section">
<text class="section-title">上传佐证材料</text>
<view class="upload-area">
<view class="upload-hint">
<text class="hint-text">支持 JPG/PNG,最多 6 张</text>
</view>
<view class="image-grid">
<view
wx:for="{{uploadedFileIds}}"
wx:key="index"
class="image-item"
>
<image src="{{item}}" mode="aspectFill" class="image-preview" />
<view class="image-remove" bindtap="onRemoveImage" data-index="{{index}}">×</view>
</view>
<view class="upload-btn" bindtap="onChooseImage">
<text class="upload-icon">+</text>
<text class="upload-text">上传</text>
</view>
</view>
</view>
</view>
<!-- 隐私提示 -->
<view class="privacy-note">
<text class="note-text">🔒 仅用于资格审核,不对其他用户展示</text>
</view>
<!-- 提交按钮 -->
<view class="action-bar">
<button class="btn-primary" bindtap="onSubmit">
提交审核
</button>
</view>
</view>
@@ -0,0 +1,200 @@
/* U63 本地资格补充核验页 */
.page {
padding: 24rpx;
background: #FAFAF8;
min-height: 100vh;
padding-bottom: 120rpx;
}
.header {
background: #fff;
border-radius: 16rpx;
padding: 32rpx 24rpx;
margin-bottom: 24rpx;
}
.title {
display: block;
font-size: 32rpx;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 12rpx;
}
.desc {
display: block;
font-size: 26rpx;
color: #666;
line-height: 1.6;
}
.type-tag {
background: #E8F5E9;
border-radius: 8rpx;
padding: 16rpx 20rpx;
margin-bottom: 24rpx;
}
.type-tag text {
font-size: 24rpx;
color: #2E7D32;
}
.section {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 24rpx;
}
.section-title {
font-size: 28rpx;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 20rpx;
display: block;
}
.evidence-list {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.evidence-card {
padding: 24rpx;
border-radius: 12rpx;
border: 2rpx solid #eee;
}
.evidence-card.active {
border-color: #E96B6B;
background: #FFF5F5;
}
.evidence-label {
display: block;
font-size: 28rpx;
font-weight: 500;
color: #333;
margin-bottom: 4rpx;
}
.evidence-desc {
display: block;
font-size: 22rpx;
color: #999;
}
.upload-area {
border: 2rpx dashed #ddd;
border-radius: 12rpx;
padding: 24rpx;
}
.upload-hint {
text-align: center;
margin-bottom: 16rpx;
}
.hint-text {
font-size: 22rpx;
color: #999;
}
.image-grid {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.image-item {
width: 160rpx;
height: 160rpx;
position: relative;
border-radius: 8rpx;
overflow: hidden;
}
.image-preview {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-remove {
position: absolute;
top: 4rpx;
right: 4rpx;
width: 36rpx;
height: 36rpx;
background: rgba(0,0,0,0.5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 24rpx;
line-height: 1;
}
.upload-btn {
width: 160rpx;
height: 160rpx;
border: 2rpx dashed #ccc;
border-radius: 8rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.upload-icon {
font-size: 48rpx;
color: #ccc;
}
.upload-text {
font-size: 22rpx;
color: #999;
margin-top: 8rpx;
}
.privacy-note {
background: #F0F7FF;
border-radius: 8rpx;
padding: 16rpx 20rpx;
margin-bottom: 24rpx;
}
.note-text {
font-size: 24rpx;
color: #1976D2;
}
.action-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 16rpx 24rpx;
background: #fff;
box-shadow: 0 -2rpx 12rpx rgba(0,0,0,0.05);
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
}
.btn-primary {
width: 100%;
height: 88rpx;
line-height: 88rpx;
background: #E96B6B;
color: #fff;
border-radius: 12rpx;
font-size: 30rpx;
font-weight: 500;
border: none;
}
.btn-primary:disabled {
background: #ccc;
}

Some files were not shown because too many files have changed in this diff Show More