Authored by Bad

登录相关

... ... @@ -8,7 +8,7 @@ SHOPRO_BASE_URL=http://mall.hcxtec.com
# 后端接口 - 测试环境(通过 process.env.NODE_ENV = development)
SHOPRO_DEV_BASE_URL=http://192.168.1.200:48081
# SHOPRO_DEV_BASE_URL=http://192.168.1.85:48080
SHOPRO_DEV_BASE_URL=https://fitness.hcxtec.com
# SHOPRO_DEV_BASE_URL=https://fitness.hcxtec.com
# SHOPRO_DEV_BASE_URL=http://api-dashboard.yudao.iocoder.cn/
### SHOPRO_DEV_BASE_URL=http://10.171.1.188:48080
### SHOPRO_DEV_BASE_URL = http://yunai.natapp1.cc
... ...
... ... @@ -144,7 +144,14 @@
"navigationBarTitleText": "登录",
"navigationStyle": "default"
}
}
},
{
"path": "pages/index/reset-password",
"style": {
"navigationBarTitleText": "找回密码",
"navigationStyle": "default"
}
}
]
},
{
... ...
... ... @@ -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;
const res = await AreaApi.getAreaTree();
addressTree.value = res?.data || [];
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();
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));
// 4. 组装安全的映射矩阵索引
const safePIdx = pIdx;
const safeCIdx = cIdx !== -1 ? cIdx : 0;
const safeRIdx = rIdx !== -1 ? rIdx : 0;
// 安全赋值索引快照
multiIndex.value = [pIdx, cIdx !== -1 ? cIdx : 0, rIdx !== -1 ? rIdx : 0];
multiIndex.value = [safePIdx, safeCIdx, safeRIdx];
// 按照变动后的物理索引刷新 Picker 内部映射
syncPickerColumnData(pIdx, cIdx !== -1 ? cIdx : 0);
// 5. 更新数据池并刷新回显文字描述
syncPickerColumnData(safePIdx, safeCIdx);
selectedAddress.value = [addressTree.value[pIdx]?.name, cities[cIdx]?.name, regions[rIdx]?.name]
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;
}
}
... ...
... ... @@ -3,19 +3,8 @@
<!-- 1. Logo 及品牌信息 -->
<view class="logo-section">
<view class="logo-box">
<!-- 渐变绿火焰 SVG 矢量图,无需本地图片 -->
<svg viewBox="0 0 24 24" class="logo-svg">
<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(#flameGradient)"
/>
<defs>
<linearGradient id="flameGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#8fc31f" />
<stop offset="100%" stop-color="#00b074" />
</linearGradient>
</defs>
</svg>
<!-- 优化:为了兼容微信小程序等跨端环境,原生 SVG 转换为 Base64 嵌入标准 image 标签中,确保完美渲染 -->
<image class="logo-img" :src="logoSvgBase64" mode="aspectFit" />
</view>
<view class="brand-title">FitFlow 悦动健身</view>
<view class="brand-slogan">健康极简 · 开启你的蜕变时刻</view>
... ... @@ -23,7 +12,7 @@
<!-- 2. 登录方式 Tab 切换 -->
<view class="tab-container">
<view class="tab-item" :class="{ active: activeTab === 'sms' }" @click="activeTab = 'sms'">
<view class="tab-item" :class="{ active: activeTab === 'sms' }" @click="switchTab('sms')">
<u-icon
name="phone"
size="18"
... ... @@ -35,7 +24,7 @@
<view
class="tab-item"
:class="{ active: activeTab === 'password' }"
@click="activeTab = 'password'"
@click="switchTab('password')"
>
<u-icon
name="lock"
... ... @@ -81,7 +70,11 @@
/>
</view>
<!-- 获取验证码按钮 -->
<view class="code-btn" :class="{ disabled: countdown > 0 }" @click="handleGetCode">
<view
class="code-btn"
:class="{ disabled: countdown > 0 || isSending }"
@click="handleGetCode"
>
<text>{{ countdown > 0 ? `${countdown}s 后重试` : '获取验证码' }}</text>
</view>
</view>
... ... @@ -98,11 +91,15 @@
placeholder="请输入您的密码"
placeholder-style="color: #a1a8b3"
class="native-input"
:password="true"
/>
</view>
</view>
</view>
<!-- 忘记密码 -->
<view class="forget-password" v-if="activeTab === 'password'">
<view class="link-text" @click.stop="goToForgetPassword"> 忘记密码? </view>
</view>
<!-- 4. 用户协议与隐私政策 -->
<view class="agreement-container" @click="isAgreed = !isAgreed">
<view class="checkbox-box" :class="{ checked: isAgreed }">
... ... @@ -117,15 +114,21 @@
</view>
<!-- 5. 立即登录按钮 -->
<view class="submit-btn" @click="handleLogin">
<text>立即登录</text>
<u-icon name="arrow-right" size="14" color="#ffffff" class="btn-arrow" />
<view class="submit-btn" :class="{ 'disabled-btn': isSubmitting }" @click="handleLogin">
<text>{{ isSubmitting ? '登录中...' : '立即登录' }}</text>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue';
import { ref, onUnmounted } from 'vue';
import sheep from '@/sheep';
import AuthUtil from '@/sheep/api/member/auth';
import { onHide } from '@dcloudio/uni-app';
// 用于跨端兼容渲染的 Base64 编码火焰 SVG (绿渐变)
const logoSvgBase64 =
'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>';
const activeTab = ref('sms'); // sms: 免密, password: 密码
const phoneNumber = ref('');
... ... @@ -133,51 +136,197 @@
const password = ref('');
const isAgreed = ref(false);
// 防抖及加载动画状态变量
const isSending = ref(false);
const isSubmitting = ref(false);
// 验证码倒计时逻辑
const countdown = ref(0);
const handleGetCode = () => {
if (countdown.value > 0) return;
if (!phoneNumber.value || phoneNumber.value.length !== 11) {
uni.showToast({ title: '请输入正确的手机号码', icon: 'none' });
let timer = null;
// 验证手机号码格式
const validatePhone = (phone) => {
return /^1[3-9]\d{9}$/.test(phone);
};
// 切换Tab,同时清理另一个 Tab 的输入
const switchTab = (type) => {
activeTab.value = type;
verifyCode.value = '';
password.value = '';
};
// 发送验证码
const handleGetCode = async () => {
if (countdown.value > 0 || isSending.value) return;
// 前置逻辑校验
if (!phoneNumber.value) {
uni.showToast({ title: '请输入手机号码', icon: 'none' });
return;
}
if (!validatePhone(phoneNumber.value)) {
uni.showToast({ title: '请输入正确的11位手机号码', icon: 'none' });
return;
}
uni.showToast({ title: '验证码已发送', icon: 'success' });
countdown.value = 60;
const timer = setInterval(() => {
countdown.value--;
if (countdown.value <= 0) {
clearInterval(timer);
}
}, 1000);
isSending.value = true;
try {
// 成功发送验证码请求
const res = await AuthUtil.sendSmsCode({ phone: phoneNumber.value });
uni.showToast({ title: '验证码已发送', icon: 'success' });
countdown.value = 60;
timer = setInterval(() => {
countdown.value--;
if (countdown.value <= 0) {
clearInterval(timer);
timer = null;
}
}, 1000);
} catch (err) {
console.error('发送验证码失败异常:', err);
} finally {
isSending.value = false;
}
};
// 登录提交事件
const handleLogin = () => {
// 登录按钮提交逻辑
const handleLogin = async () => {
if (isSubmitting.value) return;
// 1. 用户协议验证
if (!isAgreed.value) {
uni.showToast({ title: '请先阅读并同意用户协议隐私政策', icon: 'none' });
uni.showToast({ title: '请先阅读并同意用户协议隐私政策', icon: 'none' });
return;
}
// 2. 基础手机号格式验证
if (!phoneNumber.value) {
uni.showToast({ title: '请输入手机号码', icon: 'none' });
return;
}
if (!validatePhone(phoneNumber.value)) {
uni.showToast({ title: '请输入正确的手机号码', icon: 'none' });
return;
}
uni.showToast({ title: '登录成功', icon: 'success' });
// 3. Tab特定表单验证并请求
if (activeTab.value === 'sms') {
if (!verifyCode.value) {
uni.showToast({ title: '请输入验证码', icon: 'none' });
return;
}
if (verifyCode.value.length < 4) {
uni.showToast({ title: '请输入完整的验证码', icon: 'none' });
return;
}
await performLogin('sms');
} else {
if (!password.value) {
uni.showToast({ title: '请输入密码', icon: 'none' });
return;
}
await performLogin('password');
}
};
// 协议点击跳转
// 执行最终请求与状态写入
const performLogin = async (type) => {
isSubmitting.value = true;
uni.showLoading({ title: '正在登录...', mask: true });
try {
let res = null;
if (type === 'sms') {
// 修复:原代码使用的 AuthApi 变更为导入的 AuthUtil 服务
res = await AuthUtil.loginBySms({
phone: phoneNumber.value,
code: verifyCode.value,
});
} else {
res = await AuthUtil.loginByPhonePassword({
phone: phoneNumber.value,
password: password.value,
});
}
const authData = res.data || {};
if (authData && authData.accessToken) {
// 1. 取得芋道商城 Pinia 内置的 user Store
const userStore = sheep.$store('user');
// 2. 写入 Token 到 Pinia 及持久态 LocalStorage
userStore.setToken(authData.accessToken, authData.refreshToken || '');
// 3. 登录成功后,主动拉取一次用户信息同步至本地状态
await userStore.getInfo();
uni.showToast({
title: '登录成功',
icon: 'success',
});
// 4. 跳转至目标页面
setTimeout(() => {
uni.switchTab({
url: '/pages/xunji/xunji',
});
}, 1200);
} else {
uni.showToast({
title: res.msg || '登录失败,未获取到有效凭证',
icon: 'none',
});
}
} catch (err) {
console.error('登录逻辑异常:', err);
} finally {
isSubmitting.value = false;
uni.hideLoading();
}
};
// 忘记密码跳转
const goToForgetPassword = () => {
uni.navigateTo({
url: '/pages7/pages/index/reset-password',
});
};
// 协议详情跳转
const goToAgreement = (type) => {
uni.showToast({
title: `跳转至${type === 'service' ? '用户协议' : '隐私政策'}`,
icon: 'none',
const url =
type === 'service'
? '/pages/public/richtext?id=service'
: '/pages/public/richtext?id=privacy';
uni.navigateTo({
url,
fail: () => {
uni.showToast({
title: `跳转至${type === 'service' ? '用户协议' : '隐私政策'}`,
icon: 'none',
});
},
});
};
// 页面卸载生命周期:清除定时器,规避潜在的内存泄露
onHide(() => {
if (timer) {
clearInterval(timer);
timer = null;
}
});
</script>
<style lang="scss" scoped>
.login-wrapper {
min-height: 100vh;
height: 100vh;
// #ifdef H5
height: calc(100vh - 44px);
// #endif
background-color: #ffffff;
padding: 80rpx 50rpx;
box-sizing: border-box;
... ... @@ -202,7 +351,7 @@
align-items: center;
margin-bottom: 30rpx;
.logo-svg {
.logo-img {
width: 76rpx;
height: 76rpx;
}
... ... @@ -323,12 +472,20 @@
color: #b0b8c4;
background-color: #f4f6fa;
border-color: #e1e6eb;
pointer-events: none; // 禁用点击以避免多余的触发
}
}
}
}
}
.forget-password {
text-align: right;
margin-top: 20rpx;
font-size: 24rpx;
color: rgb(220, 54, 46);
}
// 4. Agreement
.agreement-container {
display: flex;
... ... @@ -389,6 +546,11 @@
opacity: 0.9;
}
&.disabled-btn {
opacity: 0.75;
pointer-events: none; // 阻止后续的多余点击
}
.btn-arrow {
margin-left: 10rpx;
}
... ...
<template>
<view class="login-wrapper">
<!-- 主表单区域:复用与登录页相同的 form-container 设计语言 -->
<view class="form-container">
<!-- 1. 手机号码 -->
<view class="form-item-wrapper">
<text class="form-label">手机号码</text>
<view class="input-box">
<u-icon name="phone" size="20" color="#a1a8b3" class="input-icon" />
<input
type="number"
v-model="phone"
placeholder="请输入您的手机号"
placeholder-style="color: #a1a8b3"
maxlength="11"
class="native-input"
/>
</view>
</view>
<!-- 2. 短信验证码:复用登录页免密模式的双列布局 -->
<view class="form-item-wrapper">
<text class="form-label">验证码</text>
<view class="code-row">
<view class="input-box flex-1">
<u-icon name="chat" size="20" color="#a1a8b3" class="input-icon" />
<input
type="number"
v-model="code"
placeholder="6位验证码"
placeholder-style="color: #a1a8b3"
maxlength="6"
class="native-input"
/>
</view>
<!-- 获取验证码按钮 -->
<view
class="code-btn"
:class="{ disabled: countdown > 0 || isSending }"
@click="handleSendCode"
>
<text>{{ countdown > 0 ? `${countdown}s 后重试` : '获取验证码' }}</text>
</view>
</view>
</view>
<!-- 3. 设置新密码 -->
<view class="form-item-wrapper">
<text class="form-label">设置新密码</text>
<view class="input-box">
<u-icon name="lock" size="20" color="#a1a8b3" class="input-icon" />
<input
:type="showPassword ? 'text' : 'password'"
v-model="password"
placeholder="请设置您的新密码"
placeholder-style="color: #a1a8b3"
class="native-input"
/>
<u-icon
:name="showPassword ? 'eye-fill' : 'eye'"
size="20"
color="#a1a8b3"
class="eye-icon"
@click="showPassword = !showPassword"
/>
</view>
<text class="input-hint">请设置8~16位包含数字、大小写字母、特殊字符组合作为密码</text>
</view>
<!-- 4. 确认新密码 -->
<view class="form-item-wrapper">
<text class="form-label">确认密码</text>
<view class="input-box">
<u-icon name="lock" size="20" color="#a1a8b3" class="input-icon" />
<input
:type="showConfirmPassword ? 'text' : 'password'"
v-model="confirmPassword"
placeholder="请再次输入新密码"
placeholder-style="color: #a1a8b3"
class="native-input"
/>
<u-icon
:name="showConfirmPassword ? 'eye-fill' : 'eye'"
size="20"
color="#a1a8b3"
class="eye-icon"
@click="showConfirmPassword = !showConfirmPassword"
/>
</view>
</view>
</view>
<!-- 5. 提交按钮:复用登录页高质感渐变色圆角按钮 -->
<view class="submit-btn" :class="{ 'disabled-btn': isSubmitting }" @click="handleSubmit">
<text>{{ isSubmitting ? '保存中...' : '确认修改' }}</text>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue';
import AuthUtil from '@/sheep/api/member/auth';
import { onHide } from '@dcloudio/uni-app';
const phone = ref('');
const code = ref('');
const password = ref('');
const confirmPassword = ref('');
// 密码显示隐藏控制
const showPassword = ref(false);
const showConfirmPassword = ref(false);
// 防抖及倒计时状态
const isSending = ref(false);
const isSubmitting = ref(false);
const countdown = ref(0);
let timer = null;
// 验证手机号码格式
const validatePhone = (num) => {
return /^1[3-9]\d{9}$/.test(num);
};
// 强密码复杂度检测:8~16位,大小写、数字、特殊字符
const validatePasswordStrength = (pwd) => {
const reg =
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&._#^&+=*!~-])[A-Za-z\d@$!%*?&._#^&+=*!~-]{8,16}$/;
return reg.test(pwd);
};
// 发送短信验证码
const handleSendCode = async () => {
if (countdown.value > 0 || isSending.value) return;
if (!phone.value) {
uni.showToast({ title: '请输入手机号码', icon: 'none' });
return;
}
if (!validatePhone(phone.value)) {
uni.showToast({ title: '请输入正确的11位手机号码', icon: 'none' });
return;
}
isSending.value = true;
try {
const res = await AuthUtil.sendSmsCode({ phone: phone.value });
if (res && (res.code === 0 || res.data === true)) {
uni.showToast({ title: '验证码已发送', icon: 'success' });
countdown.value = 60;
timer = setInterval(() => {
countdown.value--;
if (countdown.value <= 0) {
clearInterval(timer);
timer = null;
}
}, 1000);
} else {
uni.showToast({ title: res.msg || '获取验证码失败', icon: 'none' });
}
} catch (err) {
console.error('发送验证码失败异常:', err);
} finally {
isSending.value = false;
}
};
// 保存并提交重置密码
const handleSubmit = async () => {
if (isSubmitting.value) return;
if (!phone.value || !validatePhone(phone.value)) {
uni.showToast({ title: '请输入正确的11位手机号码', icon: 'none' });
return;
}
if (!code.value || code.value.length < 4) {
uni.showToast({ title: '请输入短信验证码', icon: 'none' });
return;
}
if (!password.value) {
uni.showToast({ title: '请输入您的新密码', icon: 'none' });
return;
}
// if (!validatePasswordStrength(password.value)) {
// uni.showToast({ title: '密码须为8~16位并包含大小写字母、数字及特殊字符', icon: 'none' });
// return;
// }
if (password.value !== confirmPassword.value) {
uni.showToast({ title: '两次输入的密码不一致', icon: 'none' });
return;
}
isSubmitting.value = true;
uni.showLoading({ title: '正在修改密码...', mask: true });
try {
const res = await AuthUtil.setPassword({
phone: phone.value,
code: code.value,
password: password.value,
});
uni.redirectTo({
url: '/pages7/pages/index/login',
});
} catch (err) {
console.error('重置密码运行异常:', err);
} finally {
isSubmitting.value = false;
uni.hideLoading();
}
};
// 生命周期管理:清除定时器
onHide(() => {
if (timer) {
clearInterval(timer);
timer = null;
}
});
</script>
<style lang="scss" scoped>
.login-wrapper {
height: 100vh;
// #ifdef H5
height: calc(100vh - 44px);
// #endif
background-color: #ffffff;
padding: 40rpx 50rpx; // 移除顶部大 Logo 后,缩减顶部 Padding 保持呼吸感
box-sizing: border-box;
// 1. 表单容器
.form-container {
margin-top: 20rpx;
.form-item-wrapper {
margin-bottom: 36rpx;
.form-label {
font-size: 26rpx;
color: #0a1931;
font-weight: bold;
display: block;
margin-bottom: 16rpx;
}
// 输入框提示语
.input-hint {
display: block;
font-size: 24rpx;
color: #9097a3;
line-height: 1.5;
margin-top: 12rpx;
padding: 0 10rpx;
}
.input-box {
height: 104rpx;
background-color: #f4f6fa;
border: 2rpx solid #e1e6eb;
border-radius: 24rpx;
display: flex;
align-items: center;
padding: 0 30rpx;
box-sizing: border-box;
.input-icon {
margin-right: 16rpx;
}
.eye-icon {
padding: 10rpx;
}
.native-input {
flex: 1;
height: 100%;
font-size: 28rpx;
color: #0a1931;
}
}
// 验证码双列布局
.code-row {
display: flex;
align-items: center;
.flex-1 {
flex: 1;
}
.code-btn {
width: 220rpx;
height: 104rpx;
border-radius: 24rpx;
background-color: #f9fdf2;
border: 2rpx solid #d0ee9c;
display: flex;
justify-content: center;
align-items: center;
margin-left: 20rpx;
font-size: 26rpx;
color: #8fc31f;
font-weight: bold;
transition: all 0.2s ease;
&:active {
opacity: 0.8;
}
&.disabled {
color: #b0b8c4;
background-color: #f4f6fa;
border-color: #e1e6eb;
pointer-events: none;
}
}
}
}
}
// 2. 确认修改按钮 (登录页同款高级渐变微立体阴影)
.submit-btn {
height: 104rpx;
border-radius: 24rpx;
background: linear-gradient(135deg, #79d621 0%, #00b074 100%);
box-shadow: 0 16rpx 40rpx rgba(0, 176, 116, 0.25);
display: flex;
justify-content: center;
align-items: center;
color: #ffffff;
font-size: 32rpx;
font-weight: bold;
letter-spacing: 2rpx;
margin-top: 80rpx;
transition: all 0.2s ease;
&:active {
transform: scale(0.98);
opacity: 0.9;
}
&.disabled-btn {
opacity: 0.75;
pointer-events: none;
}
.btn-arrow {
margin-left: 10rpx;
}
}
}
</style>
... ...
... ... @@ -2,44 +2,37 @@ import request from '@/sheep/request';
const AuthUtil = {
// 使用手机 + 密码登录
login: (data) => {
loginByPhonePassword: (data) => {
return request({
url: '/member/auth/login',
url: '/app/auth/loginByPhonePassword',
method: 'POST',
data,
custom: {
showSuccess: true,
loadingMsg: '登录中',
successMsg: '登录成功',
},
});
},
// 使用手机 + 验证码登录
smsLogin: (data) => {
loginBySms: (data) => {
return request({
url: '/member/auth/sms-login',
url: '/app/auth/loginBySms',
method: 'POST',
data,
custom: {
showSuccess: true,
loadingMsg: '登录中',
successMsg: '登录成功',
},
});
},
// 发送手机验证码
sendSmsCode: (mobile, scene) => {
sendSmsCode: (data) => {
return request({
url: '/member/auth/send-sms-code',
url: '/app/auth/sendSmsCode',
method: 'POST',
data: {
mobile,
scene,
},
data,
custom: {
loadingMsg: '发送中',
showSuccess: true,
successMsg: '发送成功',
showLoading: false,
},
});
},
... ... @@ -68,6 +61,16 @@ const AuthUtil = {
},
});
},
// 重置密码
setPassword: (data) => {
return request({
url: '/app/auth/setPassword',
method: 'POST',
data,
});
},
// 社交授权的跳转
socialAuthRedirect: (type, redirectUri) => {
return request({
... ...
... ... @@ -261,7 +261,7 @@ const UserApi = {
// 获取用户基本信息
getUserBasicInfo: () => {
return request({
url: '/app/student/personData',
url: '/app/user/getInfo',
method: 'GET',
custom: {
showLoading: false,
... ...