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,247 @@
/**
* 基础资料页 U06(Phase 04 P04-T21)
*
* 重构要点(遵循 AGENTS.md 不变量):
* - 数据:profileService.getProfile + userStore.updateProfile,不再直连 api(第 9 条)
* - 性别/出生日期:由身份证自动生成,只读展示(第 8 条:本地不长期保存身份证/完整生日)
* - 草稿恢复:onLoad 读 ONBOARDING_DRAFT.basic,保存前写回(验收条件"Draft退出后可恢复")
* - 数量限制:本页无数值型限制字段,选项列表遵循 §4.1 配置
* - 下一步:跳 profile-lifestyle
*/
const { userStore, appStore } = require('../../stores');
const { profileService } = require('../../services');
const { ROUTE, STORAGE_KEYS } = require('../../constants');
const MARITAL_OPTIONS = [
{ value: 'never_married', label: '未婚' },
{ value: 'divorced', label: '离异' },
{ value: 'widowed', label: '丧偶' },
];
const CHILDREN_OPTIONS = [
{ value: 'none', label: '无子女' },
{ value: 'has', label: '有子女' },
];
const EDUCATION_OPTIONS = [
{ value: 'high_school', label: '高中/中专' },
{ value: 'college', label: '大专' },
{ value: 'bachelor', label: '本科' },
{ value: 'master', label: '硕士' },
{ value: 'phd', label: '博士' },
];
const OCCUPATION_OPTIONS = [
{ value: 'government', label: '机关事业单位' },
{ value: 'education', label: '教育' },
{ value: 'medical', label: '医疗' },
{ value: 'enterprise', label: '企业职员' },
{ value: 'technical', label: '技术人员' },
{ value: 'self_employed', label: '个体经营' },
{ value: 'freelance', label: '自由职业' },
{ value: 'service', label: '服务行业' },
{ value: 'agriculture', label: '农业' },
{ value: 'other', label: '其他' },
];
const REGION_OPTIONS = ['丹阳', '镇江市区', '镇江其他辖区', '南京'];
Page({
data: {
statusBarHeight: 20,
navBarHeight: 44,
loading: false,
saving: false,
// 身份证自动生成(只读)
gender: '',
genderLabel: '',
ageText: '',
// 用户填写
regionName: '',
heightCm: '',
maritalStatus: '',
hasChildren: '',
educationCode: '',
occupationCode: '',
// 选项
maritalOptions: MARITAL_OPTIONS,
childrenOptions: CHILDREN_OPTIONS,
educationOptions: EDUCATION_OPTIONS,
occupationOptions: OCCUPATION_OPTIONS,
regionOptions: REGION_OPTIONS,
regionIndex: -1,
maritalIndex: -1,
childrenIndex: -1,
educationIndex: -1,
occupationIndex: -1,
},
onLoad() {
this._initSystemInfo();
this._applyDraft();
this.loadProfile();
},
_initSystemInfo() {
try {
const sysInfo = wx.getSystemInfoSync();
this.setData({
statusBarHeight: sysInfo.statusBarHeight || 20,
navBarHeight: 44,
});
} catch (e) { /* 使用默认值 */ }
},
/** 从服务端拉取已有资料(合并草稿) */
async loadProfile() {
this.setData({ loading: true });
try {
const profile = await profileService.getProfile();
this._mergeProfile(profile);
} catch (err) {
// 首次填写时无资料,使用草稿默认值
} finally {
this.setData({ loading: false });
}
},
/** 合并服务端资料到表单(草稿优先,草稿未填则用服务端值) */
_mergeProfile(profile) {
if (!profile) return;
const draft = this._readDraft();
const regionName = draft.regionName || profile.regionName || '';
const maritalStatus = draft.maritalStatus || profile.maritalStatus || '';
const hasChildren = draft.hasChildren || (profile.hasChildren !== undefined ? (profile.hasChildren ? 'has' : 'none') : '');
const educationCode = draft.educationCode || profile.educationCode || '';
const occupationCode = draft.occupationCode || profile.occupationCode || '';
const heightCm = draft.heightCm || profile.heightCm || '';
this.setData({
gender: profile.gender || draft.gender || '',
genderLabel: profile.gender === 'male' ? '男' : (profile.gender === 'female' ? '女' : ''),
ageText: profile.age ? `${profile.age}岁` : '',
regionName,
heightCm,
maritalStatus,
hasChildren,
educationCode,
occupationCode,
regionIndex: REGION_OPTIONS.indexOf(regionName),
maritalIndex: MARITAL_OPTIONS.findIndex((o) => o.value === maritalStatus),
childrenIndex: CHILDREN_OPTIONS.findIndex((o) => o.value === hasChildren),
educationIndex: EDUCATION_OPTIONS.findIndex((o) => o.value === educationCode),
occupationIndex: OCCUPATION_OPTIONS.findIndex((o) => o.value === occupationCode),
});
},
// ============ 草稿 ============
_readDraft() {
try {
const draft = wx.getStorageSync(STORAGE_KEYS.ONBOARDING_DRAFT) || {};
return draft.basic || {};
} catch (e) {
return {};
}
},
/** 把草稿值填入表单(loadProfile 失败时也能恢复) */
_applyDraft() {
const d = this._readDraft();
if (!d || Object.keys(d).length === 0) return;
this.setData({
regionName: d.regionName || '',
heightCm: d.heightCm || '',
maritalStatus: d.maritalStatus || '',
hasChildren: d.hasChildren || '',
educationCode: d.educationCode || '',
occupationCode: d.occupationCode || '',
regionIndex: REGION_OPTIONS.indexOf(d.regionName || ''),
maritalIndex: MARITAL_OPTIONS.findIndex((o) => o.value === (d.maritalStatus || '')),
childrenIndex: CHILDREN_OPTIONS.findIndex((o) => o.value === (d.hasChildren || '')),
educationIndex: EDUCATION_OPTIONS.findIndex((o) => o.value === (d.educationCode || '')),
occupationIndex: OCCUPATION_OPTIONS.findIndex((o) => o.value === (d.occupationCode || '')),
});
},
_writeDraft() {
try {
const draft = wx.getStorageSync(STORAGE_KEYS.ONBOARDING_DRAFT) || {};
draft.basic = {
regionName: this.data.regionName,
heightCm: this.data.heightCm,
maritalStatus: this.data.maritalStatus,
hasChildren: this.data.hasChildren,
educationCode: this.data.educationCode,
occupationCode: this.data.occupationCode,
};
draft.currentStep = 'basic';
wx.setStorageSync(STORAGE_KEYS.ONBOARDING_DRAFT, draft);
} catch (e) { /* ignore */ }
},
// ============ 表单交互 ============
onRegionChange(e) {
const idx = Number(e.detail.value);
this.setData({ regionIndex: idx, regionName: REGION_OPTIONS[idx] });
},
onHeightInput(e) {
this.setData({ heightCm: e.detail.value });
},
onMaritalChange(e) {
const idx = Number(e.detail.value);
this.setData({ maritalIndex: idx, maritalStatus: MARITAL_OPTIONS[idx].value });
},
onChildrenChange(e) {
const idx = Number(e.detail.value);
this.setData({ childrenIndex: idx, hasChildren: CHILDREN_OPTIONS[idx].value });
},
onEducationChange(e) {
const idx = Number(e.detail.value);
this.setData({ educationIndex: idx, educationCode: EDUCATION_OPTIONS[idx].value });
},
onOccupationChange(e) {
const idx = Number(e.detail.value);
this.setData({ occupationIndex: idx, occupationCode: OCCUPATION_OPTIONS[idx].value });
},
// ============ 导航 ============
onBack() {
this._writeDraft();
wx.navigateBack();
},
/** 下一步:校验必填 → 保存草稿 → 提交服务端 → 跳 profile-lifestyle */
async onNext() {
if (this.data.saving) return;
const { regionName, heightCm, maritalStatus, hasChildren, educationCode, occupationCode } = this.data;
if (!regionName) return appStore.toast('请选择居住区域');
if (!heightCm) return appStore.toast('请填写身高');
if (!maritalStatus) return appStore.toast('请选择婚姻状况');
if (!hasChildren) return appStore.toast('请选择子女情况');
if (!educationCode) return appStore.toast('请选择学历');
if (!occupationCode) return appStore.toast('请选择职业类型');
this.setData({ saving: true });
this._writeDraft();
try {
await userStore.updateProfile({
regionName,
heightCm: Number(heightCm),
maritalStatus,
hasChildren: hasChildren === 'has',
educationCode,
occupationCode,
});
this.setData({ saving: false });
wx.navigateTo({ url: ROUTE.ONBOARDING.PROFILE_LIFESTYLE });
} catch (err) {
this.setData({ saving: false });
appStore.toastError(err, '保存失败');
}
},
});
@@ -0,0 +1,11 @@
{
"navigationBarTitleText": "基础资料",
"navigationStyle": "custom",
"usingComponents": {
"ui-nav-bar": "/components/nav-bar/index",
"ui-section-header": "/components/section-header/index",
"ui-input": "/components/input/index",
"ui-button": "/components/button/index",
"ui-status-badge": "/components/status-badge/index"
}
}
@@ -0,0 +1,134 @@
<!-- 基础资料页 U06 -->
<view class="pb-page">
<ui-nav-bar
title="基础资料"
status-bar-height="{{statusBarHeight}}"
nav-bar-height="{{navBarHeight}}"
bind:back="onBack"
/>
<view class="pb-body">
<!-- 身份证自动生成(只读) -->
<view class="pb-section card-base">
<ui-section-header title="基本信息" subtitle="由实名认证自动生成" />
<view class="pb-readonly">
<view class="pb-row">
<text class="pb-row__label">性别</text>
<text class="pb-row__value">{{genderLabel || '未认证'}}</text>
</view>
<view class="pb-row">
<text class="pb-row__label">年龄</text>
<text class="pb-row__value">{{ageText || '未认证'}}</text>
</view>
</view>
</view>
<!-- 用户填写 -->
<view class="pb-section card-base">
<ui-section-header title="个人资料" subtitle="请如实填写" />
<view class="pb-form">
<!-- 居住区域 -->
<picker
mode="selector"
range="{{regionOptions}}"
value="{{regionIndex}}"
bindchange="onRegionChange"
>
<view class="pb-field">
<text class="pb-field__label">居住区域</text>
<view class="pb-field__value {{regionIndex >= 0 ? '' : 'pb-field__value--placeholder'}}">
{{regionIndex >= 0 ? regionOptions[regionIndex] : '请选择'}}
</view>
</view>
</picker>
<!-- 身高 -->
<view class="pb-field">
<text class="pb-field__label">身高(cm)</text>
<input
class="pb-field__input"
type="number"
value="{{heightCm}}"
placeholder="请输入"
bindinput="onHeightInput"
/>
</view>
<!-- 婚姻状况 -->
<picker
mode="selector"
range="{{maritalOptions}}"
range-key="label"
value="{{maritalIndex < 0 ? 0 : maritalIndex}}"
bindchange="onMaritalChange"
>
<view class="pb-field">
<text class="pb-field__label">婚姻状况</text>
<view class="pb-field__value {{maritalIndex >= 0 ? '' : 'pb-field__value--placeholder'}}">
{{maritalIndex >= 0 ? maritalOptions[maritalIndex].label : '请选择'}}
</view>
</view>
</picker>
<!-- 子女情况 -->
<picker
mode="selector"
range="{{childrenOptions}}"
range-key="label"
value="{{childrenIndex < 0 ? 0 : childrenIndex}}"
bindchange="onChildrenChange"
>
<view class="pb-field">
<text class="pb-field__label">子女情况</text>
<view class="pb-field__value {{childrenIndex >= 0 ? '' : 'pb-field__value--placeholder'}}">
{{childrenIndex >= 0 ? childrenOptions[childrenIndex].label : '请选择'}}
</view>
</view>
</picker>
<!-- 学历 -->
<picker
mode="selector"
range="{{educationOptions}}"
range-key="label"
value="{{educationIndex < 0 ? 0 : educationIndex}}"
bindchange="onEducationChange"
>
<view class="pb-field">
<text class="pb-field__label">学历</text>
<view class="pb-field__value {{educationIndex >= 0 ? '' : 'pb-field__value--placeholder'}}">
{{educationIndex >= 0 ? educationOptions[educationIndex].label : '请选择'}}
</view>
</view>
</picker>
<!-- 职业类型 -->
<picker
mode="selector"
range="{{occupationOptions}}"
range-key="label"
value="{{occupationIndex < 0 ? 0 : occupationIndex}}"
bindchange="onOccupationChange"
>
<view class="pb-field">
<text class="pb-field__label">职业类型</text>
<view class="pb-field__value {{occupationIndex >= 0 ? '' : 'pb-field__value--placeholder'}}">
{{occupationIndex >= 0 ? occupationOptions[occupationIndex].label : '请选择'}}
</view>
</view>
</picker>
</view>
</view>
</view>
<!-- 底部操作 -->
<view class="pb-actions">
<ui-button
variant="primary"
size="lg"
block
loading="{{saving}}"
bind:tap="onNext"
>下一步</ui-button>
</view>
</view>
@@ -0,0 +1,101 @@
/**
* 基础资料页 U06
*/
.pb-page {
min-height: 100vh;
background: var(--color-bg-page);
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
}
.pb-body {
padding: var(--spacing-2) var(--spacing-page-x);
}
.pb-section {
margin-bottom: var(--spacing-3);
}
/* ============ 只读区 ============ */
.pb-readonly {
margin-top: var(--spacing-2);
}
.pb-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-3) 0;
border-bottom: var(--border-width) solid var(--color-divider);
}
.pb-row:last-child {
border-bottom: none;
}
.pb-row__label {
font-size: var(--font-size-md);
color: var(--color-text-tertiary);
}
.pb-row__value {
font-size: var(--font-size-md);
color: var(--color-text-primary);
font-weight: var(--font-weight-medium);
}
/* ============ 表单 ============ */
.pb-form {
margin-top: var(--spacing-2);
}
.pb-field {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-3) 0;
border-bottom: var(--border-width) solid var(--color-divider);
min-height: 88rpx;
}
.pb-field:last-child {
border-bottom: none;
}
.pb-field__label {
font-size: var(--font-size-md);
color: var(--color-text-primary);
flex-shrink: 0;
width: 180rpx;
}
.pb-field__value {
flex: 1;
text-align: right;
font-size: var(--font-size-md);
color: var(--color-text-primary);
}
.pb-field__value--placeholder {
color: var(--color-text-placeholder);
}
.pb-field__input {
flex: 1;
text-align: right;
font-size: var(--font-size-md);
color: var(--color-text-primary);
}
/* ============ 底部 ============ */
.pb-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);
}