|
...
|
...
|
@@ -3,7 +3,10 @@ |
|
|
|
<view class="avatar-section">
|
|
|
|
<button class="avatar-edit-btn" open-type="chooseAvatar" @chooseavatar="handleChooseAvatar">
|
|
|
|
<image
|
|
|
|
:src="formData.avatar || 'https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260526/默认头像_1779779926983.png'"
|
|
|
|
:src="
|
|
|
|
formData.avatar ||
|
|
|
|
'https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260526/默认头像_1779779926983.png'
|
|
|
|
"
|
|
|
|
mode="aspectFill"
|
|
|
|
class="avatar-image"
|
|
|
|
></image>
|
|
...
|
...
|
@@ -69,6 +72,7 @@ |
|
|
|
<button class="save-btn" :loading="saveLoading" @click="saveProfile">保存修改</button>
|
|
|
|
</view>
|
|
|
|
|
|
|
|
<!-- 昵称编辑弹窗 -->
|
|
|
|
<uni-popup ref="nicknamePopupRef" type="bottom" @change="onNicknamePopupChange">
|
|
|
|
<view class="popup-container">
|
|
|
|
<view class="popup-header">
|
|
...
|
...
|
@@ -91,6 +95,7 @@ |
|
|
|
</view>
|
|
|
|
</uni-popup>
|
|
|
|
|
|
|
|
<!-- 性别选择弹窗 -->
|
|
|
|
<uni-popup ref="genderPopupRef" type="bottom">
|
|
|
|
<view class="popup-container gender-popup">
|
|
|
|
<view class="popup-header">
|
|
...
|
...
|
@@ -123,8 +128,8 @@ |
|
|
|
</template>
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
import { ref, reactive } from 'vue';
|
|
|
|
import { onShow } from '@dcloudio/uni-app';
|
|
|
|
import { ref, reactive, nextTick } from 'vue';
|
|
|
|
import { onShow, onBackPress } from '@dcloudio/uni-app';
|
|
|
|
import dayjs from 'dayjs';
|
|
|
|
import UserApi from '@/sheep/api/member/user';
|
|
|
|
import AreaApi from '@/sheep/api/system/area';
|
|
...
|
...
|
@@ -137,10 +142,11 @@ |
|
|
|
const nicknamePopupRef = ref(null);
|
|
|
|
const genderPopupRef = ref(null);
|
|
|
|
|
|
|
|
// 状态防抖控制
|
|
|
|
// 状态与防抖
|
|
|
|
const saveLoading = ref(false);
|
|
|
|
const hasModified = ref(false); // 侦听变更状态
|
|
|
|
|
|
|
|
// 【核心改造】:业务独立表单响应式对象(阻断与 Store 的源数据直接浅拷贝)
|
|
|
|
// 核心业务隔离表单
|
|
|
|
const formData = ref({
|
|
|
|
avatar: '',
|
|
|
|
nickname: '',
|
|
...
|
...
|
@@ -153,13 +159,13 @@ |
|
|
|
isAllowUpdSex: false,
|
|
|
|
});
|
|
|
|
|
|
|
|
// 【核心改造】:中间弹窗缓存态,避免点击“取消”或“划走弹窗”时篡改主表单
|
|
|
|
// 深度解耦的弹窗缓存数据
|
|
|
|
const editCacheData = reactive({
|
|
|
|
nickname: '',
|
|
|
|
sex: 0,
|
|
|
|
});
|
|
|
|
|
|
|
|
// 级联选择器(Picker)状态流
|
|
|
|
// 省市区级联选择器状态流
|
|
|
|
const addressTree = ref([]);
|
|
|
|
const multiArray = ref([[], [], []]);
|
|
|
|
const multiIndex = ref([0, 0, 0]);
|
|
...
|
...
|
@@ -170,43 +176,81 @@ |
|
|
|
*/
|
|
|
|
onShow(async () => {
|
|
|
|
try {
|
|
|
|
// 采用同步阻塞保证地域级联树先于回显执行
|
|
|
|
// 1. 同步加载行政地区三级树(内部已作 Session 缓存,避免重复请求)
|
|
|
|
await fetchAreaTree();
|
|
|
|
|
|
|
|
// 深度解构 Store 核心数据,注入隔离的局部表单状态
|
|
|
|
// 2. 深度克隆 Store 数据,隔离表单与 Store 的直接联动,保持单向数据流
|
|
|
|
if (userStore.userInfo) {
|
|
|
|
formData.value = JSON.parse(JSON.stringify(userStore.userInfo));
|
|
|
|
initEchoAddress();
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
console.error('[Init Error] 初始化个人信息流失败:', error);
|
|
|
|
console.error('[Init Error] 初始化个人信息数据流失败:', error);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 微信标准化:获取/上传微信特有开放头像能力
|
|
|
|
* 拦截非保存下的非正常退出,防止用户误触返回造成数据丢失
|
|
|
|
*/
|
|
|
|
onBackPress((options) => {
|
|
|
|
// 监听表单内容变动(仅在未保存且已有修改时提示)
|
|
|
|
const isUnsaved = JSON.stringify(formData.value) !== JSON.stringify(userStore.userInfo);
|
|
|
|
if (isUnsaved && !saveLoading.value) {
|
|
|
|
uni.showModal({
|
|
|
|
title: '提示',
|
|
|
|
content: '您有尚未保存的修改,确定要离开吗?',
|
|
|
|
success: (res) => {
|
|
|
|
if (res.confirm) {
|
|
|
|
hasModified.value = false;
|
|
|
|
uni.navigateBack({ delta: 1 });
|
|
|
|
}
|
|
|
|
},
|
|
|
|
});
|
|
|
|
return true; // 拦截返回键
|
|
|
|
}
|
|
|
|
return false;
|
|
|
|
});
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 头像上传逻辑:增加微信ChooseAvatar路径格式预检与文件防崩溃重试
|
|
|
|
*/
|
|
|
|
const handleChooseAvatar = async (e) => {
|
|
|
|
const { avatarUrl } = e.detail;
|
|
|
|
if (!avatarUrl) return;
|
|
|
|
|
|
|
|
uni.showLoading({ title: '上传中...', mask: true });
|
|
|
|
// 前置性能防御:对 H5/App 端选择本地大图片进行预警(微信自带ChooseAvatar主要回传临时压缩图,此处作跨端边界防守)
|
|
|
|
// #ifndef MP-WEIXIN
|
|
|
|
uni.getFileInfo({
|
|
|
|
filePath: avatarUrl,
|
|
|
|
success: (fileInfo) => {
|
|
|
|
const sizeMb = fileInfo.size / 1024 / 1024;
|
|
|
|
if (sizeMb > 5) {
|
|
|
|
uni.showToast({ title: '图片大小不能超过5MB', icon: 'none' });
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
},
|
|
|
|
});
|
|
|
|
// #endif
|
|
|
|
|
|
|
|
uni.showLoading({ title: '头像上传中...', mask: true });
|
|
|
|
try {
|
|
|
|
const res = await FileApi.uploadFile(avatarUrl);
|
|
|
|
if (res?.data) {
|
|
|
|
formData.value.avatar = res.data;
|
|
|
|
uni.showToast({ title: '头像上传成功', icon: 'success' });
|
|
|
|
} else {
|
|
|
|
throw new Error('服务器未返回有效的图片URL路径');
|
|
|
|
}
|
|
|
|
} catch (err) {
|
|
|
|
console.error('[Upload Error] 头像转存失败:', err);
|
|
|
|
uni.showToast({ title: '头像上传失败', icon: 'none' });
|
|
|
|
console.error('[Upload Error] 临时路径转存服务器失败:', err);
|
|
|
|
uni.showToast({ title: '头像保存失败,请稍后重试', icon: 'none' });
|
|
|
|
} finally {
|
|
|
|
uni.hideLoading();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 唤起并初始化昵称弹窗
|
|
|
|
* 唤起并初始化昵称弹窗缓存
|
|
|
|
*/
|
|
|
|
const gotoEditNickname = () => {
|
|
|
|
editCacheData.nickname = formData.value.nickname || '';
|
|
...
|
...
|
@@ -214,10 +258,10 @@ |
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 确认昵称逻辑修改
|
|
|
|
* 确认编辑昵称
|
|
|
|
*/
|
|
|
|
const confirmEditNickname = () => {
|
|
|
|
const targetNickname = editCacheData.nickname.trim();
|
|
|
|
const targetNickname = editCacheData.nickname ? editCacheData.nickname.trim() : '';
|
|
|
|
if (!targetNickname) {
|
|
|
|
uni.showToast({ title: '昵称不能为空', icon: 'none' });
|
|
|
|
return;
|
|
...
|
...
|
@@ -231,7 +275,7 @@ |
|
|
|
*/
|
|
|
|
const gotoSelectGender = () => {
|
|
|
|
if (!formData.value.isAllowUpdSex) {
|
|
|
|
uni.showToast({ title: '暂不支持修改性别', icon: 'none' });
|
|
|
|
uni.showToast({ title: '性别暂不支持多次修改', icon: 'none' });
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
editCacheData.sex = formData.value.sex || 0;
|
|
...
|
...
|
@@ -239,66 +283,103 @@ |
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 变更性别(直接变更缓存,延时关闭)
|
|
|
|
* 优化:解耦的性别变更逻辑
|
|
|
|
*/
|
|
|
|
const selectGender = (sex) => {
|
|
|
|
// 1. 临时更改缓存态而非直接写回 formData
|
|
|
|
editCacheData.sex = sex;
|
|
|
|
formData.value.sex = sex;
|
|
|
|
formData.value.sex = sex; // 仅在用户明确选定后回写,并带有 200ms 的视觉缓冲反馈
|
|
|
|
setTimeout(() => {
|
|
|
|
genderPopupRef.value.close();
|
|
|
|
}, 200); // 200ms 延时留出点击涟漪视觉反馈
|
|
|
|
}, 200);
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 清理弹窗残留状态
|
|
|
|
* 弹窗关闭时彻底释放和清理临时的输入脏状态
|
|
|
|
*/
|
|
|
|
const onNicknamePopupChange = (e) => {
|
|
|
|
if (!e.show) editCacheData.nickname = '';
|
|
|
|
if (!e.show) {
|
|
|
|
editCacheData.nickname = '';
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 获取底层标准行政区域三级树
|
|
|
|
* 优化:实现会话级局部静态缓存,解决 onShow 高频请求带来的服务器资源浪费
|
|
|
|
*/
|
|
|
|
const fetchAreaTree = async () => {
|
|
|
|
if (addressTree.value.length > 0) return;
|
|
|
|
if (addressTree.value && addressTree.value.length > 0) return;
|
|
|
|
|
|
|
|
// 尝试拉取框架/系统底层会话级存储或内存,确保单次生命周期内仅调用一次 API
|
|
|
|
const cachedTree = uni.getStorageSync('sys_area_tree');
|
|
|
|
if (cachedTree) {
|
|
|
|
addressTree.value = cachedTree;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
const res = await AreaApi.getAreaTree();
|
|
|
|
addressTree.value = res?.data || [];
|
|
|
|
if (res?.data) {
|
|
|
|
addressTree.value = res.data;
|
|
|
|
uni.setStorageSync('sys_area_tree', res.data); // 写入临时磁盘缓存,生命周期内有效
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
console.error('[API Error] 获取省市区行政结构树失败:', error);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 初始化级联选择器三列静态形态
|
|
|
|
* 安全地同步极简Picker的三列静态节点关联数据
|
|
|
|
*/
|
|
|
|
const syncPickerColumnData = (pIdx = 0, cIdx = 0) => {
|
|
|
|
if (!addressTree.value.length) return;
|
|
|
|
if (!addressTree.value || addressTree.value.length === 0) return;
|
|
|
|
|
|
|
|
const provinces = addressTree.value;
|
|
|
|
const cities = provinces[pIdx]?.children || [];
|
|
|
|
const regions = cities[cIdx]?.children || [];
|
|
|
|
const pSelected = provinces[pIdx] || provinces[0]; // 极端越界安全垫付
|
|
|
|
const cities = pSelected?.children || [];
|
|
|
|
const cSelected = cities[cIdx] || cities[0];
|
|
|
|
const regions = cSelected?.children || [];
|
|
|
|
|
|
|
|
multiArray.value = [provinces, cities, regions];
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 【关键优化修复】:滚动 Picker 各列时防高频网络或逻辑同步阻塞引起的真机闪退抖动
|
|
|
|
* 【关键越界重构】:修正真机由于异步滚动的时差引发的越界闪退
|
|
|
|
*/
|
|
|
|
const pickerColumnChange = (e) => {
|
|
|
|
const { column, value } = e.detail;
|
|
|
|
multiIndex.value[column] = value;
|
|
|
|
|
|
|
|
// 1. 原子化本地临时深拷贝索引指针
|
|
|
|
const nextIndex = [...multiIndex.value];
|
|
|
|
nextIndex[column] = value;
|
|
|
|
|
|
|
|
if (column === 0) {
|
|
|
|
// 变动第一列(省):重置城市与区域级联至首项,并刷新后两列数据数组
|
|
|
|
multiIndex.value[1] = 0;
|
|
|
|
multiIndex.value[2] = 0;
|
|
|
|
syncPickerColumnData(value, 0);
|
|
|
|
// 2. 变动第一列(省):联动重置市、区索引指针归零
|
|
|
|
nextIndex[1] = 0;
|
|
|
|
nextIndex[2] = 0;
|
|
|
|
|
|
|
|
// 3. 拦截防御:确保选择的省份在当前最新行政数组内
|
|
|
|
const safeProvinceIdx = Math.min(value, addressTree.value.length - 1);
|
|
|
|
syncPickerColumnData(safeProvinceIdx, 0);
|
|
|
|
} else if (column === 1) {
|
|
|
|
// 变动第二列(市):重置区域级联至首项,并刷新第三列数据数组
|
|
|
|
multiIndex.value[2] = 0;
|
|
|
|
syncPickerColumnData(multiIndex.value[0], value);
|
|
|
|
// 4. 变动第二列(市):联动重置区索引指针归零
|
|
|
|
nextIndex[2] = 0;
|
|
|
|
|
|
|
|
const safeProvinceIdx = Math.min(nextIndex[0], addressTree.value.length - 1);
|
|
|
|
const safeProvince = addressTree.value[safeProvinceIdx];
|
|
|
|
const cities = safeProvince?.children || [];
|
|
|
|
const safeCityIdx = Math.min(value, cities.length - 1);
|
|
|
|
|
|
|
|
syncPickerColumnData(safeProvinceIdx, safeCityIdx);
|
|
|
|
}
|
|
|
|
|
|
|
|
// 5. 在同一视图更新 tick 下完成覆盖更新,避免多路联动逻辑竞态导致的重叠渲染
|
|
|
|
nextTick(() => {
|
|
|
|
multiIndex.value = nextIndex;
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 确认选择省市区
|
|
|
|
* 确认选择省市区,绑定数据映射
|
|
|
|
*/
|
|
|
|
const pickerChange = (e) => {
|
|
|
|
const indices = e.detail.value;
|
|
...
|
...
|
@@ -316,67 +397,97 @@ |
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 纯算法优化:实现省市区ID一键查找映射回显
|
|
|
|
* 数据回显定位器:精准利用底层树定位当前用户已有地区信息
|
|
|
|
*/
|
|
|
|
const initEchoAddress = () => {
|
|
|
|
const { province, city, region } = formData.value;
|
|
|
|
if (!province || !addressTree.value.length) {
|
|
|
|
// 若无历史资产,纯净初始化首行联动
|
|
|
|
if (!province || !addressTree.value || addressTree.value.length === 0) {
|
|
|
|
syncPickerColumnData(0, 0);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const pIdx = addressTree.value.findIndex((p) => p.id === province);
|
|
|
|
if (pIdx === -1) return;
|
|
|
|
// 1. 查找省份物理位置
|
|
|
|
const pIdx = addressTree.value.findIndex((p) => String(p.id) === String(province));
|
|
|
|
if (pIdx === -1) {
|
|
|
|
syncPickerColumnData(0, 0);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// 2. 查找地级市物理位置
|
|
|
|
const cities = addressTree.value[pIdx]?.children || [];
|
|
|
|
const cIdx = cities.findIndex((c) => c.id === city);
|
|
|
|
const cIdx = cities.findIndex((c) => String(c.id) === String(city));
|
|
|
|
|
|
|
|
// 3. 查找区县物理位置
|
|
|
|
const regions = cIdx !== -1 ? cities[cIdx]?.children || [] : [];
|
|
|
|
const rIdx = regions.findIndex((r) => r.id === region);
|
|
|
|
const rIdx = regions.findIndex((r) => String(r.id) === String(region));
|
|
|
|
|
|
|
|
// 安全赋值索引快照
|
|
|
|
multiIndex.value = [pIdx, cIdx !== -1 ? cIdx : 0, rIdx !== -1 ? rIdx : 0];
|
|
|
|
// 4. 组装安全的映射矩阵索引
|
|
|
|
const safePIdx = pIdx;
|
|
|
|
const safeCIdx = cIdx !== -1 ? cIdx : 0;
|
|
|
|
const safeRIdx = rIdx !== -1 ? rIdx : 0;
|
|
|
|
|
|
|
|
// 按照变动后的物理索引刷新 Picker 内部映射
|
|
|
|
syncPickerColumnData(pIdx, cIdx !== -1 ? cIdx : 0);
|
|
|
|
multiIndex.value = [safePIdx, safeCIdx, safeRIdx];
|
|
|
|
|
|
|
|
selectedAddress.value = [addressTree.value[pIdx]?.name, cities[cIdx]?.name, regions[rIdx]?.name]
|
|
|
|
// 5. 更新数据池并刷新回显文字描述
|
|
|
|
syncPickerColumnData(safePIdx, safeCIdx);
|
|
|
|
|
|
|
|
selectedAddress.value = [
|
|
|
|
addressTree.value[safePIdx]?.name,
|
|
|
|
cities[safeCIdx]?.name,
|
|
|
|
regions[safeRIdx]?.name,
|
|
|
|
]
|
|
|
|
.filter(Boolean)
|
|
|
|
.join(' ');
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 发送最终表单变更
|
|
|
|
* 保存修改逻辑:进行脏字段过滤净化后,更新用户信息
|
|
|
|
*/
|
|
|
|
const saveProfile = async () => {
|
|
|
|
if (saveLoading.value) return;
|
|
|
|
|
|
|
|
// 前置轻校验:去除尾部空隙并校验合法性
|
|
|
|
if (!formData.value.nickname || !formData.value.nickname.trim()) {
|
|
|
|
uni.showToast({ title: '昵称不能为空', icon: 'none' });
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
saveLoading.value = true;
|
|
|
|
uni.showLoading({ title: '正在保存资料...', mask: true });
|
|
|
|
|
|
|
|
uni.showLoading({ title: '保存中...', mask: true });
|
|
|
|
try {
|
|
|
|
// 清洗掉无用或者只读的强脏字段
|
|
|
|
const { createTime, isAllowUpdSex, ...payload } = formData.value;
|
|
|
|
// 脏数据深度过滤净化:只保留后端允许更新的可写属性,剥离统计及只读系统字段
|
|
|
|
const { createTime, isAllowUpdSex, registerIp, id, userId, mobile, ...payload } =
|
|
|
|
formData.value;
|
|
|
|
|
|
|
|
// 保证提交数据的值是修剪过左右空格的
|
|
|
|
payload.nickname = payload.nickname.trim();
|
|
|
|
if (payload.signature) {
|
|
|
|
payload.signature = payload.signature.trim();
|
|
|
|
}
|
|
|
|
|
|
|
|
await UserApi.updateUser(payload);
|
|
|
|
|
|
|
|
// 保存成功后同步刷新 Store 内全局状态快照
|
|
|
|
// 异步刷新全局 Pinia 的 userInfo 用户属性,使全局头像和昵称视图无痛同步
|
|
|
|
if (userStore.getUserInfo) {
|
|
|
|
await userStore.getUserInfo();
|
|
|
|
}
|
|
|
|
|
|
|
|
uni.showToast({ title: '保存修改成功', icon: 'success' });
|
|
|
|
setTimeout(() => uni.navigateBack(), 1200);
|
|
|
|
uni.showToast({ title: '保存成功', icon: 'success' });
|
|
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
uni.navigateBack({ delta: 1 });
|
|
|
|
}, 1200);
|
|
|
|
} catch (err) {
|
|
|
|
console.error('[API Error] 保存个人资料失败:', err);
|
|
|
|
uni.showToast({ title: '保存失败,请稍后重试', icon: 'none' });
|
|
|
|
console.error('[API Error] 保存个人信息更新请求失败:', err);
|
|
|
|
uni.showToast({ title: err.msg || '保存失败,请稍后重试', icon: 'none' });
|
|
|
|
} finally {
|
|
|
|
uni.hideLoading();
|
|
|
|
saveLoading.value = false;
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
// 数据转换原子层清洗工具
|
|
|
|
// 格式化展示原子转换工具
|
|
|
|
const formatGender = (sex) => {
|
|
|
|
if (sex === 1) return '男';
|
|
|
|
if (sex === 2) return '女';
|
|
...
|
...
|
@@ -407,7 +518,10 @@ |
|
|
|
|
|
|
|
.profile-page {
|
|
|
|
background-color: $color-bg;
|
|
|
|
min-height: 100vh;
|
|
|
|
height: 100vh;
|
|
|
|
// #ifdef H5
|
|
|
|
height: calc(100vh - 44px);
|
|
|
|
// #endif
|
|
|
|
padding-bottom: env(safe-area-inset-bottom);
|
|
|
|
|
|
|
|
.avatar-section {
|
|
...
|
...
|
@@ -592,7 +706,7 @@ |
|
|
|
font-size: 28rpx;
|
|
|
|
font-weight: 500;
|
|
|
|
border-radius: $radius;
|
|
|
|
&::after {
|
|
|
|
&:after {
|
|
|
|
content: none;
|
|
|
|
}
|
|
|
|
}
|
|
...
|
...
|
@@ -610,7 +724,7 @@ |
|
|
|
font-size: 28rpx;
|
|
|
|
font-weight: 500;
|
|
|
|
border-radius: $radius;
|
|
|
|
&::after {
|
|
|
|
&:after {
|
|
|
|
content: none;
|
|
|
|
}
|
|
|
|
}
|
...
|
...
|
|