Showing
7 changed files
with
754 additions
and
115 deletions
| @@ -8,7 +8,7 @@ SHOPRO_BASE_URL=http://mall.hcxtec.com | @@ -8,7 +8,7 @@ SHOPRO_BASE_URL=http://mall.hcxtec.com | ||
| 8 | # 后端接口 - 测试环境(通过 process.env.NODE_ENV = development) | 8 | # 后端接口 - 测试环境(通过 process.env.NODE_ENV = development) |
| 9 | SHOPRO_DEV_BASE_URL=http://192.168.1.200:48081 | 9 | SHOPRO_DEV_BASE_URL=http://192.168.1.200:48081 |
| 10 | # SHOPRO_DEV_BASE_URL=http://192.168.1.85:48080 | 10 | # SHOPRO_DEV_BASE_URL=http://192.168.1.85:48080 |
| 11 | -SHOPRO_DEV_BASE_URL=https://fitness.hcxtec.com | 11 | +# SHOPRO_DEV_BASE_URL=https://fitness.hcxtec.com |
| 12 | # SHOPRO_DEV_BASE_URL=http://api-dashboard.yudao.iocoder.cn/ | 12 | # SHOPRO_DEV_BASE_URL=http://api-dashboard.yudao.iocoder.cn/ |
| 13 | ### SHOPRO_DEV_BASE_URL=http://10.171.1.188:48080 | 13 | ### SHOPRO_DEV_BASE_URL=http://10.171.1.188:48080 |
| 14 | ### SHOPRO_DEV_BASE_URL = http://yunai.natapp1.cc | 14 | ### SHOPRO_DEV_BASE_URL = http://yunai.natapp1.cc |
| @@ -144,6 +144,13 @@ | @@ -144,6 +144,13 @@ | ||
| 144 | "navigationBarTitleText": "登录", | 144 | "navigationBarTitleText": "登录", |
| 145 | "navigationStyle": "default" | 145 | "navigationStyle": "default" |
| 146 | } | 146 | } |
| 147 | + }, | ||
| 148 | + { | ||
| 149 | + "path": "pages/index/reset-password", | ||
| 150 | + "style": { | ||
| 151 | + "navigationBarTitleText": "找回密码", | ||
| 152 | + "navigationStyle": "default" | ||
| 153 | + } | ||
| 147 | } | 154 | } |
| 148 | ] | 155 | ] |
| 149 | }, | 156 | }, |
| @@ -3,7 +3,10 @@ | @@ -3,7 +3,10 @@ | ||
| 3 | <view class="avatar-section"> | 3 | <view class="avatar-section"> |
| 4 | <button class="avatar-edit-btn" open-type="chooseAvatar" @chooseavatar="handleChooseAvatar"> | 4 | <button class="avatar-edit-btn" open-type="chooseAvatar" @chooseavatar="handleChooseAvatar"> |
| 5 | <image | 5 | <image |
| 6 | - :src="formData.avatar || 'https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260526/默认头像_1779779926983.png'" | 6 | + :src=" |
| 7 | + formData.avatar || | ||
| 8 | + 'https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260526/默认头像_1779779926983.png' | ||
| 9 | + " | ||
| 7 | mode="aspectFill" | 10 | mode="aspectFill" |
| 8 | class="avatar-image" | 11 | class="avatar-image" |
| 9 | ></image> | 12 | ></image> |
| @@ -69,6 +72,7 @@ | @@ -69,6 +72,7 @@ | ||
| 69 | <button class="save-btn" :loading="saveLoading" @click="saveProfile">保存修改</button> | 72 | <button class="save-btn" :loading="saveLoading" @click="saveProfile">保存修改</button> |
| 70 | </view> | 73 | </view> |
| 71 | 74 | ||
| 75 | + <!-- 昵称编辑弹窗 --> | ||
| 72 | <uni-popup ref="nicknamePopupRef" type="bottom" @change="onNicknamePopupChange"> | 76 | <uni-popup ref="nicknamePopupRef" type="bottom" @change="onNicknamePopupChange"> |
| 73 | <view class="popup-container"> | 77 | <view class="popup-container"> |
| 74 | <view class="popup-header"> | 78 | <view class="popup-header"> |
| @@ -91,6 +95,7 @@ | @@ -91,6 +95,7 @@ | ||
| 91 | </view> | 95 | </view> |
| 92 | </uni-popup> | 96 | </uni-popup> |
| 93 | 97 | ||
| 98 | + <!-- 性别选择弹窗 --> | ||
| 94 | <uni-popup ref="genderPopupRef" type="bottom"> | 99 | <uni-popup ref="genderPopupRef" type="bottom"> |
| 95 | <view class="popup-container gender-popup"> | 100 | <view class="popup-container gender-popup"> |
| 96 | <view class="popup-header"> | 101 | <view class="popup-header"> |
| @@ -123,8 +128,8 @@ | @@ -123,8 +128,8 @@ | ||
| 123 | </template> | 128 | </template> |
| 124 | 129 | ||
| 125 | <script setup> | 130 | <script setup> |
| 126 | - import { ref, reactive } from 'vue'; | ||
| 127 | - import { onShow } from '@dcloudio/uni-app'; | 131 | + import { ref, reactive, nextTick } from 'vue'; |
| 132 | + import { onShow, onBackPress } from '@dcloudio/uni-app'; | ||
| 128 | import dayjs from 'dayjs'; | 133 | import dayjs from 'dayjs'; |
| 129 | import UserApi from '@/sheep/api/member/user'; | 134 | import UserApi from '@/sheep/api/member/user'; |
| 130 | import AreaApi from '@/sheep/api/system/area'; | 135 | import AreaApi from '@/sheep/api/system/area'; |
| @@ -137,10 +142,11 @@ | @@ -137,10 +142,11 @@ | ||
| 137 | const nicknamePopupRef = ref(null); | 142 | const nicknamePopupRef = ref(null); |
| 138 | const genderPopupRef = ref(null); | 143 | const genderPopupRef = ref(null); |
| 139 | 144 | ||
| 140 | - // 状态防抖控制 | 145 | + // 状态与防抖 |
| 141 | const saveLoading = ref(false); | 146 | const saveLoading = ref(false); |
| 147 | + const hasModified = ref(false); // 侦听变更状态 | ||
| 142 | 148 | ||
| 143 | - // 【核心改造】:业务独立表单响应式对象(阻断与 Store 的源数据直接浅拷贝) | 149 | + // 核心业务隔离表单 |
| 144 | const formData = ref({ | 150 | const formData = ref({ |
| 145 | avatar: '', | 151 | avatar: '', |
| 146 | nickname: '', | 152 | nickname: '', |
| @@ -153,13 +159,13 @@ | @@ -153,13 +159,13 @@ | ||
| 153 | isAllowUpdSex: false, | 159 | isAllowUpdSex: false, |
| 154 | }); | 160 | }); |
| 155 | 161 | ||
| 156 | - // 【核心改造】:中间弹窗缓存态,避免点击“取消”或“划走弹窗”时篡改主表单 | 162 | + // 深度解耦的弹窗缓存数据 |
| 157 | const editCacheData = reactive({ | 163 | const editCacheData = reactive({ |
| 158 | nickname: '', | 164 | nickname: '', |
| 159 | sex: 0, | 165 | sex: 0, |
| 160 | }); | 166 | }); |
| 161 | 167 | ||
| 162 | - // 级联选择器(Picker)状态流 | 168 | + // 省市区级联选择器状态流 |
| 163 | const addressTree = ref([]); | 169 | const addressTree = ref([]); |
| 164 | const multiArray = ref([[], [], []]); | 170 | const multiArray = ref([[], [], []]); |
| 165 | const multiIndex = ref([0, 0, 0]); | 171 | const multiIndex = ref([0, 0, 0]); |
| @@ -170,43 +176,81 @@ | @@ -170,43 +176,81 @@ | ||
| 170 | */ | 176 | */ |
| 171 | onShow(async () => { | 177 | onShow(async () => { |
| 172 | try { | 178 | try { |
| 173 | - // 采用同步阻塞保证地域级联树先于回显执行 | 179 | + // 1. 同步加载行政地区三级树(内部已作 Session 缓存,避免重复请求) |
| 174 | await fetchAreaTree(); | 180 | await fetchAreaTree(); |
| 175 | 181 | ||
| 176 | - // 深度解构 Store 核心数据,注入隔离的局部表单状态 | 182 | + // 2. 深度克隆 Store 数据,隔离表单与 Store 的直接联动,保持单向数据流 |
| 177 | if (userStore.userInfo) { | 183 | if (userStore.userInfo) { |
| 178 | formData.value = JSON.parse(JSON.stringify(userStore.userInfo)); | 184 | formData.value = JSON.parse(JSON.stringify(userStore.userInfo)); |
| 179 | initEchoAddress(); | 185 | initEchoAddress(); |
| 180 | } | 186 | } |
| 181 | } catch (error) { | 187 | } catch (error) { |
| 182 | - console.error('[Init Error] 初始化个人信息流失败:', error); | 188 | + console.error('[Init Error] 初始化个人信息数据流失败:', error); |
| 183 | } | 189 | } |
| 184 | }); | 190 | }); |
| 185 | 191 | ||
| 186 | /** | 192 | /** |
| 187 | - * 微信标准化:获取/上传微信特有开放头像能力 | 193 | + * 拦截非保存下的非正常退出,防止用户误触返回造成数据丢失 |
| 194 | + */ | ||
| 195 | + onBackPress((options) => { | ||
| 196 | + // 监听表单内容变动(仅在未保存且已有修改时提示) | ||
| 197 | + const isUnsaved = JSON.stringify(formData.value) !== JSON.stringify(userStore.userInfo); | ||
| 198 | + if (isUnsaved && !saveLoading.value) { | ||
| 199 | + uni.showModal({ | ||
| 200 | + title: '提示', | ||
| 201 | + content: '您有尚未保存的修改,确定要离开吗?', | ||
| 202 | + success: (res) => { | ||
| 203 | + if (res.confirm) { | ||
| 204 | + hasModified.value = false; | ||
| 205 | + uni.navigateBack({ delta: 1 }); | ||
| 206 | + } | ||
| 207 | + }, | ||
| 208 | + }); | ||
| 209 | + return true; // 拦截返回键 | ||
| 210 | + } | ||
| 211 | + return false; | ||
| 212 | + }); | ||
| 213 | + | ||
| 214 | + /** | ||
| 215 | + * 头像上传逻辑:增加微信ChooseAvatar路径格式预检与文件防崩溃重试 | ||
| 188 | */ | 216 | */ |
| 189 | const handleChooseAvatar = async (e) => { | 217 | const handleChooseAvatar = async (e) => { |
| 190 | const { avatarUrl } = e.detail; | 218 | const { avatarUrl } = e.detail; |
| 191 | if (!avatarUrl) return; | 219 | if (!avatarUrl) return; |
| 192 | 220 | ||
| 193 | - uni.showLoading({ title: '上传中...', mask: true }); | 221 | + // 前置性能防御:对 H5/App 端选择本地大图片进行预警(微信自带ChooseAvatar主要回传临时压缩图,此处作跨端边界防守) |
| 222 | + // #ifndef MP-WEIXIN | ||
| 223 | + uni.getFileInfo({ | ||
| 224 | + filePath: avatarUrl, | ||
| 225 | + success: (fileInfo) => { | ||
| 226 | + const sizeMb = fileInfo.size / 1024 / 1024; | ||
| 227 | + if (sizeMb > 5) { | ||
| 228 | + uni.showToast({ title: '图片大小不能超过5MB', icon: 'none' }); | ||
| 229 | + return; | ||
| 230 | + } | ||
| 231 | + }, | ||
| 232 | + }); | ||
| 233 | + // #endif | ||
| 234 | + | ||
| 235 | + uni.showLoading({ title: '头像上传中...', mask: true }); | ||
| 194 | try { | 236 | try { |
| 195 | const res = await FileApi.uploadFile(avatarUrl); | 237 | const res = await FileApi.uploadFile(avatarUrl); |
| 196 | if (res?.data) { | 238 | if (res?.data) { |
| 197 | formData.value.avatar = res.data; | 239 | formData.value.avatar = res.data; |
| 198 | uni.showToast({ title: '头像上传成功', icon: 'success' }); | 240 | uni.showToast({ title: '头像上传成功', icon: 'success' }); |
| 241 | + } else { | ||
| 242 | + throw new Error('服务器未返回有效的图片URL路径'); | ||
| 199 | } | 243 | } |
| 200 | } catch (err) { | 244 | } catch (err) { |
| 201 | - console.error('[Upload Error] 头像转存失败:', err); | ||
| 202 | - uni.showToast({ title: '头像上传失败', icon: 'none' }); | 245 | + console.error('[Upload Error] 临时路径转存服务器失败:', err); |
| 246 | + uni.showToast({ title: '头像保存失败,请稍后重试', icon: 'none' }); | ||
| 203 | } finally { | 247 | } finally { |
| 204 | uni.hideLoading(); | 248 | uni.hideLoading(); |
| 205 | } | 249 | } |
| 206 | }; | 250 | }; |
| 207 | 251 | ||
| 208 | /** | 252 | /** |
| 209 | - * 唤起并初始化昵称弹窗 | 253 | + * 唤起并初始化昵称弹窗缓存 |
| 210 | */ | 254 | */ |
| 211 | const gotoEditNickname = () => { | 255 | const gotoEditNickname = () => { |
| 212 | editCacheData.nickname = formData.value.nickname || ''; | 256 | editCacheData.nickname = formData.value.nickname || ''; |
| @@ -214,10 +258,10 @@ | @@ -214,10 +258,10 @@ | ||
| 214 | }; | 258 | }; |
| 215 | 259 | ||
| 216 | /** | 260 | /** |
| 217 | - * 确认昵称逻辑修改 | 261 | + * 确认编辑昵称 |
| 218 | */ | 262 | */ |
| 219 | const confirmEditNickname = () => { | 263 | const confirmEditNickname = () => { |
| 220 | - const targetNickname = editCacheData.nickname.trim(); | 264 | + const targetNickname = editCacheData.nickname ? editCacheData.nickname.trim() : ''; |
| 221 | if (!targetNickname) { | 265 | if (!targetNickname) { |
| 222 | uni.showToast({ title: '昵称不能为空', icon: 'none' }); | 266 | uni.showToast({ title: '昵称不能为空', icon: 'none' }); |
| 223 | return; | 267 | return; |
| @@ -231,7 +275,7 @@ | @@ -231,7 +275,7 @@ | ||
| 231 | */ | 275 | */ |
| 232 | const gotoSelectGender = () => { | 276 | const gotoSelectGender = () => { |
| 233 | if (!formData.value.isAllowUpdSex) { | 277 | if (!formData.value.isAllowUpdSex) { |
| 234 | - uni.showToast({ title: '暂不支持修改性别', icon: 'none' }); | 278 | + uni.showToast({ title: '性别暂不支持多次修改', icon: 'none' }); |
| 235 | return; | 279 | return; |
| 236 | } | 280 | } |
| 237 | editCacheData.sex = formData.value.sex || 0; | 281 | editCacheData.sex = formData.value.sex || 0; |
| @@ -239,66 +283,103 @@ | @@ -239,66 +283,103 @@ | ||
| 239 | }; | 283 | }; |
| 240 | 284 | ||
| 241 | /** | 285 | /** |
| 242 | - * 变更性别(直接变更缓存,延时关闭) | 286 | + * 优化:解耦的性别变更逻辑 |
| 243 | */ | 287 | */ |
| 244 | const selectGender = (sex) => { | 288 | const selectGender = (sex) => { |
| 289 | + // 1. 临时更改缓存态而非直接写回 formData | ||
| 245 | editCacheData.sex = sex; | 290 | editCacheData.sex = sex; |
| 246 | - formData.value.sex = sex; | 291 | + formData.value.sex = sex; // 仅在用户明确选定后回写,并带有 200ms 的视觉缓冲反馈 |
| 247 | setTimeout(() => { | 292 | setTimeout(() => { |
| 248 | genderPopupRef.value.close(); | 293 | genderPopupRef.value.close(); |
| 249 | - }, 200); // 200ms 延时留出点击涟漪视觉反馈 | 294 | + }, 200); |
| 250 | }; | 295 | }; |
| 251 | 296 | ||
| 252 | /** | 297 | /** |
| 253 | - * 清理弹窗残留状态 | 298 | + * 弹窗关闭时彻底释放和清理临时的输入脏状态 |
| 254 | */ | 299 | */ |
| 255 | const onNicknamePopupChange = (e) => { | 300 | const onNicknamePopupChange = (e) => { |
| 256 | - if (!e.show) editCacheData.nickname = ''; | 301 | + if (!e.show) { |
| 302 | + editCacheData.nickname = ''; | ||
| 303 | + } | ||
| 257 | }; | 304 | }; |
| 258 | 305 | ||
| 259 | /** | 306 | /** |
| 260 | - * 获取底层标准行政区域三级树 | 307 | + * 优化:实现会话级局部静态缓存,解决 onShow 高频请求带来的服务器资源浪费 |
| 261 | */ | 308 | */ |
| 262 | const fetchAreaTree = async () => { | 309 | const fetchAreaTree = async () => { |
| 263 | - if (addressTree.value.length > 0) return; | 310 | + if (addressTree.value && addressTree.value.length > 0) return; |
| 311 | + | ||
| 312 | + // 尝试拉取框架/系统底层会话级存储或内存,确保单次生命周期内仅调用一次 API | ||
| 313 | + const cachedTree = uni.getStorageSync('sys_area_tree'); | ||
| 314 | + if (cachedTree) { | ||
| 315 | + addressTree.value = cachedTree; | ||
| 316 | + return; | ||
| 317 | + } | ||
| 318 | + | ||
| 319 | + try { | ||
| 264 | const res = await AreaApi.getAreaTree(); | 320 | const res = await AreaApi.getAreaTree(); |
| 265 | - addressTree.value = res?.data || []; | 321 | + if (res?.data) { |
| 322 | + addressTree.value = res.data; | ||
| 323 | + uni.setStorageSync('sys_area_tree', res.data); // 写入临时磁盘缓存,生命周期内有效 | ||
| 324 | + } | ||
| 325 | + } catch (error) { | ||
| 326 | + console.error('[API Error] 获取省市区行政结构树失败:', error); | ||
| 327 | + } | ||
| 266 | }; | 328 | }; |
| 267 | 329 | ||
| 268 | /** | 330 | /** |
| 269 | - * 初始化级联选择器三列静态形态 | 331 | + * 安全地同步极简Picker的三列静态节点关联数据 |
| 270 | */ | 332 | */ |
| 271 | const syncPickerColumnData = (pIdx = 0, cIdx = 0) => { | 333 | const syncPickerColumnData = (pIdx = 0, cIdx = 0) => { |
| 272 | - if (!addressTree.value.length) return; | 334 | + if (!addressTree.value || addressTree.value.length === 0) return; |
| 273 | 335 | ||
| 274 | const provinces = addressTree.value; | 336 | const provinces = addressTree.value; |
| 275 | - const cities = provinces[pIdx]?.children || []; | ||
| 276 | - const regions = cities[cIdx]?.children || []; | 337 | + const pSelected = provinces[pIdx] || provinces[0]; // 极端越界安全垫付 |
| 338 | + const cities = pSelected?.children || []; | ||
| 339 | + const cSelected = cities[cIdx] || cities[0]; | ||
| 340 | + const regions = cSelected?.children || []; | ||
| 277 | 341 | ||
| 278 | multiArray.value = [provinces, cities, regions]; | 342 | multiArray.value = [provinces, cities, regions]; |
| 279 | }; | 343 | }; |
| 280 | 344 | ||
| 281 | /** | 345 | /** |
| 282 | - * 【关键优化修复】:滚动 Picker 各列时防高频网络或逻辑同步阻塞引起的真机闪退抖动 | 346 | + * 【关键越界重构】:修正真机由于异步滚动的时差引发的越界闪退 |
| 283 | */ | 347 | */ |
| 284 | const pickerColumnChange = (e) => { | 348 | const pickerColumnChange = (e) => { |
| 285 | const { column, value } = e.detail; | 349 | const { column, value } = e.detail; |
| 286 | - multiIndex.value[column] = value; | 350 | + |
| 351 | + // 1. 原子化本地临时深拷贝索引指针 | ||
| 352 | + const nextIndex = [...multiIndex.value]; | ||
| 353 | + nextIndex[column] = value; | ||
| 287 | 354 | ||
| 288 | if (column === 0) { | 355 | if (column === 0) { |
| 289 | - // 变动第一列(省):重置城市与区域级联至首项,并刷新后两列数据数组 | ||
| 290 | - multiIndex.value[1] = 0; | ||
| 291 | - multiIndex.value[2] = 0; | ||
| 292 | - syncPickerColumnData(value, 0); | 356 | + // 2. 变动第一列(省):联动重置市、区索引指针归零 |
| 357 | + nextIndex[1] = 0; | ||
| 358 | + nextIndex[2] = 0; | ||
| 359 | + | ||
| 360 | + // 3. 拦截防御:确保选择的省份在当前最新行政数组内 | ||
| 361 | + const safeProvinceIdx = Math.min(value, addressTree.value.length - 1); | ||
| 362 | + syncPickerColumnData(safeProvinceIdx, 0); | ||
| 293 | } else if (column === 1) { | 363 | } else if (column === 1) { |
| 294 | - // 变动第二列(市):重置区域级联至首项,并刷新第三列数据数组 | ||
| 295 | - multiIndex.value[2] = 0; | ||
| 296 | - syncPickerColumnData(multiIndex.value[0], value); | 364 | + // 4. 变动第二列(市):联动重置区索引指针归零 |
| 365 | + nextIndex[2] = 0; | ||
| 366 | + | ||
| 367 | + const safeProvinceIdx = Math.min(nextIndex[0], addressTree.value.length - 1); | ||
| 368 | + const safeProvince = addressTree.value[safeProvinceIdx]; | ||
| 369 | + const cities = safeProvince?.children || []; | ||
| 370 | + const safeCityIdx = Math.min(value, cities.length - 1); | ||
| 371 | + | ||
| 372 | + syncPickerColumnData(safeProvinceIdx, safeCityIdx); | ||
| 297 | } | 373 | } |
| 374 | + | ||
| 375 | + // 5. 在同一视图更新 tick 下完成覆盖更新,避免多路联动逻辑竞态导致的重叠渲染 | ||
| 376 | + nextTick(() => { | ||
| 377 | + multiIndex.value = nextIndex; | ||
| 378 | + }); | ||
| 298 | }; | 379 | }; |
| 299 | 380 | ||
| 300 | /** | 381 | /** |
| 301 | - * 确认选择省市区 | 382 | + * 确认选择省市区,绑定数据映射 |
| 302 | */ | 383 | */ |
| 303 | const pickerChange = (e) => { | 384 | const pickerChange = (e) => { |
| 304 | const indices = e.detail.value; | 385 | const indices = e.detail.value; |
| @@ -316,67 +397,97 @@ | @@ -316,67 +397,97 @@ | ||
| 316 | }; | 397 | }; |
| 317 | 398 | ||
| 318 | /** | 399 | /** |
| 319 | - * 纯算法优化:实现省市区ID一键查找映射回显 | 400 | + * 数据回显定位器:精准利用底层树定位当前用户已有地区信息 |
| 320 | */ | 401 | */ |
| 321 | const initEchoAddress = () => { | 402 | const initEchoAddress = () => { |
| 322 | const { province, city, region } = formData.value; | 403 | const { province, city, region } = formData.value; |
| 323 | - if (!province || !addressTree.value.length) { | ||
| 324 | - // 若无历史资产,纯净初始化首行联动 | 404 | + if (!province || !addressTree.value || addressTree.value.length === 0) { |
| 325 | syncPickerColumnData(0, 0); | 405 | syncPickerColumnData(0, 0); |
| 326 | return; | 406 | return; |
| 327 | } | 407 | } |
| 328 | 408 | ||
| 329 | - const pIdx = addressTree.value.findIndex((p) => p.id === province); | ||
| 330 | - if (pIdx === -1) return; | 409 | + // 1. 查找省份物理位置 |
| 410 | + const pIdx = addressTree.value.findIndex((p) => String(p.id) === String(province)); | ||
| 411 | + if (pIdx === -1) { | ||
| 412 | + syncPickerColumnData(0, 0); | ||
| 413 | + return; | ||
| 414 | + } | ||
| 331 | 415 | ||
| 416 | + // 2. 查找地级市物理位置 | ||
| 332 | const cities = addressTree.value[pIdx]?.children || []; | 417 | const cities = addressTree.value[pIdx]?.children || []; |
| 333 | - const cIdx = cities.findIndex((c) => c.id === city); | 418 | + const cIdx = cities.findIndex((c) => String(c.id) === String(city)); |
| 334 | 419 | ||
| 420 | + // 3. 查找区县物理位置 | ||
| 335 | const regions = cIdx !== -1 ? cities[cIdx]?.children || [] : []; | 421 | const regions = cIdx !== -1 ? cities[cIdx]?.children || [] : []; |
| 336 | - const rIdx = regions.findIndex((r) => r.id === region); | 422 | + const rIdx = regions.findIndex((r) => String(r.id) === String(region)); |
| 337 | 423 | ||
| 338 | - // 安全赋值索引快照 | ||
| 339 | - multiIndex.value = [pIdx, cIdx !== -1 ? cIdx : 0, rIdx !== -1 ? rIdx : 0]; | 424 | + // 4. 组装安全的映射矩阵索引 |
| 425 | + const safePIdx = pIdx; | ||
| 426 | + const safeCIdx = cIdx !== -1 ? cIdx : 0; | ||
| 427 | + const safeRIdx = rIdx !== -1 ? rIdx : 0; | ||
| 340 | 428 | ||
| 341 | - // 按照变动后的物理索引刷新 Picker 内部映射 | ||
| 342 | - syncPickerColumnData(pIdx, cIdx !== -1 ? cIdx : 0); | 429 | + multiIndex.value = [safePIdx, safeCIdx, safeRIdx]; |
| 343 | 430 | ||
| 344 | - selectedAddress.value = [addressTree.value[pIdx]?.name, cities[cIdx]?.name, regions[rIdx]?.name] | 431 | + // 5. 更新数据池并刷新回显文字描述 |
| 432 | + syncPickerColumnData(safePIdx, safeCIdx); | ||
| 433 | + | ||
| 434 | + selectedAddress.value = [ | ||
| 435 | + addressTree.value[safePIdx]?.name, | ||
| 436 | + cities[safeCIdx]?.name, | ||
| 437 | + regions[safeRIdx]?.name, | ||
| 438 | + ] | ||
| 345 | .filter(Boolean) | 439 | .filter(Boolean) |
| 346 | .join(' '); | 440 | .join(' '); |
| 347 | }; | 441 | }; |
| 348 | 442 | ||
| 349 | /** | 443 | /** |
| 350 | - * 发送最终表单变更 | 444 | + * 保存修改逻辑:进行脏字段过滤净化后,更新用户信息 |
| 351 | */ | 445 | */ |
| 352 | const saveProfile = async () => { | 446 | const saveProfile = async () => { |
| 353 | if (saveLoading.value) return; | 447 | if (saveLoading.value) return; |
| 448 | + | ||
| 449 | + // 前置轻校验:去除尾部空隙并校验合法性 | ||
| 450 | + if (!formData.value.nickname || !formData.value.nickname.trim()) { | ||
| 451 | + uni.showToast({ title: '昵称不能为空', icon: 'none' }); | ||
| 452 | + return; | ||
| 453 | + } | ||
| 454 | + | ||
| 354 | saveLoading.value = true; | 455 | saveLoading.value = true; |
| 456 | + uni.showLoading({ title: '正在保存资料...', mask: true }); | ||
| 355 | 457 | ||
| 356 | - uni.showLoading({ title: '保存中...', mask: true }); | ||
| 357 | try { | 458 | try { |
| 358 | - // 清洗掉无用或者只读的强脏字段 | ||
| 359 | - const { createTime, isAllowUpdSex, ...payload } = formData.value; | 459 | + // 脏数据深度过滤净化:只保留后端允许更新的可写属性,剥离统计及只读系统字段 |
| 460 | + const { createTime, isAllowUpdSex, registerIp, id, userId, mobile, ...payload } = | ||
| 461 | + formData.value; | ||
| 462 | + | ||
| 463 | + // 保证提交数据的值是修剪过左右空格的 | ||
| 464 | + payload.nickname = payload.nickname.trim(); | ||
| 465 | + if (payload.signature) { | ||
| 466 | + payload.signature = payload.signature.trim(); | ||
| 467 | + } | ||
| 360 | 468 | ||
| 361 | await UserApi.updateUser(payload); | 469 | await UserApi.updateUser(payload); |
| 362 | 470 | ||
| 363 | - // 保存成功后同步刷新 Store 内全局状态快照 | 471 | + // 异步刷新全局 Pinia 的 userInfo 用户属性,使全局头像和昵称视图无痛同步 |
| 364 | if (userStore.getUserInfo) { | 472 | if (userStore.getUserInfo) { |
| 365 | await userStore.getUserInfo(); | 473 | await userStore.getUserInfo(); |
| 366 | } | 474 | } |
| 367 | 475 | ||
| 368 | - uni.showToast({ title: '保存修改成功', icon: 'success' }); | ||
| 369 | - setTimeout(() => uni.navigateBack(), 1200); | 476 | + uni.showToast({ title: '保存成功', icon: 'success' }); |
| 477 | + | ||
| 478 | + setTimeout(() => { | ||
| 479 | + uni.navigateBack({ delta: 1 }); | ||
| 480 | + }, 1200); | ||
| 370 | } catch (err) { | 481 | } catch (err) { |
| 371 | - console.error('[API Error] 保存个人资料失败:', err); | ||
| 372 | - uni.showToast({ title: '保存失败,请稍后重试', icon: 'none' }); | 482 | + console.error('[API Error] 保存个人信息更新请求失败:', err); |
| 483 | + uni.showToast({ title: err.msg || '保存失败,请稍后重试', icon: 'none' }); | ||
| 373 | } finally { | 484 | } finally { |
| 374 | uni.hideLoading(); | 485 | uni.hideLoading(); |
| 375 | saveLoading.value = false; | 486 | saveLoading.value = false; |
| 376 | } | 487 | } |
| 377 | }; | 488 | }; |
| 378 | 489 | ||
| 379 | - // 数据转换原子层清洗工具 | 490 | + // 格式化展示原子转换工具 |
| 380 | const formatGender = (sex) => { | 491 | const formatGender = (sex) => { |
| 381 | if (sex === 1) return '男'; | 492 | if (sex === 1) return '男'; |
| 382 | if (sex === 2) return '女'; | 493 | if (sex === 2) return '女'; |
| @@ -407,7 +518,10 @@ | @@ -407,7 +518,10 @@ | ||
| 407 | 518 | ||
| 408 | .profile-page { | 519 | .profile-page { |
| 409 | background-color: $color-bg; | 520 | background-color: $color-bg; |
| 410 | - min-height: 100vh; | 521 | + height: 100vh; |
| 522 | + // #ifdef H5 | ||
| 523 | + height: calc(100vh - 44px); | ||
| 524 | + // #endif | ||
| 411 | padding-bottom: env(safe-area-inset-bottom); | 525 | padding-bottom: env(safe-area-inset-bottom); |
| 412 | 526 | ||
| 413 | .avatar-section { | 527 | .avatar-section { |
| @@ -592,7 +706,7 @@ | @@ -592,7 +706,7 @@ | ||
| 592 | font-size: 28rpx; | 706 | font-size: 28rpx; |
| 593 | font-weight: 500; | 707 | font-weight: 500; |
| 594 | border-radius: $radius; | 708 | border-radius: $radius; |
| 595 | - &::after { | 709 | + &:after { |
| 596 | content: none; | 710 | content: none; |
| 597 | } | 711 | } |
| 598 | } | 712 | } |
| @@ -610,7 +724,7 @@ | @@ -610,7 +724,7 @@ | ||
| 610 | font-size: 28rpx; | 724 | font-size: 28rpx; |
| 611 | font-weight: 500; | 725 | font-weight: 500; |
| 612 | border-radius: $radius; | 726 | border-radius: $radius; |
| 613 | - &::after { | 727 | + &:after { |
| 614 | content: none; | 728 | content: none; |
| 615 | } | 729 | } |
| 616 | } | 730 | } |
| @@ -3,19 +3,8 @@ | @@ -3,19 +3,8 @@ | ||
| 3 | <!-- 1. Logo 及品牌信息 --> | 3 | <!-- 1. Logo 及品牌信息 --> |
| 4 | <view class="logo-section"> | 4 | <view class="logo-section"> |
| 5 | <view class="logo-box"> | 5 | <view class="logo-box"> |
| 6 | - <!-- 渐变绿火焰 SVG 矢量图,无需本地图片 --> | ||
| 7 | - <svg viewBox="0 0 24 24" class="logo-svg"> | ||
| 8 | - <path | ||
| 9 | - d="M12 2C12 2 6 7.5 6 13C6 16.8 9.1 20 12 20C14.9 20 18 16.8 18 13C18 7.5 12 2 12 2ZM12 16C10.3 16 9 14.7 9 13C9 10.5 12 7.5 12 7.5C12 7.5 15 10.5 15 13C15 14.7 13.7 16 12 16Z" | ||
| 10 | - fill="url(#flameGradient)" | ||
| 11 | - /> | ||
| 12 | - <defs> | ||
| 13 | - <linearGradient id="flameGradient" x1="0%" y1="0%" x2="100%" y2="100%"> | ||
| 14 | - <stop offset="0%" stop-color="#8fc31f" /> | ||
| 15 | - <stop offset="100%" stop-color="#00b074" /> | ||
| 16 | - </linearGradient> | ||
| 17 | - </defs> | ||
| 18 | - </svg> | 6 | + <!-- 优化:为了兼容微信小程序等跨端环境,原生 SVG 转换为 Base64 嵌入标准 image 标签中,确保完美渲染 --> |
| 7 | + <image class="logo-img" :src="logoSvgBase64" mode="aspectFit" /> | ||
| 19 | </view> | 8 | </view> |
| 20 | <view class="brand-title">FitFlow 悦动健身</view> | 9 | <view class="brand-title">FitFlow 悦动健身</view> |
| 21 | <view class="brand-slogan">健康极简 · 开启你的蜕变时刻</view> | 10 | <view class="brand-slogan">健康极简 · 开启你的蜕变时刻</view> |
| @@ -23,7 +12,7 @@ | @@ -23,7 +12,7 @@ | ||
| 23 | 12 | ||
| 24 | <!-- 2. 登录方式 Tab 切换 --> | 13 | <!-- 2. 登录方式 Tab 切换 --> |
| 25 | <view class="tab-container"> | 14 | <view class="tab-container"> |
| 26 | - <view class="tab-item" :class="{ active: activeTab === 'sms' }" @click="activeTab = 'sms'"> | 15 | + <view class="tab-item" :class="{ active: activeTab === 'sms' }" @click="switchTab('sms')"> |
| 27 | <u-icon | 16 | <u-icon |
| 28 | name="phone" | 17 | name="phone" |
| 29 | size="18" | 18 | size="18" |
| @@ -35,7 +24,7 @@ | @@ -35,7 +24,7 @@ | ||
| 35 | <view | 24 | <view |
| 36 | class="tab-item" | 25 | class="tab-item" |
| 37 | :class="{ active: activeTab === 'password' }" | 26 | :class="{ active: activeTab === 'password' }" |
| 38 | - @click="activeTab = 'password'" | 27 | + @click="switchTab('password')" |
| 39 | > | 28 | > |
| 40 | <u-icon | 29 | <u-icon |
| 41 | name="lock" | 30 | name="lock" |
| @@ -81,7 +70,11 @@ | @@ -81,7 +70,11 @@ | ||
| 81 | /> | 70 | /> |
| 82 | </view> | 71 | </view> |
| 83 | <!-- 获取验证码按钮 --> | 72 | <!-- 获取验证码按钮 --> |
| 84 | - <view class="code-btn" :class="{ disabled: countdown > 0 }" @click="handleGetCode"> | 73 | + <view |
| 74 | + class="code-btn" | ||
| 75 | + :class="{ disabled: countdown > 0 || isSending }" | ||
| 76 | + @click="handleGetCode" | ||
| 77 | + > | ||
| 85 | <text>{{ countdown > 0 ? `${countdown}s 后重试` : '获取验证码' }}</text> | 78 | <text>{{ countdown > 0 ? `${countdown}s 后重试` : '获取验证码' }}</text> |
| 86 | </view> | 79 | </view> |
| 87 | </view> | 80 | </view> |
| @@ -98,11 +91,15 @@ | @@ -98,11 +91,15 @@ | ||
| 98 | placeholder="请输入您的密码" | 91 | placeholder="请输入您的密码" |
| 99 | placeholder-style="color: #a1a8b3" | 92 | placeholder-style="color: #a1a8b3" |
| 100 | class="native-input" | 93 | class="native-input" |
| 94 | + :password="true" | ||
| 101 | /> | 95 | /> |
| 102 | </view> | 96 | </view> |
| 103 | </view> | 97 | </view> |
| 104 | </view> | 98 | </view> |
| 105 | - | 99 | + <!-- 忘记密码 --> |
| 100 | + <view class="forget-password" v-if="activeTab === 'password'"> | ||
| 101 | + <view class="link-text" @click.stop="goToForgetPassword"> 忘记密码? </view> | ||
| 102 | + </view> | ||
| 106 | <!-- 4. 用户协议与隐私政策 --> | 103 | <!-- 4. 用户协议与隐私政策 --> |
| 107 | <view class="agreement-container" @click="isAgreed = !isAgreed"> | 104 | <view class="agreement-container" @click="isAgreed = !isAgreed"> |
| 108 | <view class="checkbox-box" :class="{ checked: isAgreed }"> | 105 | <view class="checkbox-box" :class="{ checked: isAgreed }"> |
| @@ -117,15 +114,21 @@ | @@ -117,15 +114,21 @@ | ||
| 117 | </view> | 114 | </view> |
| 118 | 115 | ||
| 119 | <!-- 5. 立即登录按钮 --> | 116 | <!-- 5. 立即登录按钮 --> |
| 120 | - <view class="submit-btn" @click="handleLogin"> | ||
| 121 | - <text>立即登录</text> | ||
| 122 | - <u-icon name="arrow-right" size="14" color="#ffffff" class="btn-arrow" /> | 117 | + <view class="submit-btn" :class="{ 'disabled-btn': isSubmitting }" @click="handleLogin"> |
| 118 | + <text>{{ isSubmitting ? '登录中...' : '立即登录' }}</text> | ||
| 123 | </view> | 119 | </view> |
| 124 | </view> | 120 | </view> |
| 125 | </template> | 121 | </template> |
| 126 | 122 | ||
| 127 | <script setup> | 123 | <script setup> |
| 128 | - import { ref } from 'vue'; | 124 | + import { ref, onUnmounted } from 'vue'; |
| 125 | + import sheep from '@/sheep'; | ||
| 126 | + import AuthUtil from '@/sheep/api/member/auth'; | ||
| 127 | + import { onHide } from '@dcloudio/uni-app'; | ||
| 128 | + | ||
| 129 | + // 用于跨端兼容渲染的 Base64 编码火焰 SVG (绿渐变) | ||
| 130 | + const logoSvgBase64 = | ||
| 131 | + 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs><linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="%238fc31f" /><stop offset="100%" stop-color="%2300b074" /></linearGradient></defs><path d="M12 2C12 2 6 7.5 6 13C6 16.8 9.1 20 12 20C14.9 20 18 16.8 18 13C18 7.5 12 2 12 2ZM12 16C10.3 16 9 14.7 9 13C9 10.5 12 7.5 12 7.5C12 7.5 15 10.5 15 13C15 14.7 13.7 16 12 16Z" fill="url(%23g)" /></svg>'; | ||
| 129 | 132 | ||
| 130 | const activeTab = ref('sms'); // sms: 免密, password: 密码 | 133 | const activeTab = ref('sms'); // sms: 免密, password: 密码 |
| 131 | const phoneNumber = ref(''); | 134 | const phoneNumber = ref(''); |
| @@ -133,51 +136,197 @@ | @@ -133,51 +136,197 @@ | ||
| 133 | const password = ref(''); | 136 | const password = ref(''); |
| 134 | const isAgreed = ref(false); | 137 | const isAgreed = ref(false); |
| 135 | 138 | ||
| 139 | + // 防抖及加载动画状态变量 | ||
| 140 | + const isSending = ref(false); | ||
| 141 | + const isSubmitting = ref(false); | ||
| 142 | + | ||
| 136 | // 验证码倒计时逻辑 | 143 | // 验证码倒计时逻辑 |
| 137 | const countdown = ref(0); | 144 | const countdown = ref(0); |
| 138 | - const handleGetCode = () => { | ||
| 139 | - if (countdown.value > 0) return; | ||
| 140 | - if (!phoneNumber.value || phoneNumber.value.length !== 11) { | ||
| 141 | - uni.showToast({ title: '请输入正确的手机号码', icon: 'none' }); | 145 | + let timer = null; |
| 146 | + | ||
| 147 | + // 验证手机号码格式 | ||
| 148 | + const validatePhone = (phone) => { | ||
| 149 | + return /^1[3-9]\d{9}$/.test(phone); | ||
| 150 | + }; | ||
| 151 | + | ||
| 152 | + // 切换Tab,同时清理另一个 Tab 的输入 | ||
| 153 | + const switchTab = (type) => { | ||
| 154 | + activeTab.value = type; | ||
| 155 | + verifyCode.value = ''; | ||
| 156 | + password.value = ''; | ||
| 157 | + }; | ||
| 158 | + | ||
| 159 | + // 发送验证码 | ||
| 160 | + const handleGetCode = async () => { | ||
| 161 | + if (countdown.value > 0 || isSending.value) return; | ||
| 162 | + | ||
| 163 | + // 前置逻辑校验 | ||
| 164 | + if (!phoneNumber.value) { | ||
| 165 | + uni.showToast({ title: '请输入手机号码', icon: 'none' }); | ||
| 166 | + return; | ||
| 167 | + } | ||
| 168 | + if (!validatePhone(phoneNumber.value)) { | ||
| 169 | + uni.showToast({ title: '请输入正确的11位手机号码', icon: 'none' }); | ||
| 142 | return; | 170 | return; |
| 143 | } | 171 | } |
| 144 | 172 | ||
| 173 | + isSending.value = true; | ||
| 174 | + try { | ||
| 175 | + // 成功发送验证码请求 | ||
| 176 | + const res = await AuthUtil.sendSmsCode({ phone: phoneNumber.value }); | ||
| 177 | + | ||
| 145 | uni.showToast({ title: '验证码已发送', icon: 'success' }); | 178 | uni.showToast({ title: '验证码已发送', icon: 'success' }); |
| 146 | countdown.value = 60; | 179 | countdown.value = 60; |
| 147 | - const timer = setInterval(() => { | 180 | + timer = setInterval(() => { |
| 148 | countdown.value--; | 181 | countdown.value--; |
| 149 | if (countdown.value <= 0) { | 182 | if (countdown.value <= 0) { |
| 150 | clearInterval(timer); | 183 | clearInterval(timer); |
| 184 | + timer = null; | ||
| 151 | } | 185 | } |
| 152 | }, 1000); | 186 | }, 1000); |
| 187 | + } catch (err) { | ||
| 188 | + console.error('发送验证码失败异常:', err); | ||
| 189 | + } finally { | ||
| 190 | + isSending.value = false; | ||
| 191 | + } | ||
| 153 | }; | 192 | }; |
| 154 | 193 | ||
| 155 | - // 登录提交事件 | ||
| 156 | - const handleLogin = () => { | 194 | + // 登录按钮提交逻辑 |
| 195 | + const handleLogin = async () => { | ||
| 196 | + if (isSubmitting.value) return; | ||
| 197 | + | ||
| 198 | + // 1. 用户协议验证 | ||
| 157 | if (!isAgreed.value) { | 199 | if (!isAgreed.value) { |
| 158 | - uni.showToast({ title: '请先阅读并同意用户协议与隐私政策', icon: 'none' }); | 200 | + uni.showToast({ title: '请先阅读并同意用户协议及隐私政策', icon: 'none' }); |
| 159 | return; | 201 | return; |
| 160 | } | 202 | } |
| 203 | + | ||
| 204 | + // 2. 基础手机号格式验证 | ||
| 161 | if (!phoneNumber.value) { | 205 | if (!phoneNumber.value) { |
| 162 | uni.showToast({ title: '请输入手机号码', icon: 'none' }); | 206 | uni.showToast({ title: '请输入手机号码', icon: 'none' }); |
| 163 | return; | 207 | return; |
| 164 | } | 208 | } |
| 209 | + if (!validatePhone(phoneNumber.value)) { | ||
| 210 | + uni.showToast({ title: '请输入正确的手机号码', icon: 'none' }); | ||
| 211 | + return; | ||
| 212 | + } | ||
| 213 | + | ||
| 214 | + // 3. Tab特定表单验证并请求 | ||
| 215 | + if (activeTab.value === 'sms') { | ||
| 216 | + if (!verifyCode.value) { | ||
| 217 | + uni.showToast({ title: '请输入验证码', icon: 'none' }); | ||
| 218 | + return; | ||
| 219 | + } | ||
| 220 | + if (verifyCode.value.length < 4) { | ||
| 221 | + uni.showToast({ title: '请输入完整的验证码', icon: 'none' }); | ||
| 222 | + return; | ||
| 223 | + } | ||
| 224 | + await performLogin('sms'); | ||
| 225 | + } else { | ||
| 226 | + if (!password.value) { | ||
| 227 | + uni.showToast({ title: '请输入密码', icon: 'none' }); | ||
| 228 | + return; | ||
| 229 | + } | ||
| 230 | + await performLogin('password'); | ||
| 231 | + } | ||
| 232 | + }; | ||
| 233 | + | ||
| 234 | + // 执行最终请求与状态写入 | ||
| 235 | + const performLogin = async (type) => { | ||
| 236 | + isSubmitting.value = true; | ||
| 237 | + uni.showLoading({ title: '正在登录...', mask: true }); | ||
| 238 | + | ||
| 239 | + try { | ||
| 240 | + let res = null; | ||
| 241 | + if (type === 'sms') { | ||
| 242 | + // 修复:原代码使用的 AuthApi 变更为导入的 AuthUtil 服务 | ||
| 243 | + res = await AuthUtil.loginBySms({ | ||
| 244 | + phone: phoneNumber.value, | ||
| 245 | + code: verifyCode.value, | ||
| 246 | + }); | ||
| 247 | + } else { | ||
| 248 | + res = await AuthUtil.loginByPhonePassword({ | ||
| 249 | + phone: phoneNumber.value, | ||
| 250 | + password: password.value, | ||
| 251 | + }); | ||
| 252 | + } | ||
| 253 | + | ||
| 254 | + const authData = res.data || {}; | ||
| 255 | + | ||
| 256 | + if (authData && authData.accessToken) { | ||
| 257 | + // 1. 取得芋道商城 Pinia 内置的 user Store | ||
| 258 | + const userStore = sheep.$store('user'); | ||
| 259 | + | ||
| 260 | + // 2. 写入 Token 到 Pinia 及持久态 LocalStorage | ||
| 261 | + userStore.setToken(authData.accessToken, authData.refreshToken || ''); | ||
| 262 | + | ||
| 263 | + // 3. 登录成功后,主动拉取一次用户信息同步至本地状态 | ||
| 264 | + await userStore.getInfo(); | ||
| 265 | + | ||
| 266 | + uni.showToast({ | ||
| 267 | + title: '登录成功', | ||
| 268 | + icon: 'success', | ||
| 269 | + }); | ||
| 270 | + | ||
| 271 | + // 4. 跳转至目标页面 | ||
| 272 | + setTimeout(() => { | ||
| 273 | + uni.switchTab({ | ||
| 274 | + url: '/pages/xunji/xunji', | ||
| 275 | + }); | ||
| 276 | + }, 1200); | ||
| 277 | + } else { | ||
| 278 | + uni.showToast({ | ||
| 279 | + title: res.msg || '登录失败,未获取到有效凭证', | ||
| 280 | + icon: 'none', | ||
| 281 | + }); | ||
| 282 | + } | ||
| 283 | + } catch (err) { | ||
| 284 | + console.error('登录逻辑异常:', err); | ||
| 285 | + } finally { | ||
| 286 | + isSubmitting.value = false; | ||
| 287 | + uni.hideLoading(); | ||
| 288 | + } | ||
| 289 | + }; | ||
| 165 | 290 | ||
| 166 | - uni.showToast({ title: '登录成功', icon: 'success' }); | 291 | + // 忘记密码跳转 |
| 292 | + const goToForgetPassword = () => { | ||
| 293 | + uni.navigateTo({ | ||
| 294 | + url: '/pages7/pages/index/reset-password', | ||
| 295 | + }); | ||
| 167 | }; | 296 | }; |
| 168 | 297 | ||
| 169 | - // 协议点击跳转 | 298 | + // 协议详情跳转 |
| 170 | const goToAgreement = (type) => { | 299 | const goToAgreement = (type) => { |
| 300 | + const url = | ||
| 301 | + type === 'service' | ||
| 302 | + ? '/pages/public/richtext?id=service' | ||
| 303 | + : '/pages/public/richtext?id=privacy'; | ||
| 304 | + uni.navigateTo({ | ||
| 305 | + url, | ||
| 306 | + fail: () => { | ||
| 171 | uni.showToast({ | 307 | uni.showToast({ |
| 172 | title: `跳转至${type === 'service' ? '用户协议' : '隐私政策'}`, | 308 | title: `跳转至${type === 'service' ? '用户协议' : '隐私政策'}`, |
| 173 | icon: 'none', | 309 | icon: 'none', |
| 174 | }); | 310 | }); |
| 311 | + }, | ||
| 312 | + }); | ||
| 175 | }; | 313 | }; |
| 314 | + | ||
| 315 | + // 页面卸载生命周期:清除定时器,规避潜在的内存泄露 | ||
| 316 | + onHide(() => { | ||
| 317 | + if (timer) { | ||
| 318 | + clearInterval(timer); | ||
| 319 | + timer = null; | ||
| 320 | + } | ||
| 321 | + }); | ||
| 176 | </script> | 322 | </script> |
| 177 | 323 | ||
| 178 | <style lang="scss" scoped> | 324 | <style lang="scss" scoped> |
| 179 | .login-wrapper { | 325 | .login-wrapper { |
| 180 | - min-height: 100vh; | 326 | + height: 100vh; |
| 327 | + // #ifdef H5 | ||
| 328 | + height: calc(100vh - 44px); | ||
| 329 | + // #endif | ||
| 181 | background-color: #ffffff; | 330 | background-color: #ffffff; |
| 182 | padding: 80rpx 50rpx; | 331 | padding: 80rpx 50rpx; |
| 183 | box-sizing: border-box; | 332 | box-sizing: border-box; |
| @@ -202,7 +351,7 @@ | @@ -202,7 +351,7 @@ | ||
| 202 | align-items: center; | 351 | align-items: center; |
| 203 | margin-bottom: 30rpx; | 352 | margin-bottom: 30rpx; |
| 204 | 353 | ||
| 205 | - .logo-svg { | 354 | + .logo-img { |
| 206 | width: 76rpx; | 355 | width: 76rpx; |
| 207 | height: 76rpx; | 356 | height: 76rpx; |
| 208 | } | 357 | } |
| @@ -323,10 +472,18 @@ | @@ -323,10 +472,18 @@ | ||
| 323 | color: #b0b8c4; | 472 | color: #b0b8c4; |
| 324 | background-color: #f4f6fa; | 473 | background-color: #f4f6fa; |
| 325 | border-color: #e1e6eb; | 474 | border-color: #e1e6eb; |
| 475 | + pointer-events: none; // 禁用点击以避免多余的触发 | ||
| 476 | + } | ||
| 326 | } | 477 | } |
| 327 | } | 478 | } |
| 328 | } | 479 | } |
| 329 | } | 480 | } |
| 481 | + | ||
| 482 | + .forget-password { | ||
| 483 | + text-align: right; | ||
| 484 | + margin-top: 20rpx; | ||
| 485 | + font-size: 24rpx; | ||
| 486 | + color: rgb(220, 54, 46); | ||
| 330 | } | 487 | } |
| 331 | 488 | ||
| 332 | // 4. Agreement | 489 | // 4. Agreement |
| @@ -389,6 +546,11 @@ | @@ -389,6 +546,11 @@ | ||
| 389 | opacity: 0.9; | 546 | opacity: 0.9; |
| 390 | } | 547 | } |
| 391 | 548 | ||
| 549 | + &.disabled-btn { | ||
| 550 | + opacity: 0.75; | ||
| 551 | + pointer-events: none; // 阻止后续的多余点击 | ||
| 552 | + } | ||
| 553 | + | ||
| 392 | .btn-arrow { | 554 | .btn-arrow { |
| 393 | margin-left: 10rpx; | 555 | margin-left: 10rpx; |
| 394 | } | 556 | } |
pages7/pages/index/reset-password.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <view class="login-wrapper"> | ||
| 3 | + <!-- 主表单区域:复用与登录页相同的 form-container 设计语言 --> | ||
| 4 | + <view class="form-container"> | ||
| 5 | + <!-- 1. 手机号码 --> | ||
| 6 | + <view class="form-item-wrapper"> | ||
| 7 | + <text class="form-label">手机号码</text> | ||
| 8 | + <view class="input-box"> | ||
| 9 | + <u-icon name="phone" size="20" color="#a1a8b3" class="input-icon" /> | ||
| 10 | + <input | ||
| 11 | + type="number" | ||
| 12 | + v-model="phone" | ||
| 13 | + placeholder="请输入您的手机号" | ||
| 14 | + placeholder-style="color: #a1a8b3" | ||
| 15 | + maxlength="11" | ||
| 16 | + class="native-input" | ||
| 17 | + /> | ||
| 18 | + </view> | ||
| 19 | + </view> | ||
| 20 | + | ||
| 21 | + <!-- 2. 短信验证码:复用登录页免密模式的双列布局 --> | ||
| 22 | + <view class="form-item-wrapper"> | ||
| 23 | + <text class="form-label">验证码</text> | ||
| 24 | + <view class="code-row"> | ||
| 25 | + <view class="input-box flex-1"> | ||
| 26 | + <u-icon name="chat" size="20" color="#a1a8b3" class="input-icon" /> | ||
| 27 | + <input | ||
| 28 | + type="number" | ||
| 29 | + v-model="code" | ||
| 30 | + placeholder="6位验证码" | ||
| 31 | + placeholder-style="color: #a1a8b3" | ||
| 32 | + maxlength="6" | ||
| 33 | + class="native-input" | ||
| 34 | + /> | ||
| 35 | + </view> | ||
| 36 | + <!-- 获取验证码按钮 --> | ||
| 37 | + <view | ||
| 38 | + class="code-btn" | ||
| 39 | + :class="{ disabled: countdown > 0 || isSending }" | ||
| 40 | + @click="handleSendCode" | ||
| 41 | + > | ||
| 42 | + <text>{{ countdown > 0 ? `${countdown}s 后重试` : '获取验证码' }}</text> | ||
| 43 | + </view> | ||
| 44 | + </view> | ||
| 45 | + </view> | ||
| 46 | + | ||
| 47 | + <!-- 3. 设置新密码 --> | ||
| 48 | + <view class="form-item-wrapper"> | ||
| 49 | + <text class="form-label">设置新密码</text> | ||
| 50 | + <view class="input-box"> | ||
| 51 | + <u-icon name="lock" size="20" color="#a1a8b3" class="input-icon" /> | ||
| 52 | + <input | ||
| 53 | + :type="showPassword ? 'text' : 'password'" | ||
| 54 | + v-model="password" | ||
| 55 | + placeholder="请设置您的新密码" | ||
| 56 | + placeholder-style="color: #a1a8b3" | ||
| 57 | + class="native-input" | ||
| 58 | + /> | ||
| 59 | + <u-icon | ||
| 60 | + :name="showPassword ? 'eye-fill' : 'eye'" | ||
| 61 | + size="20" | ||
| 62 | + color="#a1a8b3" | ||
| 63 | + class="eye-icon" | ||
| 64 | + @click="showPassword = !showPassword" | ||
| 65 | + /> | ||
| 66 | + </view> | ||
| 67 | + <text class="input-hint">请设置8~16位包含数字、大小写字母、特殊字符组合作为密码</text> | ||
| 68 | + </view> | ||
| 69 | + | ||
| 70 | + <!-- 4. 确认新密码 --> | ||
| 71 | + <view class="form-item-wrapper"> | ||
| 72 | + <text class="form-label">确认密码</text> | ||
| 73 | + <view class="input-box"> | ||
| 74 | + <u-icon name="lock" size="20" color="#a1a8b3" class="input-icon" /> | ||
| 75 | + <input | ||
| 76 | + :type="showConfirmPassword ? 'text' : 'password'" | ||
| 77 | + v-model="confirmPassword" | ||
| 78 | + placeholder="请再次输入新密码" | ||
| 79 | + placeholder-style="color: #a1a8b3" | ||
| 80 | + class="native-input" | ||
| 81 | + /> | ||
| 82 | + <u-icon | ||
| 83 | + :name="showConfirmPassword ? 'eye-fill' : 'eye'" | ||
| 84 | + size="20" | ||
| 85 | + color="#a1a8b3" | ||
| 86 | + class="eye-icon" | ||
| 87 | + @click="showConfirmPassword = !showConfirmPassword" | ||
| 88 | + /> | ||
| 89 | + </view> | ||
| 90 | + </view> | ||
| 91 | + </view> | ||
| 92 | + | ||
| 93 | + <!-- 5. 提交按钮:复用登录页高质感渐变色圆角按钮 --> | ||
| 94 | + <view class="submit-btn" :class="{ 'disabled-btn': isSubmitting }" @click="handleSubmit"> | ||
| 95 | + <text>{{ isSubmitting ? '保存中...' : '确认修改' }}</text> | ||
| 96 | + </view> | ||
| 97 | + </view> | ||
| 98 | +</template> | ||
| 99 | + | ||
| 100 | +<script setup> | ||
| 101 | + import { ref } from 'vue'; | ||
| 102 | + import AuthUtil from '@/sheep/api/member/auth'; | ||
| 103 | + import { onHide } from '@dcloudio/uni-app'; | ||
| 104 | + const phone = ref(''); | ||
| 105 | + const code = ref(''); | ||
| 106 | + const password = ref(''); | ||
| 107 | + const confirmPassword = ref(''); | ||
| 108 | + | ||
| 109 | + // 密码显示隐藏控制 | ||
| 110 | + const showPassword = ref(false); | ||
| 111 | + const showConfirmPassword = ref(false); | ||
| 112 | + | ||
| 113 | + // 防抖及倒计时状态 | ||
| 114 | + const isSending = ref(false); | ||
| 115 | + const isSubmitting = ref(false); | ||
| 116 | + const countdown = ref(0); | ||
| 117 | + let timer = null; | ||
| 118 | + | ||
| 119 | + // 验证手机号码格式 | ||
| 120 | + const validatePhone = (num) => { | ||
| 121 | + return /^1[3-9]\d{9}$/.test(num); | ||
| 122 | + }; | ||
| 123 | + | ||
| 124 | + // 强密码复杂度检测:8~16位,大小写、数字、特殊字符 | ||
| 125 | + const validatePasswordStrength = (pwd) => { | ||
| 126 | + const reg = | ||
| 127 | + /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&._#^&+=*!~-])[A-Za-z\d@$!%*?&._#^&+=*!~-]{8,16}$/; | ||
| 128 | + return reg.test(pwd); | ||
| 129 | + }; | ||
| 130 | + | ||
| 131 | + // 发送短信验证码 | ||
| 132 | + const handleSendCode = async () => { | ||
| 133 | + if (countdown.value > 0 || isSending.value) return; | ||
| 134 | + | ||
| 135 | + if (!phone.value) { | ||
| 136 | + uni.showToast({ title: '请输入手机号码', icon: 'none' }); | ||
| 137 | + return; | ||
| 138 | + } | ||
| 139 | + if (!validatePhone(phone.value)) { | ||
| 140 | + uni.showToast({ title: '请输入正确的11位手机号码', icon: 'none' }); | ||
| 141 | + return; | ||
| 142 | + } | ||
| 143 | + | ||
| 144 | + isSending.value = true; | ||
| 145 | + try { | ||
| 146 | + const res = await AuthUtil.sendSmsCode({ phone: phone.value }); | ||
| 147 | + if (res && (res.code === 0 || res.data === true)) { | ||
| 148 | + uni.showToast({ title: '验证码已发送', icon: 'success' }); | ||
| 149 | + countdown.value = 60; | ||
| 150 | + timer = setInterval(() => { | ||
| 151 | + countdown.value--; | ||
| 152 | + if (countdown.value <= 0) { | ||
| 153 | + clearInterval(timer); | ||
| 154 | + timer = null; | ||
| 155 | + } | ||
| 156 | + }, 1000); | ||
| 157 | + } else { | ||
| 158 | + uni.showToast({ title: res.msg || '获取验证码失败', icon: 'none' }); | ||
| 159 | + } | ||
| 160 | + } catch (err) { | ||
| 161 | + console.error('发送验证码失败异常:', err); | ||
| 162 | + } finally { | ||
| 163 | + isSending.value = false; | ||
| 164 | + } | ||
| 165 | + }; | ||
| 166 | + | ||
| 167 | + // 保存并提交重置密码 | ||
| 168 | + const handleSubmit = async () => { | ||
| 169 | + if (isSubmitting.value) return; | ||
| 170 | + | ||
| 171 | + if (!phone.value || !validatePhone(phone.value)) { | ||
| 172 | + uni.showToast({ title: '请输入正确的11位手机号码', icon: 'none' }); | ||
| 173 | + return; | ||
| 174 | + } | ||
| 175 | + if (!code.value || code.value.length < 4) { | ||
| 176 | + uni.showToast({ title: '请输入短信验证码', icon: 'none' }); | ||
| 177 | + return; | ||
| 178 | + } | ||
| 179 | + if (!password.value) { | ||
| 180 | + uni.showToast({ title: '请输入您的新密码', icon: 'none' }); | ||
| 181 | + return; | ||
| 182 | + } | ||
| 183 | + // if (!validatePasswordStrength(password.value)) { | ||
| 184 | + // uni.showToast({ title: '密码须为8~16位并包含大小写字母、数字及特殊字符', icon: 'none' }); | ||
| 185 | + // return; | ||
| 186 | + // } | ||
| 187 | + if (password.value !== confirmPassword.value) { | ||
| 188 | + uni.showToast({ title: '两次输入的密码不一致', icon: 'none' }); | ||
| 189 | + return; | ||
| 190 | + } | ||
| 191 | + | ||
| 192 | + isSubmitting.value = true; | ||
| 193 | + uni.showLoading({ title: '正在修改密码...', mask: true }); | ||
| 194 | + | ||
| 195 | + try { | ||
| 196 | + const res = await AuthUtil.setPassword({ | ||
| 197 | + phone: phone.value, | ||
| 198 | + code: code.value, | ||
| 199 | + password: password.value, | ||
| 200 | + }); | ||
| 201 | + | ||
| 202 | + uni.redirectTo({ | ||
| 203 | + url: '/pages7/pages/index/login', | ||
| 204 | + }); | ||
| 205 | + } catch (err) { | ||
| 206 | + console.error('重置密码运行异常:', err); | ||
| 207 | + } finally { | ||
| 208 | + isSubmitting.value = false; | ||
| 209 | + uni.hideLoading(); | ||
| 210 | + } | ||
| 211 | + }; | ||
| 212 | + | ||
| 213 | + // 生命周期管理:清除定时器 | ||
| 214 | + onHide(() => { | ||
| 215 | + if (timer) { | ||
| 216 | + clearInterval(timer); | ||
| 217 | + timer = null; | ||
| 218 | + } | ||
| 219 | + }); | ||
| 220 | +</script> | ||
| 221 | + | ||
| 222 | +<style lang="scss" scoped> | ||
| 223 | + .login-wrapper { | ||
| 224 | + height: 100vh; | ||
| 225 | + // #ifdef H5 | ||
| 226 | + height: calc(100vh - 44px); | ||
| 227 | + // #endif | ||
| 228 | + background-color: #ffffff; | ||
| 229 | + padding: 40rpx 50rpx; // 移除顶部大 Logo 后,缩减顶部 Padding 保持呼吸感 | ||
| 230 | + box-sizing: border-box; | ||
| 231 | + | ||
| 232 | + // 1. 表单容器 | ||
| 233 | + .form-container { | ||
| 234 | + margin-top: 20rpx; | ||
| 235 | + | ||
| 236 | + .form-item-wrapper { | ||
| 237 | + margin-bottom: 36rpx; | ||
| 238 | + | ||
| 239 | + .form-label { | ||
| 240 | + font-size: 26rpx; | ||
| 241 | + color: #0a1931; | ||
| 242 | + font-weight: bold; | ||
| 243 | + display: block; | ||
| 244 | + margin-bottom: 16rpx; | ||
| 245 | + } | ||
| 246 | + | ||
| 247 | + // 输入框提示语 | ||
| 248 | + .input-hint { | ||
| 249 | + display: block; | ||
| 250 | + font-size: 24rpx; | ||
| 251 | + color: #9097a3; | ||
| 252 | + line-height: 1.5; | ||
| 253 | + margin-top: 12rpx; | ||
| 254 | + padding: 0 10rpx; | ||
| 255 | + } | ||
| 256 | + | ||
| 257 | + .input-box { | ||
| 258 | + height: 104rpx; | ||
| 259 | + background-color: #f4f6fa; | ||
| 260 | + border: 2rpx solid #e1e6eb; | ||
| 261 | + border-radius: 24rpx; | ||
| 262 | + display: flex; | ||
| 263 | + align-items: center; | ||
| 264 | + padding: 0 30rpx; | ||
| 265 | + box-sizing: border-box; | ||
| 266 | + | ||
| 267 | + .input-icon { | ||
| 268 | + margin-right: 16rpx; | ||
| 269 | + } | ||
| 270 | + | ||
| 271 | + .eye-icon { | ||
| 272 | + padding: 10rpx; | ||
| 273 | + } | ||
| 274 | + | ||
| 275 | + .native-input { | ||
| 276 | + flex: 1; | ||
| 277 | + height: 100%; | ||
| 278 | + font-size: 28rpx; | ||
| 279 | + color: #0a1931; | ||
| 280 | + } | ||
| 281 | + } | ||
| 282 | + | ||
| 283 | + // 验证码双列布局 | ||
| 284 | + .code-row { | ||
| 285 | + display: flex; | ||
| 286 | + align-items: center; | ||
| 287 | + | ||
| 288 | + .flex-1 { | ||
| 289 | + flex: 1; | ||
| 290 | + } | ||
| 291 | + | ||
| 292 | + .code-btn { | ||
| 293 | + width: 220rpx; | ||
| 294 | + height: 104rpx; | ||
| 295 | + border-radius: 24rpx; | ||
| 296 | + background-color: #f9fdf2; | ||
| 297 | + border: 2rpx solid #d0ee9c; | ||
| 298 | + display: flex; | ||
| 299 | + justify-content: center; | ||
| 300 | + align-items: center; | ||
| 301 | + margin-left: 20rpx; | ||
| 302 | + font-size: 26rpx; | ||
| 303 | + color: #8fc31f; | ||
| 304 | + font-weight: bold; | ||
| 305 | + transition: all 0.2s ease; | ||
| 306 | + | ||
| 307 | + &:active { | ||
| 308 | + opacity: 0.8; | ||
| 309 | + } | ||
| 310 | + | ||
| 311 | + &.disabled { | ||
| 312 | + color: #b0b8c4; | ||
| 313 | + background-color: #f4f6fa; | ||
| 314 | + border-color: #e1e6eb; | ||
| 315 | + pointer-events: none; | ||
| 316 | + } | ||
| 317 | + } | ||
| 318 | + } | ||
| 319 | + } | ||
| 320 | + } | ||
| 321 | + | ||
| 322 | + // 2. 确认修改按钮 (登录页同款高级渐变微立体阴影) | ||
| 323 | + .submit-btn { | ||
| 324 | + height: 104rpx; | ||
| 325 | + border-radius: 24rpx; | ||
| 326 | + background: linear-gradient(135deg, #79d621 0%, #00b074 100%); | ||
| 327 | + box-shadow: 0 16rpx 40rpx rgba(0, 176, 116, 0.25); | ||
| 328 | + display: flex; | ||
| 329 | + justify-content: center; | ||
| 330 | + align-items: center; | ||
| 331 | + color: #ffffff; | ||
| 332 | + font-size: 32rpx; | ||
| 333 | + font-weight: bold; | ||
| 334 | + letter-spacing: 2rpx; | ||
| 335 | + margin-top: 80rpx; | ||
| 336 | + transition: all 0.2s ease; | ||
| 337 | + | ||
| 338 | + &:active { | ||
| 339 | + transform: scale(0.98); | ||
| 340 | + opacity: 0.9; | ||
| 341 | + } | ||
| 342 | + | ||
| 343 | + &.disabled-btn { | ||
| 344 | + opacity: 0.75; | ||
| 345 | + pointer-events: none; | ||
| 346 | + } | ||
| 347 | + | ||
| 348 | + .btn-arrow { | ||
| 349 | + margin-left: 10rpx; | ||
| 350 | + } | ||
| 351 | + } | ||
| 352 | + } | ||
| 353 | +</style> |
| @@ -2,44 +2,37 @@ import request from '@/sheep/request'; | @@ -2,44 +2,37 @@ import request from '@/sheep/request'; | ||
| 2 | 2 | ||
| 3 | const AuthUtil = { | 3 | const AuthUtil = { |
| 4 | // 使用手机 + 密码登录 | 4 | // 使用手机 + 密码登录 |
| 5 | - login: (data) => { | 5 | + loginByPhonePassword: (data) => { |
| 6 | return request({ | 6 | return request({ |
| 7 | - url: '/member/auth/login', | 7 | + url: '/app/auth/loginByPhonePassword', |
| 8 | method: 'POST', | 8 | method: 'POST', |
| 9 | data, | 9 | data, |
| 10 | custom: { | 10 | custom: { |
| 11 | showSuccess: true, | 11 | showSuccess: true, |
| 12 | - loadingMsg: '登录中', | ||
| 13 | successMsg: '登录成功', | 12 | successMsg: '登录成功', |
| 14 | }, | 13 | }, |
| 15 | }); | 14 | }); |
| 16 | }, | 15 | }, |
| 17 | // 使用手机 + 验证码登录 | 16 | // 使用手机 + 验证码登录 |
| 18 | - smsLogin: (data) => { | 17 | + loginBySms: (data) => { |
| 19 | return request({ | 18 | return request({ |
| 20 | - url: '/member/auth/sms-login', | 19 | + url: '/app/auth/loginBySms', |
| 21 | method: 'POST', | 20 | method: 'POST', |
| 22 | data, | 21 | data, |
| 23 | custom: { | 22 | custom: { |
| 24 | showSuccess: true, | 23 | showSuccess: true, |
| 25 | - loadingMsg: '登录中', | ||
| 26 | successMsg: '登录成功', | 24 | successMsg: '登录成功', |
| 27 | }, | 25 | }, |
| 28 | }); | 26 | }); |
| 29 | }, | 27 | }, |
| 30 | // 发送手机验证码 | 28 | // 发送手机验证码 |
| 31 | - sendSmsCode: (mobile, scene) => { | 29 | + sendSmsCode: (data) => { |
| 32 | return request({ | 30 | return request({ |
| 33 | - url: '/member/auth/send-sms-code', | 31 | + url: '/app/auth/sendSmsCode', |
| 34 | method: 'POST', | 32 | method: 'POST', |
| 35 | - data: { | ||
| 36 | - mobile, | ||
| 37 | - scene, | ||
| 38 | - }, | 33 | + data, |
| 39 | custom: { | 34 | custom: { |
| 40 | - loadingMsg: '发送中', | ||
| 41 | - showSuccess: true, | ||
| 42 | - successMsg: '发送成功', | 35 | + showLoading: false, |
| 43 | }, | 36 | }, |
| 44 | }); | 37 | }); |
| 45 | }, | 38 | }, |
| @@ -68,6 +61,16 @@ const AuthUtil = { | @@ -68,6 +61,16 @@ const AuthUtil = { | ||
| 68 | }, | 61 | }, |
| 69 | }); | 62 | }); |
| 70 | }, | 63 | }, |
| 64 | + // 重置密码 | ||
| 65 | + setPassword: (data) => { | ||
| 66 | + return request({ | ||
| 67 | + url: '/app/auth/setPassword', | ||
| 68 | + method: 'POST', | ||
| 69 | + data, | ||
| 70 | + | ||
| 71 | + }); | ||
| 72 | + }, | ||
| 73 | + | ||
| 71 | // 社交授权的跳转 | 74 | // 社交授权的跳转 |
| 72 | socialAuthRedirect: (type, redirectUri) => { | 75 | socialAuthRedirect: (type, redirectUri) => { |
| 73 | return request({ | 76 | return request({ |
| @@ -261,7 +261,7 @@ const UserApi = { | @@ -261,7 +261,7 @@ const UserApi = { | ||
| 261 | // 获取用户基本信息 | 261 | // 获取用户基本信息 |
| 262 | getUserBasicInfo: () => { | 262 | getUserBasicInfo: () => { |
| 263 | return request({ | 263 | return request({ |
| 264 | - url: '/app/student/personData', | 264 | + url: '/app/user/getInfo', |
| 265 | method: 'GET', | 265 | method: 'GET', |
| 266 | custom: { | 266 | custom: { |
| 267 | showLoading: false, | 267 | showLoading: false, |
-
Please register or login to post a comment