chore: establish V1.1 recovery baseline
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* 用户资料详情页 U21
|
||||
*
|
||||
* 重构要点(遵循 AGENTS.md 不变量):
|
||||
* - 数据获取:recommendationService.getUserDetail(API_INVENTORY 行 43),不再直连 api 模块
|
||||
* - 喜欢:matchStore.like(mutualMatch=true 时跳转会话页,AGENTS 第 3/6 条)
|
||||
* - 拉黑:blockUser 跨 Store 编排(MatchStore + MessageStore 同步停用,AGENTS 第 4 条)
|
||||
* - 举报:跳转 report 页,不在本页处理
|
||||
* - 敏感数据:profile.birthday/身份证/手机号 仅服务端持有,前端不缓存(AGENTS 第 8 条)
|
||||
* - Required 不可放宽:本页只读展示,无任何放宽择偶条件的入口(AGENTS 第 2 条)
|
||||
*/
|
||||
const { matchStore, appStore, blockUser } = require('../../stores');
|
||||
const { recommendationService } = require('../../services');
|
||||
const { to, DANGER_ACTION, RELATION_STATUS, BUSINESS_ERROR_CODE } = require('../../constants');
|
||||
|
||||
Page({
|
||||
data: {
|
||||
userId: '',
|
||||
profile: null,
|
||||
loading: true,
|
||||
loadError: '',
|
||||
/** @type {import('../../types').RelationStatus} */
|
||||
relationStatus: RELATION_STATUS.NEUTRAL,
|
||||
isLiked: false,
|
||||
isMutual: false,
|
||||
canChat: false,
|
||||
actionLoading: false,
|
||||
statusBarHeight: 20,
|
||||
navBarHeight: 44,
|
||||
showBlockDialog: false,
|
||||
blockAction: DANGER_ACTION.BLOCK,
|
||||
/** 派生的展示字段 */
|
||||
displayName: '',
|
||||
ageText: '',
|
||||
metaLine: '',
|
||||
badges: [],
|
||||
basicInfo: [],
|
||||
attitudeInfo: [],
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
const userId = options.id || '';
|
||||
if (!userId) {
|
||||
this.setData({ loading: false, loadError: '用户不存在' });
|
||||
return;
|
||||
}
|
||||
// 状态栏高度由 app.js 注入到全局,这里读一次
|
||||
try {
|
||||
const sysInfo = wx.getSystemInfoSync ? wx.getSystemInfoSync() : null;
|
||||
if (sysInfo) {
|
||||
this.setData({
|
||||
statusBarHeight: sysInfo.statusBarHeight || 20,
|
||||
navBarHeight: 44,
|
||||
});
|
||||
}
|
||||
} catch (e) { /* 使用默认值 */ }
|
||||
|
||||
this.setData({ userId });
|
||||
this.loadProfile();
|
||||
},
|
||||
|
||||
async loadProfile() {
|
||||
this.setData({ loading: true, loadError: '' });
|
||||
try {
|
||||
const user = await recommendationService.getUserDetail(this.data.userId);
|
||||
this.setData({ loading: false, profile: user });
|
||||
this._deriveDisplay(user);
|
||||
} catch (err) {
|
||||
this.setData({
|
||||
loading: false,
|
||||
loadError: (err && err.message) || '加载失败',
|
||||
});
|
||||
appStore.toastError(err, '加载用户资料失败');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 把后端 User 字段派生为展示用结构,避免 wxml 内做条件拼接
|
||||
*/
|
||||
_deriveDisplay(user) {
|
||||
if (!user) return;
|
||||
const relationStatus = user.relationStatus || RELATION_STATUS.NEUTRAL;
|
||||
const isLiked = relationStatus === RELATION_STATUS.LIKED || relationStatus === RELATION_STATUS.MUTUAL_MATCH;
|
||||
const isMutual = relationStatus === RELATION_STATUS.MUTUAL_MATCH;
|
||||
// AGENTS 第 3 条:只有 Mutual 后才能聊天
|
||||
const canChat = isMutual && relationStatus !== RELATION_STATUS.BLOCKED;
|
||||
|
||||
const badges = [];
|
||||
if (user.verified || user.isRealNameVerified) {
|
||||
badges.push({ kind: 'success', label: '已实名' });
|
||||
}
|
||||
if (user.reviewed || user.profileReviewStatus === 'approved') {
|
||||
badges.push({ kind: 'info', label: '资料已审核' });
|
||||
}
|
||||
|
||||
const metaParts = [];
|
||||
if (user.heightCm) metaParts.push(`${user.heightCm}cm`);
|
||||
if (user.regionName) metaParts.push(user.regionName);
|
||||
if (user.educationCode) metaParts.push(user.educationCode);
|
||||
if (user.occupationCode) metaParts.push(user.occupationCode);
|
||||
|
||||
const basicInfo = [];
|
||||
if (user.maritalStatus) basicInfo.push({ label: '婚姻状况', value: user.maritalStatus });
|
||||
if (user.childrenStatus || user.hasChildren !== undefined) {
|
||||
basicInfo.push({ label: '子女情况', value: user.childrenStatus || (user.hasChildren ? '有孩' : '无孩') });
|
||||
}
|
||||
if (user.educationCode) basicInfo.push({ label: '学历', value: user.educationCode });
|
||||
if (user.occupationCode) basicInfo.push({ label: '职业', value: user.occupationCode });
|
||||
if (user.smokingStatus) basicInfo.push({ label: '吸烟', value: user.smokingStatus });
|
||||
if (user.drinkingStatus) basicInfo.push({ label: '饮酒', value: user.drinkingStatus });
|
||||
if (user.longTermDanyang) basicInfo.push({ label: '定居意愿', value: user.longTermDanyang, full: true });
|
||||
if (user.futureDanyang) basicInfo.push({ label: '未来规划', value: user.futureDanyang, full: true });
|
||||
|
||||
const attitudeInfo = [];
|
||||
if (user.relationshipGoal) attitudeInfo.push({ label: '寻找', value: user.relationshipGoal, full: true });
|
||||
if (user.marriagePlan) attitudeInfo.push({ label: '结婚计划', value: user.marriagePlan, full: true });
|
||||
|
||||
this.setData({
|
||||
relationStatus,
|
||||
isLiked,
|
||||
isMutual,
|
||||
canChat,
|
||||
displayName: user.displayName || '用户',
|
||||
ageText: user.age ? `${user.age}岁` : '',
|
||||
metaLine: metaParts.join(' · '),
|
||||
badges,
|
||||
basicInfo,
|
||||
attitudeInfo,
|
||||
});
|
||||
},
|
||||
|
||||
onBack() {
|
||||
wx.navigateBack();
|
||||
},
|
||||
|
||||
onRetry() {
|
||||
this.loadProfile();
|
||||
},
|
||||
|
||||
/**
|
||||
* 喜欢(AGENTS 第 3/5/6 条:用户本人操作,mutualMatch 时跳聊天)
|
||||
*/
|
||||
async onLike() {
|
||||
const { userId, actionLoading, isLiked } = this.data;
|
||||
if (!userId || actionLoading || isLiked) return;
|
||||
this.setData({ actionLoading: true });
|
||||
try {
|
||||
const result = await matchStore.like(userId);
|
||||
// 本地派生状态更新
|
||||
if (result && result.mutualMatch) {
|
||||
this.setData({
|
||||
isLiked: true,
|
||||
isMutual: true,
|
||||
canChat: true,
|
||||
relationStatus: RELATION_STATUS.MUTUAL_MATCH,
|
||||
actionLoading: false,
|
||||
});
|
||||
appStore.toastSuccess('双向喜欢!可以开始聊天了');
|
||||
if (result.matchedConversationId) {
|
||||
wx.navigateTo({ url: to.chat(result.matchedConversationId) });
|
||||
}
|
||||
} else {
|
||||
this.setData({
|
||||
isLiked: true,
|
||||
relationStatus: RELATION_STATUS.LIKED,
|
||||
actionLoading: false,
|
||||
});
|
||||
appStore.toastSuccess('已表达喜欢');
|
||||
}
|
||||
} catch (err) {
|
||||
this.setData({ actionLoading: false });
|
||||
if (err && err.code === BUSINESS_ERROR_CODE.DUPLICATE_ACTION) {
|
||||
appStore.toast('已表达过喜欢');
|
||||
} else {
|
||||
appStore.toastError(err, '操作失败');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 开始聊天(仅 Mutual 后可见,AGENTS 第 3 条)
|
||||
*/
|
||||
onGoChat() {
|
||||
const { userId, canChat, profile } = this.data;
|
||||
if (!canChat) {
|
||||
appStore.toast('双方互相喜欢后才能开始聊天');
|
||||
return;
|
||||
}
|
||||
// matchedConversationId 优先来自最近 like 结果;无则用 userId 占位由 chat 页拉取
|
||||
const conversationId = (profile && profile.matchedConversationId) || userId;
|
||||
wx.navigateTo({ url: to.chat(conversationId) });
|
||||
},
|
||||
|
||||
/**
|
||||
* 举报:跳转 report 页,不在本页处理(AGENTS 第 7 条:举报内容不在此暴露)
|
||||
*/
|
||||
onReport() {
|
||||
const { userId } = this.data;
|
||||
if (!userId) return;
|
||||
wx.navigateTo({ url: to.report(userId) });
|
||||
},
|
||||
|
||||
/**
|
||||
* 拉黑入口:弹出 DangerActionDialog 二次确认(AGENTS 第 4 条)
|
||||
*/
|
||||
onBlock() {
|
||||
if (!this.data.userId) return;
|
||||
this.setData({ showBlockDialog: true });
|
||||
},
|
||||
|
||||
onConfirmBlock() {
|
||||
this.setData({ showBlockDialog: false });
|
||||
this._doBlock();
|
||||
},
|
||||
|
||||
onCancelBlock() {
|
||||
this.setData({ showBlockDialog: false });
|
||||
},
|
||||
|
||||
/**
|
||||
* 执行拉黑:调用跨 Store 编排 blockUser
|
||||
* - MatchStore:从推荐/Mutual 列表移除
|
||||
* - MessageStore:会话立即标记不可发消息
|
||||
*/
|
||||
async _doBlock() {
|
||||
const { userId, actionLoading } = this.data;
|
||||
if (!userId || actionLoading) return;
|
||||
this.setData({ actionLoading: true });
|
||||
try {
|
||||
appStore.showLoading('处理中…');
|
||||
await blockUser(userId, '用户主动拉黑');
|
||||
appStore.hideLoading();
|
||||
appStore.toastSuccess('已拉黑');
|
||||
// 拉黑后离开当前页(AGENTS 第 4 条:Block 即时禁止互动)
|
||||
setTimeout(() => wx.navigateBack(), 800);
|
||||
} catch (err) {
|
||||
appStore.hideLoading();
|
||||
this.setData({ actionLoading: false });
|
||||
appStore.toastError(err, '拉黑失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"navigationBarTitleText": "用户资料",
|
||||
"navigationStyle": "custom",
|
||||
"usingComponents": {
|
||||
"ui-nav-bar": "/components/nav-bar/index",
|
||||
"ui-avatar": "/components/avatar/index",
|
||||
"ui-status-badge": "/components/status-badge/index",
|
||||
"ui-section-header": "/components/section-header/index",
|
||||
"ui-button": "/components/button/index",
|
||||
"ui-tag": "/components/tag/index",
|
||||
"ui-loading": "/components/loading/index",
|
||||
"ui-empty": "/components/empty/index",
|
||||
"ui-danger-action-dialog": "/components/danger-action-dialog/index"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<!-- 用户资料详情页 U21 -->
|
||||
<view class="profile-page">
|
||||
<ui-nav-bar
|
||||
title="用户资料"
|
||||
status-bar-height="{{statusBarHeight}}"
|
||||
nav-bar-height="{{navBarHeight}}"
|
||||
bind:back="onBack"
|
||||
/>
|
||||
|
||||
<!-- 首次加载 -->
|
||||
<view wx:if="{{loading}}" class="profile-loading">
|
||||
<ui-loading mode="spinner" text="正在加载资料…" />
|
||||
</view>
|
||||
|
||||
<!-- 加载失败 -->
|
||||
<view wx:elif="{{loadError}}" class="profile-empty">
|
||||
<ui-empty
|
||||
title="加载失败"
|
||||
description="{{loadError}}"
|
||||
illustration="default"
|
||||
actions="{{[{text:'重试', event:'retry', variant:'primary'}]}}"
|
||||
bind:action="onRetry"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 用户不存在 -->
|
||||
<view wx:elif="{{!profile}}" class="profile-empty">
|
||||
<ui-empty
|
||||
title="用户不存在"
|
||||
description="该用户可能已注销或不可见"
|
||||
illustration="default"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 资料主体 -->
|
||||
<view wx:else class="profile-body">
|
||||
<!-- 顶部信息 -->
|
||||
<view class="profile-header">
|
||||
<ui-avatar
|
||||
src="{{profile.avatarUrl}}"
|
||||
size="xl"
|
||||
verified="{{profile.verified || profile.isRealNameVerified}}"
|
||||
reviewed="{{profile.reviewed || profile.profileReviewStatus === 'approved'}}"
|
||||
default-text="{{displayName}}"
|
||||
/>
|
||||
<view class="profile-header__info">
|
||||
<view class="profile-header__name-row">
|
||||
<text class="profile-header__name">{{displayName}}</text>
|
||||
<text wx:if="{{ageText}}" class="profile-header__age">{{ageText}}</text>
|
||||
</view>
|
||||
<view wx:if="{{metaLine}}" class="profile-header__meta">{{metaLine}}</view>
|
||||
<view wx:if="{{badges.length}}" class="profile-header__badges">
|
||||
<ui-status-badge
|
||||
wx:for="{{badges}}"
|
||||
wx:key="label"
|
||||
kind="{{item.kind}}"
|
||||
label="{{item.label}}"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 关于我 -->
|
||||
<view wx:if="{{basicInfo.length}}" class="profile-section">
|
||||
<ui-section-header title="关于我" />
|
||||
<view class="profile-info-grid">
|
||||
<view
|
||||
wx:for="{{basicInfo}}"
|
||||
wx:key="label"
|
||||
class="profile-info-item {{item.full ? 'profile-info-item--full' : ''}}"
|
||||
>
|
||||
<text class="profile-info-item__label">{{item.label}}</text>
|
||||
<text class="profile-info-item__value">{{item.value}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 自我介绍 -->
|
||||
<view wx:if="{{profile.selfIntro}}" class="profile-section">
|
||||
<ui-section-header title="自我介绍" />
|
||||
<text class="profile-intro-text">{{profile.selfIntro}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 婚恋态度 -->
|
||||
<view wx:if="{{attitudeInfo.length}}" class="profile-section">
|
||||
<ui-section-header title="婚恋态度" />
|
||||
<view class="profile-info-grid">
|
||||
<view
|
||||
wx:for="{{attitudeInfo}}"
|
||||
wx:key="label"
|
||||
class="profile-info-item profile-info-item--full"
|
||||
>
|
||||
<text class="profile-info-item__label">{{item.label}}</text>
|
||||
<text class="profile-info-item__value">{{item.value}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部操作(仅 profile 加载成功时展示) -->
|
||||
<view wx:if="{{profile && !loadError}}" class="profile-actions">
|
||||
<ui-button
|
||||
variant="ghost"
|
||||
size="md"
|
||||
custom-class="profile-actions__btn profile-actions__btn--block"
|
||||
bind:tap="onBlock"
|
||||
disabled="{{actionLoading}}"
|
||||
>拉黑</ui-button>
|
||||
<ui-button
|
||||
variant="ghost"
|
||||
size="md"
|
||||
custom-class="profile-actions__btn profile-actions__btn--report"
|
||||
bind:tap="onReport"
|
||||
disabled="{{actionLoading}}"
|
||||
>举报</ui-button>
|
||||
<ui-button
|
||||
wx:if="{{canChat}}"
|
||||
variant="secondary"
|
||||
size="md"
|
||||
custom-class="profile-actions__btn"
|
||||
bind:tap="onGoChat"
|
||||
disabled="{{actionLoading}}"
|
||||
>开始聊天</ui-button>
|
||||
<ui-button
|
||||
wx:if="{{!isLiked}}"
|
||||
variant="primary"
|
||||
size="md"
|
||||
custom-class="profile-actions__btn profile-actions__btn--like"
|
||||
loading="{{actionLoading}}"
|
||||
bind:tap="onLike"
|
||||
>喜欢</ui-button>
|
||||
<ui-button
|
||||
wx:else
|
||||
variant="primary"
|
||||
size="md"
|
||||
custom-class="profile-actions__btn"
|
||||
disabled
|
||||
>已喜欢</ui-button>
|
||||
</view>
|
||||
|
||||
<!-- 拉黑确认弹窗 -->
|
||||
<ui-danger-action-dialog
|
||||
visible="{{showBlockDialog}}"
|
||||
action="{{blockAction}}"
|
||||
target-name="{{displayName}}"
|
||||
bind:confirm="onConfirmBlock"
|
||||
bind:cancel="onCancelBlock"
|
||||
/>
|
||||
</view>
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 用户资料详情页 U21
|
||||
* 全部使用 Design Token,禁止硬编码颜色/间距/圆角
|
||||
*/
|
||||
|
||||
.profile-page {
|
||||
min-height: 100vh;
|
||||
background: var(--color-bg-page);
|
||||
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* ============ 加载/空状态 ============ */
|
||||
.profile-loading,
|
||||
.profile-empty {
|
||||
padding-top: 200rpx;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ============ 顶部信息 ============ */
|
||||
.profile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-5) var(--spacing-page-x);
|
||||
background: var(--color-bg-card);
|
||||
}
|
||||
|
||||
.profile-header__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-header__name-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-2);
|
||||
margin-bottom: var(--spacing-1);
|
||||
}
|
||||
|
||||
.profile-header__name {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.profile-header__age {
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.profile-header__meta {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-tertiary);
|
||||
line-height: var(--line-height-normal);
|
||||
}
|
||||
|
||||
.profile-header__badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-1);
|
||||
margin-top: var(--spacing-2);
|
||||
}
|
||||
|
||||
/* ============ 内容区块 ============ */
|
||||
.profile-body {
|
||||
padding-top: var(--spacing-2);
|
||||
}
|
||||
|
||||
.profile-section {
|
||||
background: var(--color-bg-card);
|
||||
margin: var(--spacing-2) var(--spacing-page-x);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-card-inner);
|
||||
}
|
||||
|
||||
.profile-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.profile-info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-info-item--full {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.profile-info-item__label {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-placeholder);
|
||||
}
|
||||
|
||||
.profile-info-item__value {
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--color-text-secondary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.profile-intro-text {
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: var(--line-height-relaxed);
|
||||
}
|
||||
|
||||
/* ============ 底部操作栏 ============ */
|
||||
.profile-actions {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
align-items: center;
|
||||
padding: var(--spacing-2) var(--spacing-page-x);
|
||||
padding-bottom: calc(var(--spacing-2) + env(safe-area-inset-bottom));
|
||||
background: var(--color-bg-card);
|
||||
border-top: var(--border-width) solid var(--color-divider);
|
||||
z-index: var(--z-sticky);
|
||||
}
|
||||
|
||||
.profile-actions__btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.profile-actions__btn--block,
|
||||
.profile-actions__btn--report {
|
||||
flex: 0 0 auto;
|
||||
min-width: 140rpx;
|
||||
}
|
||||
|
||||
.profile-actions__btn--report {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
Reference in New Issue
Block a user