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,194 @@
/**
* 头像与照片页 U08(Phase 04 P04-T23)
*
* 重构要点(遵循 AGENTS.md 不变量 + Phase 04 验收条件):
* - 数据:profileService.getPhotos(每次从服务端获取,不在本地缓存图片 URL —— 验收"图片URL非永久公开")
* - 上传:profileService.requestUploadToken + wx.uploadFile(EXIF 由服务端清理,前端不处理)
* - 删除:profileService.deletePhoto
* - 主头像:profileService.setMainPhoto(第一张或用户指定)
* - 数量限制:BUSINESS_LIMITS.MAX_PROFILE_PHOTOS(第 12 条,不硬编码)
* - 敏感数据:本页不涉及身份证/手机号(第 8 条)
* - 下一步条件:至少 1 张主头像
*/
const { appStore } = require('../../stores');
const { profileService } = require('../../services');
const { ROUTE, BUSINESS_LIMITS } = require('../../constants');
Page({
data: {
statusBarHeight: 20,
navBarHeight: 44,
loading: false,
saving: false,
avatarPhotos: [],
lifestylePhotos: [],
maxLifestyle: BUSINESS_LIMITS.MAX_PROFILE_PHOTOS - 1,
// 页面说明
guidelines: [
'请使用本人近期真实照片',
'禁止网图、二维码、广告、联系方式图片',
'建议真人生活照,避免过度美颜',
],
},
onLoad() {
this._initSystemInfo();
this.loadPhotos();
},
_initSystemInfo() {
try {
const sysInfo = wx.getSystemInfoSync();
this.setData({
statusBarHeight: sysInfo.statusBarHeight || 20,
navBarHeight: 44,
});
} catch (e) { /* 使用默认值 */ }
},
async loadPhotos() {
this.setData({ loading: true });
try {
const result = await profileService.getPhotos();
const photos = (result && result.photos) || [];
const avatarPhotos = photos.filter((p) => p.photoType === 'avatar');
const lifestylePhotos = photos.filter((p) => p.photoType === 'lifestyle');
this.setData({ avatarPhotos, lifestylePhotos, loading: false });
} catch (err) {
this.setData({ loading: false });
appStore.toastError(err, '加载照片失败');
}
},
// ============ 上传 ============
/** 主头像区添加 */
onAddAvatar() {
this._chooseAndUpload('avatar');
},
/** 生活照区添加 */
onAddLifestyle() {
const remaining = this.data.maxLifestyle - this.data.lifestylePhotos.length;
if (remaining <= 0) {
appStore.toast(`最多上传 ${BUSINESS_LIMITS.MAX_PROFILE_PHOTOS} 张照片`);
return;
}
this._chooseAndUpload('lifestyle');
},
_chooseAndUpload(photoType) {
wx.chooseMedia({
count: 1,
mediaType: ['image'],
sourceType: ['album', 'camera'],
sizeType: ['compressed'],
success: (res) => {
const tempFile = res.tempFiles[0];
if (!tempFile) return;
this._uploadPhoto(tempFile.tempFilePath, photoType);
},
fail: () => { /* 用户取消 */ },
});
},
async _uploadPhoto(filePath, photoType) {
appStore.showLoading('上传中…');
try {
// 1. 获取上传凭证(EXIF 由服务端清理)
const fileName = `photo_${Date.now()}.jpg`;
const tokenRes = await profileService.requestUploadToken({ fileName, contentType: 'image/jpeg' });
// 2. 上传到对象存储
await new Promise((resolve, reject) => {
wx.uploadFile({
url: tokenRes.uploadUrl,
filePath,
name: 'file',
formData: { 'Content-Type': 'image/jpeg' },
success: (res) => {
if (res.statusCode >= 200 && res.statusCode < 300) resolve(res);
else reject(new Error('上传失败'));
},
fail: reject,
});
});
// 3. 若上传的是头像区,或当前无主头像,则设为主头像
if (photoType === 'avatar' || this.data.avatarPhotos.length === 0) {
await profileService.setMainPhoto(tokenRes.photoId);
}
appStore.hideLoading();
appStore.toastSuccess('上传成功');
this.loadPhotos();
} catch (err) {
appStore.hideLoading();
appStore.toastError(err, '上传失败');
}
},
// ============ 删除 ============
async onRemoveAvatar(e) {
const { index } = e.detail;
const photo = this.data.avatarPhotos[index];
if (!photo) return;
this._removePhoto(photo.photoId);
},
async onRemoveLifestyle(e) {
const { index } = e.detail;
const photo = this.data.lifestylePhotos[index];
if (!photo) return;
this._removePhoto(photo.photoId);
},
async _removePhoto(photoId) {
const confirm = await appStore.confirm('确定删除这张照片?', '删除后不可恢复');
if (!confirm) return;
appStore.showLoading('删除中…');
try {
await profileService.deletePhoto(photoId);
appStore.hideLoading();
appStore.toastSuccess('已删除');
this.loadPhotos();
} catch (err) {
appStore.hideLoading();
appStore.toastError(err, '删除失败');
}
},
// ============ 预览 ============
onPreviewAvatar(e) {
const { index } = e.detail;
const photo = this.data.avatarPhotos[index];
if (photo) this._preview(photo.url);
},
onPreviewLifestyle(e) {
const { index } = e.detail;
const photo = this.data.lifestylePhotos[index];
if (photo) this._preview(photo.url);
},
_preview(currentUrl) {
const urls = [
...this.data.avatarPhotos.map((p) => p.url),
...this.data.lifestylePhotos.map((p) => p.url),
].filter(Boolean);
if (urls.length === 0) return;
wx.previewImage({ urls, current: currentUrl });
},
// ============ 导航 ============
onBack() {
wx.navigateBack();
},
onNext() {
if (this.data.avatarPhotos.length === 0) {
appStore.toast('请至少上传 1 张主头像');
return;
}
wx.navigateTo({ url: ROUTE.ONBOARDING.PROFILE_INTERESTS });
},
});
@@ -0,0 +1,11 @@
{
"navigationBarTitleText": "头像与照片",
"navigationStyle": "custom",
"usingComponents": {
"ui-nav-bar": "/components/nav-bar/index",
"ui-section-header": "/components/section-header/index",
"ui-photo-uploader": "/components/photo-uploader/index",
"ui-button": "/components/button/index",
"ui-loading": "/components/loading/index"
}
}
@@ -0,0 +1,58 @@
<!-- 头像与照片页 U08 -->
<view class="pp-page">
<ui-nav-bar
title="头像与照片"
status-bar-height="{{statusBarHeight}}"
nav-bar-height="{{navBarHeight}}"
bind:back="onBack"
/>
<view wx:if="{{loading}}" class="pp-loading">
<ui-loading mode="spinner" text="加载照片…" />
</view>
<view wx:else class="pp-body">
<!-- 主头像 -->
<view class="pp-section card-base">
<ui-section-header title="主头像" subtitle="必须上传 1 张清晰正面照" />
<view class="pp-uploader">
<ui-photo-uploader
photos="{{avatarPhotos}}"
max="{{1}}"
bind:add="onAddAvatar"
bind:remove="onRemoveAvatar"
bind:tap="onPreviewAvatar"
/>
</view>
</view>
<!-- 生活照 -->
<view class="pp-section card-base">
<ui-section-header title="生活照片" subtitle="最多 {{maxLifestyle}} 张(可选)" />
<view class="pp-uploader">
<ui-photo-uploader
photos="{{lifestylePhotos}}"
max="{{maxLifestyle}}"
bind:add="onAddLifestyle"
bind:remove="onRemoveLifestyle"
bind:tap="onPreviewLifestyle"
/>
</view>
</view>
<!-- 上传须知 -->
<view class="pp-section card-base">
<ui-section-header title="上传须知" />
<view class="pp-guidelines">
<view wx:for="{{guidelines}}" wx:key="*this" class="pp-guideline">
<view class="pp-guideline__dot"></view>
<text class="pp-guideline__text">{{item}}</text>
</view>
</view>
</view>
</view>
<view class="pp-actions">
<ui-button variant="primary" size="lg" block bind:tap="onNext">下一步</ui-button>
</view>
</view>
@@ -0,0 +1,68 @@
/**
* 头像与照片页 U08
*/
.pp-page {
min-height: 100vh;
background: var(--color-bg-page);
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
}
.pp-loading {
padding-top: 200rpx;
display: flex;
justify-content: center;
}
.pp-body {
padding: var(--spacing-2) var(--spacing-page-x);
}
.pp-section {
margin-bottom: var(--spacing-3);
}
.pp-uploader {
margin-top: var(--spacing-2);
}
/* ============ 上传须知 ============ */
.pp-guidelines {
margin-top: var(--spacing-2);
}
.pp-guideline {
display: flex;
align-items: flex-start;
gap: var(--spacing-2);
padding: var(--spacing-1) 0;
}
.pp-guideline__dot {
width: 10rpx;
height: 10rpx;
margin-top: 14rpx;
border-radius: 50%;
background: var(--color-text-placeholder);
flex-shrink: 0;
}
.pp-guideline__text {
flex: 1;
font-size: var(--font-size-sm);
color: var(--color-text-tertiary);
line-height: var(--line-height-relaxed);
}
/* ============ 底部 ============ */
.pp-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);
}