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
@@ -0,0 +1,244 @@
/**
* 资料确认页 U15(Phase 04 P04-T28)
*
* 重构要点(遵循 AGENTS.md 不变量):
* - 数据:userStore.fetchProfile / fetchPreferences / fetchReviewStatus(第 9 条)
* - 提交:userStore.submitReview(POST /me/profile/submit-review + Idempotency-Key)
* - 草稿:提交成功后清除 ONBOARDING_DRAFT,避免回填污染(第 9 条:DB 是 Source of Truth)
* - 审核状态展示:not_submitted / pending / approved / need_edit / rejected
* - 资料完整度:客户端派生展示,最终由服务端校验,前端不放宽 Required(第 2 条)
* - 路由:提交成功后 redirectTo 至 REVIEW.PENDING(Phase 05 已实现);
* 审核中页 onShow 守卫会在状态变化时引导至合适页面。
*/
const { userStore, appStore } = require('../../stores');
const { profileService } = require('../../services');
const { ROUTE, STORAGE_KEYS, PROFILE_REVIEW } = require('../../constants');
// 审核状态 → StatusBadge kind 映射
const REVIEW_BADGE = {
[PROFILE_REVIEW.NOT_SUBMITTED]: { label: '未提交', kind: 'disabled' },
[PROFILE_REVIEW.PENDING]: { label: '审核中', kind: 'warning' },
[PROFILE_REVIEW.APPROVED]: { label: '已通过', kind: 'success' },
[PROFILE_REVIEW.NEED_EDIT]: { label: '需修改', kind: 'warning' },
[PROFILE_REVIEW.REJECTED]: { label: '未通过', kind: 'danger' },
};
// 婚姻状况字典(前端展示用,权威值仍由服务端 Profile.maritalStatus 给出)
const MARITAL_LABEL = {
single: '未婚',
divorced: '离异',
widowed: '丧偶',
};
// 学历字典
const EDUCATION_LABEL = {
high_school: '高中/中专',
college: '大专',
bachelor: '本科',
master: '硕士',
doctor: '博士',
};
// 子女情况字典
const CHILDREN_LABEL = {
none: '无子女',
has: '有子女',
};
/**
* 计算资料完整度(客户端派生展示)
*
* 注意:仅用于 UI 进度条展示,最终是否可提交由服务端校验决定
* (AGENTS 第 9 条:PostgreSQL 是 Source of Truth)。
* @param {object} profile
* @param {object} preferences
* @param {number} photoCount
* @returns {number} 0-100
*/
function calcCompletion(profile, preferences, photoCount) {
if (!profile) return 0;
let filled = 0;
let total = 0;
const checks = [
!!profile.regionName,
!!profile.heightCm,
!!profile.maritalStatus,
profile.hasChildren !== undefined && profile.hasChildren !== null,
!!profile.educationCode,
!!profile.occupationCode,
!!profile.occupationCategory,
!!profile.workRegion,
profile.smokingStatus !== undefined,
profile.drinkingStatus !== undefined,
profile.longTermDanyang !== undefined,
profile.futureDanyang !== undefined,
!!profile.relationshipGoal,
!!profile.marriagePlan,
!!profile.activityWillingness,
!!profile.matchmakerWillingness,
!!profile.selfIntro && profile.selfIntro.length > 0,
photoCount > 0,
!!profile.interests && profile.interests.length > 0,
!!preferences && !!preferences.ageMin && !!preferences.ageMax,
!!preferences && !!preferences.region && preferences.region.length > 0,
!!preferences && !!preferences.maritalStatusAccepted && preferences.maritalStatusAccepted.length > 0,
];
total = checks.length;
filled = checks.filter(Boolean).length;
return Math.round((filled / total) * 100);
}
Page({
data: {
statusBarHeight: 20,
navBarHeight: 44,
loading: true,
submitting: false,
profile: null,
preferences: null,
photoCount: 0,
reviewStatus: PROFILE_REVIEW.NOT_SUBMITTED,
reviewComment: '',
resubmitAllowed: true,
completion: 0,
reviewBadge: REVIEW_BADGE[PROFILE_REVIEW.NOT_SUBMITTED],
// 派生展示字段
maritalLabel: '',
educationLabel: '',
childrenLabel: '',
},
onLoad() {
this._initSystemInfo();
this.loadAll();
},
_initSystemInfo() {
try {
const sysInfo = wx.getSystemInfoSync();
this.setData({
statusBarHeight: sysInfo.statusBarHeight || 20,
navBarHeight: 44,
});
} catch (e) { /* 使用默认值 */ }
},
// ============ 数据加载 ============
async loadAll() {
this.setData({ loading: true });
try {
// 并行拉取资料/择偶条件/审核状态,照片列表用于计数
const [profile, preferences, review, photosRes] = await Promise.all([
userStore.fetchProfile().catch(() => null),
userStore.fetchPreferences().catch(() => null),
userStore.fetchReviewStatus().catch(() => null),
profileService.getPhotos().catch(() => ({ photos: [] })),
]);
const photoCount = (photosRes && photosRes.photos && photosRes.photos.length) || 0;
// REST §39:服务端返回 { status, items[] }
const reviewStatus = (review && review.status) || PROFILE_REVIEW.NOT_SUBMITTED;
// 派生驳回原因摘要:取所有 need_edit/rejected 项的 reasonText 拼接
const rejectedItems = (review && review.items || []).filter(
(i) => i.status === PROFILE_REVIEW.NEED_EDIT || i.status === PROFILE_REVIEW.REJECTED
);
const reviewComment = rejectedItems
.map((i) => i.reasonText)
.filter(Boolean)
.join(';');
// 仅 NEED_EDIT / REJECTED 允许重新提交(修改后再次提交)
const resubmitAllowed =
reviewStatus === PROFILE_REVIEW.NOT_SUBMITTED ||
reviewStatus === PROFILE_REVIEW.NEED_EDIT ||
reviewStatus === PROFILE_REVIEW.REJECTED;
const completion = calcCompletion(profile, preferences, photoCount);
this.setData({
profile,
preferences,
photoCount,
reviewStatus,
reviewComment,
resubmitAllowed,
completion,
reviewBadge: REVIEW_BADGE[reviewStatus] || REVIEW_BADGE[PROFILE_REVIEW.NOT_SUBMITTED],
maritalLabel: MARITAL_LABEL[profile && profile.maritalStatus] || (profile && profile.maritalStatus) || '-',
educationLabel: EDUCATION_LABEL[profile && profile.educationCode] || (profile && profile.educationCode) || '-',
childrenLabel: CHILDREN_LABEL[profile && (profile.hasChildren ? 'has' : 'none')] || '-',
});
} catch (err) {
appStore.toastError(err, '加载失败');
} finally {
this.setData({ loading: false });
}
},
onRefresh() {
this.loadAll();
},
// ============ 提交审核 ============
/**
* 提交资料审核
*
* AGENTS 第 2 条:Required 不可自动放宽——本页只读展示,不修改任何择偶条件,
* 不存在前端放宽约束的路径。
* AGENTS 第 9 条:DB 是 Source of Truth——提交成功立即清除本地草稿,
* 后续以服务端审核状态为准,本地不再回填。
*/
async onSubmit() {
if (this.data.submitting) return;
if (!this.data.resubmitAllowed) {
return appStore.toast('当前状态不可提交,请稍后或修改后再试');
}
const ok = await appStore.confirm({
title: '确认提交审核?',
content: '提交后资料将进入人工审核,审核期间您可继续修改,但需重新提交审核。',
});
if (!ok) return;
this.setData({ submitting: true });
try {
await userStore.submitReview();
// 提交成功:清除新手草稿(避免再次进入回填旧数据)
this._clearDraft();
appStore.toastSuccess('已提交审核');
// 跳转审核中页(Phase 05 已实现)
setTimeout(() => {
wx.redirectTo({ url: ROUTE.REVIEW.PENDING });
}, 800);
} catch (err) {
appStore.toastError(err, '提交失败');
} finally {
this.setData({ submitting: false });
}
},
// ============ 草稿清理 ============
_clearDraft() {
try {
wx.removeStorageSync(STORAGE_KEYS.ONBOARDING_DRAFT);
wx.removeStorageSync(STORAGE_KEYS.ONBOARDING_CURRENT_STEP);
} catch (e) { /* ignore */ }
},
// ============ 导航 ============
onBack() {
wx.navigateBack();
},
// 跳回指定资料页继续修改(审核驳回/需修改场景)
onEditBasic() {
wx.navigateTo({ url: ROUTE.ONBOARDING.PROFILE_BASIC });
},
onEditIntro() {
wx.navigateTo({ url: ROUTE.ONBOARDING.PROFILE_INTRO });
},
onEditPreference() {
wx.navigateTo({ url: ROUTE.ONBOARDING.PROFILE_PREFERENCE });
},
});
@@ -0,0 +1,13 @@
{
"navigationBarTitleText": "资料确认",
"navigationStyle": "custom",
"usingComponents": {
"ui-nav-bar": "/components/nav-bar/index",
"ui-section-header": "/components/section-header/index",
"ui-status-badge": "/components/status-badge/index",
"ui-avatar": "/components/avatar/index",
"ui-button": "/components/button/index",
"ui-loading": "/components/loading/index",
"ui-empty": "/components/empty/index"
}
}
@@ -0,0 +1,152 @@
<!-- 资料确认页 U15 -->
<view class="pcfm-page">
<ui-nav-bar
title="资料确认"
status-bar-height="{{statusBarHeight}}"
nav-bar-height="{{navBarHeight}}"
bind:back="onBack"
/>
<!-- 加载态 -->
<view class="pcfm-loading" wx:if="{{loading}}">
<ui-loading text="加载中…" />
</view>
<!-- 内容主体 -->
<view class="pcfm-body" wx:elif="{{profile}}">
<!-- 审核状态条 -->
<view class="pcfm-status card-base">
<view class="pcfm-status__row">
<text class="pcfm-status__label">当前审核状态</text>
<ui-status-badge
kind="{{reviewBadge.kind}}"
label="{{reviewBadge.label}}"
icon="{{true}}"
/>
</view>
<view class="pcfm-status__comment" wx:if="{{reviewComment}}">
<text class="pcfm-status__comment-title">审核反馈:</text>
<text class="pcfm-status__comment-text">{{reviewComment}}</text>
</view>
</view>
<!-- 资料完整度 -->
<view class="pcfm-section card-base">
<ui-section-header title="资料完整度" subtitle="{{completion}}%" />
<view class="pcfm-progress">
<view class="pcfm-progress__track">
<view class="pcfm-progress__fill" style="width: {{completion}}%"></view>
</view>
<text class="pcfm-progress__hint">
{{completion >= 100 ? '资料已完整,可提交审核' : '部分资料尚未填写,提交前请确认'}}
</text>
</view>
</view>
<!-- 资料概览 -->
<view class="pcfm-section card-base">
<ui-section-header
title="资料概览"
subtitle="只读展示,如需修改请点击对应项"
/>
<view class="pcfm-summary">
<view class="pcfm-info" bindtap="onEditBasic">
<text class="pcfm-info__label">姓名</text>
<text class="pcfm-info__value">{{profile.displayName || '未设置'}}</text>
</view>
<view class="pcfm-info" bindtap="onEditBasic">
<text class="pcfm-info__label">年龄</text>
<text class="pcfm-info__value">{{profile.age || '-'}}岁</text>
</view>
<view class="pcfm-info" bindtap="onEditBasic">
<text class="pcfm-info__label">身高</text>
<text class="pcfm-info__value">{{profile.heightCm || '-'}}cm</text>
</view>
<view class="pcfm-info" bindtap="onEditBasic">
<text class="pcfm-info__label">婚姻状况</text>
<text class="pcfm-info__value">{{maritalLabel}}</text>
</view>
<view class="pcfm-info" bindtap="onEditBasic">
<text class="pcfm-info__label">子女情况</text>
<text class="pcfm-info__value">{{childrenLabel}}</text>
</view>
<view class="pcfm-info" bindtap="onEditBasic">
<text class="pcfm-info__label">学历</text>
<text class="pcfm-info__value">{{educationLabel}}</text>
</view>
<view class="pcfm-info" bindtap="onEditBasic">
<text class="pcfm-info__label">居住区域</text>
<text class="pcfm-info__value">{{profile.regionName || '-'}}</text>
</view>
<view class="pcfm-info" bindtap="onEditBasic">
<text class="pcfm-info__label">职业</text>
<text class="pcfm-info__value">{{profile.occupationCode || '-'}}</text>
</view>
<view class="pcfm-info" bindtap="onEditBasic">
<text class="pcfm-info__label">生活照</text>
<text class="pcfm-info__value">{{photoCount}}张</text>
</view>
</view>
</view>
<!-- 自我介绍 -->
<view class="pcfm-section card-base">
<ui-section-header title="自我介绍" action="修改" bind:action="onEditIntro" />
<view class="pcfm-intro">
<text class="pcfm-intro__text">{{profile.selfIntro || '暂未填写'}}</text>
</view>
</view>
<!-- 择偶条件摘要 -->
<view class="pcfm-section card-base">
<ui-section-header title="择偶条件" action="修改" bind:action="onEditPreference" />
<view class="pcfm-pref" wx:if="{{preferences}}">
<view class="pcfm-info">
<text class="pcfm-info__label">年龄范围</text>
<text class="pcfm-info__value">{{preferences.ageMin || '-'}} ~ {{preferences.ageMax || '-'}}岁</text>
</view>
<view class="pcfm-info">
<text class="pcfm-info__label">期望地区</text>
<text class="pcfm-info__value">
{{preferences.region && preferences.region.length ? preferences.region.join('、') : '不限'}}
</text>
</view>
<view class="pcfm-info">
<text class="pcfm-info__label">期望婚姻状况</text>
<text class="pcfm-info__value">
{{preferences.maritalStatusAccepted && preferences.maritalStatusAccepted.length ? preferences.maritalStatusAccepted.join('、') : '不限'}}
</text>
</view>
</view>
<ui-empty wx:else title="尚未填写择偶条件" />
</view>
<!-- 提交须知 -->
<view class="pcfm-section pcfm-notice">
<ui-section-header title="提交前请确认" />
<view class="pcfm-notice__list">
<text class="pcfm-notice__item">· 提交后资料将进入人工审核</text>
<text class="pcfm-notice__item">· 审核期间您可继续修改,但需重新提交</text>
<text class="pcfm-notice__item">· 审核通过后即可开始匹配</text>
<text class="pcfm-notice__item">· 资料须真实有效,禁止虚假信息</text>
</view>
</view>
</view>
<!-- 空态 -->
<view class="pcfm-empty" wx:else>
<ui-empty title="资料加载失败,请下拉重试" />
</view>
<!-- 底部提交按钮 -->
<view class="pcfm-actions" wx:if="{{!loading && profile}}">
<ui-button
variant="primary"
size="lg"
block
loading="{{submitting}}"
disabled="{{!resubmitAllowed}}"
bind:tap="onSubmit"
>{{reviewStatus === 'pending' ? '审核中,不可重复提交' : '提交审核'}}</ui-button>
</view>
</view>
@@ -0,0 +1,172 @@
/**
* 资料确认页 U15
*/
.pcfm-page {
min-height: 100vh;
background: var(--color-bg-page);
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
}
.pcfm-loading,
.pcfm-empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
}
.pcfm-body {
padding: var(--spacing-2) var(--spacing-page-x);
}
/* 卡片基类继承全局 .card-base(仅补充外边距) */
.pcfm-section,
.pcfm-status {
margin-bottom: var(--spacing-3);
}
/* ============ 审核状态条 ============ */
.pcfm-status__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-2);
}
.pcfm-status__label {
font-size: var(--font-size-md);
color: var(--color-text-primary);
font-weight: var(--font-weight-medium);
}
.pcfm-status__comment {
margin-top: var(--spacing-2);
padding-top: var(--spacing-2);
border-top: var(--border-width) solid var(--color-divider);
}
.pcfm-status__comment-title {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
display: block;
margin-bottom: var(--spacing-1);
}
.pcfm-status__comment-text {
font-size: var(--font-size-sm);
color: var(--color-text-primary);
line-height: 1.6;
display: block;
}
/* ============ 资料完整度 ============ */
.pcfm-progress {
margin-top: var(--spacing-2);
}
.pcfm-progress__track {
height: 12rpx;
background: var(--color-bg-disabled);
border-radius: var(--radius-full);
overflow: hidden;
}
.pcfm-progress__fill {
height: 100%;
background: var(--color-primary);
border-radius: var(--radius-full);
transition: width 0.3s ease;
}
.pcfm-progress__hint {
display: block;
margin-top: var(--spacing-2);
font-size: var(--font-size-sm);
color: var(--color-text-tertiary);
text-align: right;
}
/* ============ 资料概览 ============ */
.pcfm-summary {
margin-top: var(--spacing-2);
}
.pcfm-info {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-3);
padding: var(--spacing-3) 0;
border-bottom: var(--border-width) solid var(--color-divider);
min-height: 88rpx;
}
.pcfm-info:last-child {
border-bottom: none;
}
.pcfm-info__label {
font-size: var(--font-size-md);
color: var(--color-text-secondary);
flex-shrink: 0;
}
.pcfm-info__value {
font-size: var(--font-size-md);
color: var(--color-text-primary);
text-align: right;
word-break: break-all;
flex: 1;
}
/* ============ 自我介绍 ============ */
.pcfm-intro {
margin-top: var(--spacing-2);
padding: var(--spacing-2) var(--spacing-3);
background: var(--color-bg-page);
border-radius: var(--radius-md);
}
.pcfm-intro__text {
font-size: var(--font-size-sm);
color: var(--color-text-primary);
line-height: 1.7;
display: block;
}
/* ============ 择偶条件摘要 ============ */
.pcfm-pref {
margin-top: var(--spacing-2);
}
/* ============ 提交须知 ============ */
.pcfm-notice {
background: var(--color-primary-light);
border-radius: var(--radius-lg);
padding: var(--spacing-3) var(--spacing-4);
}
.pcfm-notice__list {
margin-top: var(--spacing-2);
}
.pcfm-notice__item {
display: block;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
line-height: 2;
}
/* ============ 底部按钮 ============ */
.pcfm-actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
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);
}