Authored by qxm

训计页面新增,我的。

Showing 62 changed files with 2707 additions and 612 deletions
... ... @@ -4,11 +4,12 @@ SHOPRO_VERSION=v2.4.1
# 后端接口 - 正式环境(通过 process.env.NODE_ENV 非 development)
# SHOPRO_BASE_URL=http://api-dashboard.yudao.iocoder.cn
# SHOPRO_BASE_URL=http://mall.hcxtec.com
SHOPRO_BASE_URL=https://xunji.geaktec.com
# 后端接口 - 测试环境(通过 process.env.NODE_ENV = development)
#SHOPRO_DEV_BASE_URL=http://192.168.1.200:48081
# 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:/xunji.geaktec.com
SHOPRO_DEV_BASE_URL=https://xunji.geaktec.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
... ...
... ... @@ -178,7 +178,39 @@
"navigationBarTitleText": "个人资料",
"navigationStyle": "default"
}
}
},
{
"path": "pages/user/wode-changjian-wenti",
"style": {
"navigationBarTitleText": "我的常见问题"
}
},
{
"path": "pages/user/wode-guanyu-hongxing",
"style": {
"navigationBarTitleText": "我的关于鸿星"
}
},
{
"path": "pages/user/wode-jiankang-ziliao",
"style": {
"navigationBarTitleText": "我的健康资料"
}
},
{
"path": "pages/user/wode-lianxi-kefu",
"style": {
"navigationBarTitleText": "我的联系客服"
}
},
{
"path": "pages/user/wode-shezhi",
"style": {
"navigationBarTitleText": "设置",
"navigationStyle": "default"
}
}
]
}
],
... ...
... ... @@ -26,7 +26,7 @@
</view>
<!-- -->
<view class="vip-banner" hover-class="opacity-hover" @click="goAddVip">
<!-- <view class="vip-banner" hover-class="opacity-hover" @click="goAddVip">
<view class="vip-info">
<uni-icons type="vip-filled" size="22" color="#f1c40f" />
<text class="vip-text">
... ... @@ -34,25 +34,25 @@
</text>
</view>
<view class="vip-btn">{{ userInfo.deposit === 1 ? '查看权益' : '立即开通' }}</view>
</view>
</view> -->
<!-- 课程状态快速入口 -->
<view class="section-card quick-entry">
<!-- <view class="section-card quick-entry">
<view v-for="entry in quickEntryConfig" :key="entry.type" class="entry-item" hover-class="opacity-hover"
@click="handleQuickEntry(entry.type)">
<text class="num">{{ userInfo[entry.key] || 0 }}</text>
<text class="label">{{ entry.label }}</text>
</view>
</view>
</view> -->
<!-- 广告位 -->
<view class="banner-box" @click="goJiamen">
<!-- <view class="banner-box" @click="goJiamen">
<image src="https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260316/4_1773627891703.png"
mode="aspectFill" class="banner-img" />
</view>
</view> -->
<!-- 核心应用区 -->
<view class="section-card apply-section">
<!-- <view class="section-card apply-section">
<view v-for="(item, index) in APPLY_CONFIG_LIST" :key="index" class="apply-item"
@click="authNavigateTo(item.url)">
<view class="icon-bg">
... ... @@ -60,10 +60,10 @@
</view>
<text class="text">{{ item.text }}</text>
</view>
</view>
</view> -->
<!-- 资产账户网格区 -->
<view v-if="userStore.isLogin" class="section-card">
<!-- <view v-if="userStore.isLogin" class="section-card">
<view class="account-grid">
<view v-for="(acc, idx) in accountConfig" :key="idx" class="account-item" @click="authNavigateTo(acc.url)">
<text class="acc-lab">{{ acc.label }}</text>
... ... @@ -73,7 +73,7 @@
</text>
</view>
</view>
</view>
</view> -->
<!-- 功能矩阵九宫格 -->
<view class="section-card icon-grid-box">
... ... @@ -100,10 +100,10 @@
<text class="setting-text">个人设置</text>
<uni-icons type="right" size="14" color="#E0E0E0" />
</view>
<view class="setting-item" @click="authNavigateTo('/pages5/pages/user/wode-yinsishezhi')">
<!-- <view class="setting-item" @click="authNavigateTo('/pages5/pages/user/wode-yinsishezhi')">
<text class="setting-text">隐私中心</text>
<uni-icons type="right" size="14" color="#E0E0E0" />
</view>
</view> -->
</view>
<Tabbar />
... ... @@ -130,7 +130,30 @@ const memberLevelName = ref('');
// 固定的 UI 配置
const APPLY_CONFIG_LIST = [];
const FUNCTION_CONFIG_LIST = [];
const FUNCTION_CONFIG_LIST = [
{
text: '健康资料',
url: '/pages5/pages/user/wode-jiankang-ziliao',
icon: '/static/icons/md-folder_open 1@1x.png',
},
{
text: '常见问题',
url: '/pages5/pages/user/wode-changjian-wenti',
icon: '/static/icons/iconPark-helpcenter 1@1x.png',
},
{
text: '联系客服',
url: '/pages5/pages/user/wode-lianxi-kefu',
icon: '/static/icons/riLine-customer-service-2-line 1@1x.png',
},
{
text: '关于鸿星',
url: '/pages5/pages/user/wode-guanyu-hongxing',
icon: '/static/icons/antOutline-exclamation-circle 1@1x.png',
},
];
// 课程计数状态配置映射
const quickEntryConfig = [
... ... @@ -191,22 +214,23 @@ const handleGridItemClick = (item) => {
*/
const fetchPageData = async () => {
try {
const [userRes, levelRes] = await Promise.all([
UserApi.getUserInfo(),
MemberApi.getMemberLevel(),
]);
// const [userRes, levelRes] = await Promise.all([
// UserApi.getUserInfo(),
// // MemberApi.getMemberLevel(),
// ]);
const userRes = UserApi.getUserInfo();
const rawUser = userRes.data || {};
userInfo.value = rawUser;
// 架构重构:数据拉取后一次性计算出等级映射结果,拒绝在 computed 内部循环执行实例化
const levels = levelRes.data?.detailList || [];
if (rawUser.level !== undefined && levels.length > 0) {
const target = levels.find((item) => item.id === rawUser.level);
memberLevelName.value = target ? target.name : '';
} else {
memberLevelName.value = '';
}
// const levels = levelRes.data?.detailList || [];
// if (rawUser.level !== undefined) {
// // const target = levels.find((item) => item.id === rawUser.level);
// // memberLevelName.value = target ? target.name : '';
// } else {
// // memberLevelName.value = '';
// }
} catch (error) {
console.error('[API Error] 拉取个人资产信息流失败:', error);
}
... ...
<template>
<view class="home-page" v-if="userStore.isLogin">
<view class="tab-bar" :style="{ paddingTop: menuButtonHeight + topSafeArea + 'px' }">
<view class="tab-item" :class="{ active: currentTab === 0 }" @click="handleTabClick(0)">训记</view>
<!-- <view class="tab-item" :class="{ active: currentTab === 0 }" @click="handleTabClick(0)">训记</view> -->
<view class="tab-item" :class="{ active: currentTab === 1 }" @click="handleTabClick(1)">计划</view>
<view class="tab-item" :class="{ active: currentTab === 2 }" @click="handleTabClick(2)">日历</view>
<view class="tab-item" :class="{ active: currentTab === 3 }" @click="handleTabClick(3)">动作</view>
... ... @@ -13,7 +13,7 @@
</view>
<view class="content">
<xunjiXunji v-if="currentTab === 0" />
<!-- <xunjiXunji v-if="currentTab === 0" /> -->
<xunjiXunlianjihua v-if="currentTab === 1" />
<xunjiRili v-if="currentTab === 2" />
<xunjiDongzuo v-if="currentTab === 3" />
... ... @@ -105,7 +105,7 @@ const trainingStore = useTrainingStore();
const userStore = useUserStore();
// --- 补全缺失的响应式变量定义 ---
const currentTab = ref(0);
const currentTab = ref(1);
const currentComponent = shallowRef(xunjiXunji); // 用于 H5 端动态组件切换(如保留原功能逻辑)
const drawer = ref(null); // uni-drawer 的组件实例引用
... ...
... ... @@ -13,7 +13,7 @@
<view v-if="showMusclePicker" class="popup-overlay" @click="closeAllPopups">
<view class="popup-content" @click.stop>
<view class="popup-header">
<view class="close-btn" @click="closeAllPopups">×</view>
<view class="close-btn" @click="closeAllPopups">x</view>
<view class="title">选择训练部位</view>
<view class="confirm-btn" @click="confirmMuscleSelection">完成</view>
</view>
... ... @@ -323,7 +323,11 @@ const openCoverSelector = () => {
// 封面上传
try {
const result = await FileApi.uploadFile(path);
console.log(res, 'res');
coverImagePath.value = result.data;
console.log('coverImagePath', coverImagePath.value);
coverUploaded.value = true;
uni.showToast({
title: "封面上传成功",
... ...
<template>
<view class="level-page">
<!-- 导航栏 -->
<uni-nav-bar title="华创信" left-icon="left" @click-left="goBack" :fixed="true" :status-bar="true" />
<view class="top-right">
<!-- <u-icon name="document" size="24" color="#fff" @click="showRules"></u-icon> -->
<text class="right-text">华创信</text>
</view>
<!-- 用户信息 -->
<view class="user-info">
<image src="https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260316/order-empty_1773628059920.png"
mode="aspectFill" class="avatar"></image>
<text class="username">华创信开发者</text>
</view>
<!-- 更多权益 -->
<view class="more-benefits">
<view class="more-text">华创信开发者</view>
<view class="more-text">系统开发中</view>
<view class="more-text">敬请期待~</view>
</view>
</view>
</template>
<script>
export default {
methods: {
goBack() {
uni.navigateBack();
},
showRules() {
uni.showToast({ title: '查看开发内容', icon: 'success' });
},
},
};
</script>
<style scoped>
.level-page {
position: relative;
width: 100%;
min-height: 100vh;
background-color: #1a1a1a;
color: white;
padding-bottom: 40rpx;
}
.top-right {
position: absolute;
top: 20rpx;
right: 20rpx;
display: flex;
align-items: center;
gap: 10rpx;
color: white;
font-size: 24rpx;
}
.user-info {
display: flex;
align-items: center;
gap: 20rpx;
padding: 20rpx;
margin-top: 20rpx;
}
.avatar {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
object-fit: cover;
}
.username {
font-size: 28rpx;
}
.level-card {
width: 600rpx;
height: 300rpx;
background-color: rgba(173, 216, 230, 0.3);
border-radius: 20rpx;
padding: 30rpx;
margin: 20rpx auto;
position: relative;
overflow: hidden;
}
.level-name {
font-size: 40rpx;
font-weight: bold;
margin-bottom: 10rpx;
}
.level-en {
font-size: 36rpx;
font-weight: bold;
margin-bottom: 20rpx;
}
.require {
font-size: 24rpx;
margin-bottom: 20rpx;
}
.detail {
font-size: 24rpx;
color: #ccc;
}
.progress-bar {
width: 600rpx;
height: 20rpx;
background-color: #333;
border-radius: 10rpx;
margin: 20rpx auto;
position: relative;
overflow: hidden;
}
.progress-track {
width: 100%;
height: 100%;
background-color: #333;
border-radius: 10rpx;
}
.progress-fill {
width: 20%;
height: 100%;
background-color: #007aff;
border-radius: 10rpx;
}
.progress-dot {
position: absolute;
top: 50%;
left: 20%;
transform: translate(-50%, -50%);
width: 12rpx;
height: 12rpx;
background-color: #007aff;
border-radius: 50%;
}
.benefits-section {
margin: 20rpx;
padding: 0 20rpx;
}
.section-title {
font-size: 32rpx;
margin-bottom: 20rpx;
padding-left: 10rpx;
}
.benefit-list {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 16rpx;
}
.benefit-item {
width: 180rpx;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 12rpx;
padding: 16rpx 12rpx;
text-align: center;
box-sizing: border-box;
}
.icon {
width: 36rpx;
height: 36rpx;
object-fit: cover;
margin-bottom: 8rpx;
}
.benefit-name {
font-size: 22rpx;
margin-bottom: 4rpx;
font-weight: bold;
}
.benefit-desc {
font-size: 18rpx;
color: #ccc;
line-height: 1.4;
}
.time-tag {
font-size: 18rpx;
color: white;
background-color: #ff6b00;
padding: 4rpx 8rpx;
border-radius: 8rpx;
margin-top: 8rpx;
display: inline-block;
}
.more-benefits {
margin: 30rpx 20rpx 0;
padding: 20rpx;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 12rpx;
text-align: center;
}
.more-text {
font-size: 24rpx;
color: #ccc;
margin-bottom: 10rpx;
}
</style>
... ...
... ... @@ -2,14 +2,9 @@
<view class="profile-page">
<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'
"
mode="aspectFill"
class="avatar-image"
></image>
<image :src="formData.avatar ||
'https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260526/默认头像_1779779926983.png'
" mode="aspectFill" class="avatar-image"></image>
</button>
</view>
... ... @@ -23,25 +18,13 @@
<view class="list-item" @click="gotoSelectGender">
<text class="item-title">性别</text>
<view class="item-value">{{ formatGender(formData.sex) }}</view>
<uni-icons
v-if="formData.isAllowUpdSex"
type="right"
color="#999"
size="20"
class="arrow-icon"
></uni-icons>
<uni-icons v-if="formData.isAllowUpdSex" type="right" color="#999" size="20" class="arrow-icon"></uni-icons>
</view>
<view class="list-item">
<text class="item-title">地区</text>
<picker
mode="multiSelector"
:range="multiArray"
range-key="name"
:value="multiIndex"
@change="pickerChange"
@columnchange="pickerColumnChange"
>
<picker mode="multiSelector" :range="multiArray" range-key="name" :value="multiIndex" @change="pickerChange"
@columnchange="pickerColumnChange">
<view class="picker" :class="{ 'placeholder-txt': !selectedAddress }">
{{ selectedAddress || '请选择省市区' }}
</view>
... ... @@ -51,13 +34,8 @@
<view class="list-item signature-item">
<text class="item-title">个性签名</text>
<view class="signature-input-wrapper">
<textarea
v-model="formData.signature"
placeholder="请输入个性签名..."
:maxlength="50"
class="signature-input"
auto-height
/>
<textarea v-model="formData.signature" placeholder="请输入个性签名..." :maxlength="50" class="signature-input"
auto-height />
<text class="char-count">{{ formData.signature?.length || 0 }}/50</text>
</view>
</view>
... ... @@ -80,13 +58,8 @@
</view>
<view class="popup-body">
<view class="input-wrap">
<input
class="nickname-input"
placeholder="请输入新昵称"
type="nickname"
v-model="editCacheData.nickname"
maxlength="20"
/>
<input class="nickname-input" placeholder="请输入新昵称" type="nickname" v-model="editCacheData.nickname"
maxlength="20" />
</view>
</view>
<view class="popup-footer">
... ... @@ -105,18 +78,10 @@
</view>
<view class="popup-body">
<view class="gender-options">
<view
class="gender-item"
:class="{ selected: editCacheData.sex === 1 }"
@click="selectGender(1)"
>
<view class="gender-item" :class="{ selected: editCacheData.sex === 1 }" @click="selectGender(1)">
<text class="gender-text">男</text>
</view>
<view
class="gender-item"
:class="{ selected: editCacheData.sex === 2 }"
@click="selectGender(2)"
>
<view class="gender-item" :class="{ selected: editCacheData.sex === 2 }" @click="selectGender(2)">
<text class="gender-text">女</text>
</view>
</view>
... ... @@ -128,594 +93,580 @@
</template>
<script setup>
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';
import FileApi from '@/sheep/api/infra/file';
import useUserStore from '@/sheep/store/user';
const userStore = useUserStore();
// 弹窗 DOM 引用声明
const nicknamePopupRef = ref(null);
const genderPopupRef = ref(null);
// 状态与防抖
const saveLoading = ref(false);
const hasModified = ref(false); // 侦听变更状态
// 核心业务隔离表单
const formData = ref({
avatar: '',
nickname: '',
sex: 0,
province: '',
city: '',
region: '',
signature: '',
createTime: '',
isAllowUpdSex: false,
});
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';
import FileApi from '@/sheep/api/infra/file';
import useUserStore from '@/sheep/store/user';
const userStore = useUserStore();
// 弹窗 DOM 引用声明
const nicknamePopupRef = ref(null);
const genderPopupRef = ref(null);
// 状态与防抖
const saveLoading = ref(false);
const hasModified = ref(false); // 侦听变更状态
// 核心业务隔离表单
const formData = ref({
avatar: '',
nickname: '',
sex: 0,
province: '',
city: '',
region: '',
signature: '',
createTime: '',
isAllowUpdSex: false,
});
// 深度解耦的弹窗缓存数据
const editCacheData = reactive({
nickname: '',
sex: 0,
});
// 省市区级联选择器状态流
const addressTree = ref([]);
const multiArray = ref([[], [], []]);
const multiIndex = ref([0, 0, 0]);
const selectedAddress = ref('');
/**
* 宿主生命周期钩子流
*/
onShow(async () => {
try {
// 1. 同步加载行政地区三级树(内部已作 Session 缓存,避免重复请求)
await fetchAreaTree();
// 2. 深度克隆 Store 数据,隔离表单与 Store 的直接联动,保持单向数据流
if (userStore.userInfo) {
formData.value = JSON.parse(JSON.stringify(userStore.userInfo));
initEchoAddress();
}
} catch (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;
// 深度解耦的弹窗缓存数据
const editCacheData = reactive({
nickname: '',
sex: 0,
});
// 省市区级联选择器状态流
const addressTree = ref([]);
const multiArray = ref([[], [], []]);
const multiIndex = ref([0, 0, 0]);
const selectedAddress = ref('');
/**
* 宿主生命周期钩子流
*/
onShow(async () => {
try {
// 1. 同步加载行政地区三级树(内部已作 Session 缓存,避免重复请求)
await fetchAreaTree();
// 2. 深度克隆 Store 数据,隔离表单与 Store 的直接联动,保持单向数据流
if (userStore.userInfo) {
formData.value = JSON.parse(JSON.stringify(userStore.userInfo));
initEchoAddress();
// 前置性能防御:对 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;
}
} catch (error) {
console.error('[Init Error] 初始化个人信息数据流失败:', error);
}
},
});
// #endif
/**
* 拦截非保存下的非正常退出,防止用户误触返回造成数据丢失
*/
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;
// 前置性能防御:对 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' });
} finally {
uni.hideLoading();
}
};
/**
* 唤起并初始化昵称弹窗缓存
*/
const gotoEditNickname = () => {
editCacheData.nickname = formData.value.nickname || '';
nicknamePopupRef.value.open();
};
/**
* 确认编辑昵称
*/
const confirmEditNickname = () => {
const targetNickname = editCacheData.nickname ? editCacheData.nickname.trim() : '';
if (!targetNickname) {
uni.showToast({ title: '昵称不能为空', icon: 'none' });
return;
}
formData.value.nickname = targetNickname;
nicknamePopupRef.value.close();
};
/**
* 唤起并初始化性别弹窗
*/
const gotoSelectGender = () => {
if (!formData.value.isAllowUpdSex) {
uni.showToast({ title: '性别暂不支持多次修改', icon: 'none' });
return;
}
editCacheData.sex = formData.value.sex || 0;
genderPopupRef.value.open();
};
/**
* 优化:解耦的性别变更逻辑
*/
const selectGender = (sex) => {
// 1. 临时更改缓存态而非直接写回 formData
editCacheData.sex = sex;
formData.value.sex = sex; // 仅在用户明确选定后回写,并带有 200ms 的视觉缓冲反馈
setTimeout(() => {
genderPopupRef.value.close();
}, 200);
};
/**
* 弹窗关闭时彻底释放和清理临时的输入脏状态
*/
const onNicknamePopupChange = (e) => {
if (!e.show) {
editCacheData.nickname = '';
}
};
/**
* 优化:实现会话级局部静态缓存,解决 onShow 高频请求带来的服务器资源浪费
*/
const fetchAreaTree = async () => {
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 FileApi.uploadFile(avatarUrl);
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 || addressTree.value.length === 0) return;
const provinces = addressTree.value;
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];
};
/**
* 【关键越界重构】:修正真机由于异步滚动的时差引发的越界闪退
*/
const pickerColumnChange = (e) => {
const { column, value } = e.detail;
// 1. 原子化本地临时深拷贝索引指针
const nextIndex = [...multiIndex.value];
nextIndex[column] = value;
if (column === 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) {
// 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);
}
console.log('res----------', res);
// 5. 在同一视图更新 tick 下完成覆盖更新,避免多路联动逻辑竞态导致的重叠渲染
nextTick(() => {
multiIndex.value = nextIndex;
});
};
/**
* 确认选择省市区,绑定数据映射
*/
const pickerChange = (e) => {
const indices = e.detail.value;
multiIndex.value = [...indices];
const pObj = multiArray.value[0][indices[0]];
const cObj = multiArray.value[1][indices[1]];
const rObj = multiArray.value[2][indices[2]];
formData.value.province = pObj?.id || '';
formData.value.city = cObj?.id || '';
formData.value.region = rObj?.id || '';
selectedAddress.value = [pObj?.name, cObj?.name, rObj?.name].filter(Boolean).join(' ');
};
/**
* 数据回显定位器:精准利用底层树定位当前用户已有地区信息
*/
const initEchoAddress = () => {
const { province, city, region } = formData.value;
if (!province || !addressTree.value || addressTree.value.length === 0) {
syncPickerColumnData(0, 0);
return;
}
// 1. 查找省份物理位置
const pIdx = addressTree.value.findIndex((p) => String(p.id) === String(province));
if (pIdx === -1) {
syncPickerColumnData(0, 0);
return;
}
formData.value.avatar = res.data;
// 2. 查找地级市物理位置
const cities = addressTree.value[pIdx]?.children || [];
const cIdx = cities.findIndex((c) => String(c.id) === String(city));
// 3. 查找区县物理位置
const regions = cIdx !== -1 ? cities[cIdx]?.children || [] : [];
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 = [safePIdx, safeCIdx, safeRIdx];
// 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;
}
} catch (err) {
console.error(err);
saveLoading.value = true;
uni.showLoading({ title: '正在保存资料...', mask: true });
}
};
/**
* 唤起并初始化昵称弹窗缓存
*/
const gotoEditNickname = () => {
editCacheData.nickname = formData.value.nickname || '';
nicknamePopupRef.value.open();
};
/**
* 确认编辑昵称
*/
const confirmEditNickname = () => {
const targetNickname = editCacheData.nickname ? editCacheData.nickname.trim() : '';
if (!targetNickname) {
uni.showToast({ title: '昵称不能为空', icon: 'none' });
return;
}
formData.value.nickname = targetNickname;
nicknamePopupRef.value.close();
};
/**
* 唤起并初始化性别弹窗
*/
const gotoSelectGender = () => {
if (!formData.value.isAllowUpdSex) {
uni.showToast({ title: '性别暂不支持多次修改', icon: 'none' });
return;
}
editCacheData.sex = formData.value.sex || 0;
genderPopupRef.value.open();
};
/**
* 优化:解耦的性别变更逻辑
*/
const selectGender = (sex) => {
// 1. 临时更改缓存态而非直接写回 formData
editCacheData.sex = sex;
formData.value.sex = sex; // 仅在用户明确选定后回写,并带有 200ms 的视觉缓冲反馈
setTimeout(() => {
genderPopupRef.value.close();
}, 200);
};
/**
* 弹窗关闭时彻底释放和清理临时的输入脏状态
*/
const onNicknamePopupChange = (e) => {
if (!e.show) {
editCacheData.nickname = '';
}
};
/**
* 优化:实现会话级局部静态缓存,解决 onShow 高频请求带来的服务器资源浪费
*/
const fetchAreaTree = async () => {
if (addressTree.value && addressTree.value.length > 0) return;
// 尝试拉取框架/系统底层会话级存储或内存,确保单次生命周期内仅调用一次 API
const cachedTree = uni.getStorageSync('sys_area_tree');
if (cachedTree) {
addressTree.value = cachedTree;
return;
}
try {
// 脏数据深度过滤净化:只保留后端允许更新的可写属性,剥离统计及只读系统字段
const { createTime, isAllowUpdSex, registerIp, id, userId, mobile, ...payload } =
formData.value;
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 || addressTree.value.length === 0) return;
const provinces = addressTree.value;
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];
};
/**
* 【关键越界重构】:修正真机由于异步滚动的时差引发的越界闪退
*/
const pickerColumnChange = (e) => {
const { column, value } = e.detail;
// 1. 原子化本地临时深拷贝索引指针
const nextIndex = [...multiIndex.value];
nextIndex[column] = value;
if (column === 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) {
// 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);
}
// 保证提交数据的值是修剪过左右空格的
payload.nickname = payload.nickname.trim();
if (payload.signature) {
payload.signature = payload.signature.trim();
}
// 5. 在同一视图更新 tick 下完成覆盖更新,避免多路联动逻辑竞态导致的重叠渲染
nextTick(() => {
multiIndex.value = nextIndex;
});
};
/**
* 确认选择省市区,绑定数据映射
*/
const pickerChange = (e) => {
const indices = e.detail.value;
multiIndex.value = [...indices];
const pObj = multiArray.value[0][indices[0]];
const cObj = multiArray.value[1][indices[1]];
const rObj = multiArray.value[2][indices[2]];
formData.value.province = pObj?.id || '';
formData.value.city = cObj?.id || '';
formData.value.region = rObj?.id || '';
selectedAddress.value = [pObj?.name, cObj?.name, rObj?.name].filter(Boolean).join(' ');
};
/**
* 数据回显定位器:精准利用底层树定位当前用户已有地区信息
*/
const initEchoAddress = () => {
const { province, city, region } = formData.value;
if (!province || !addressTree.value || addressTree.value.length === 0) {
syncPickerColumnData(0, 0);
return;
}
await UserApi.updateUser(payload);
// 1. 查找省份物理位置
const pIdx = addressTree.value.findIndex((p) => String(p.id) === String(province));
if (pIdx === -1) {
syncPickerColumnData(0, 0);
return;
}
// 异步刷新全局 Pinia 的 userInfo 用户属性,使全局头像和昵称视图无痛同步
if (userStore.getUserInfo) {
await userStore.getUserInfo();
}
// 2. 查找地级市物理位置
const cities = addressTree.value[pIdx]?.children || [];
const cIdx = cities.findIndex((c) => String(c.id) === String(city));
// 3. 查找区县物理位置
const regions = cIdx !== -1 ? cities[cIdx]?.children || [] : [];
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 = [safePIdx, safeCIdx, safeRIdx];
// 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;
}
uni.showToast({ title: '保存成功', icon: 'success' });
setTimeout(() => {
uni.navigateBack({ delta: 1 });
}, 1200);
} catch (err) {
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 '女';
return '保密';
};
const formatRegisterTime = (time) => {
if (!time) return '--';
return dayjs(time).format('YYYY-MM-DD');
};
</script>
saveLoading.value = true;
uni.showLoading({ title: '正在保存资料...', mask: true });
<style scoped lang="scss">
$color-bg: #f5f5f5;
$color-white: #ffffff;
$color-text-dark: #333;
$color-text-middle: #666;
$color-text-light: #999;
$color-primary: #fdd511;
$color-border: #eee;
$radius: 12rpx;
$radius-lg: 24rpx;
$shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
$spacing-sm: 20rpx;
$spacing-md: 24rpx;
$spacing-lg: 32rpx;
$spacing-xl: 60rpx;
.profile-page {
background-color: $color-bg;
height: 100vh;
// #ifdef H5
height: calc(100vh - 44px);
// #endif
padding-bottom: env(safe-area-inset-bottom);
.avatar-section {
display: flex;
justify-content: center;
align-items: center;
padding: $spacing-xl 0 $spacing-lg;
.avatar-edit-btn {
background-color: transparent;
border: none;
padding: 0;
line-height: 0;
border-radius: 50%;
&::after {
content: none;
}
}
try {
// 脏数据深度过滤净化:只保留后端允许更新的可写属性,剥离统计及只读系统字段
const { createTime, isAllowUpdSex, registerIp, id, userId, mobile, ...payload } =
formData.value;
.avatar-image {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
background-color: #e1e1e1;
}
// 保证提交数据的值是修剪过左右空格的
payload.nickname = payload.nickname.trim();
if (payload.signature) {
payload.signature = payload.signature.trim();
}
.info-list {
padding: 0 $spacing-sm;
await UserApi.updateUser(payload);
// 异步刷新全局 Pinia 的 userInfo 用户属性,使全局头像和昵称视图无痛同步
if (userStore.getUserInfo) {
await userStore.getUserInfo();
}
.list-item {
display: flex;
align-items: center;
justify-content: space-between;
background-color: $color-white;
border-radius: $radius;
padding: $spacing-md $spacing-sm;
margin-bottom: $spacing-sm;
box-shadow: $shadow;
uni.showToast({ title: '保存成功', icon: 'success' });
.item-title {
font-size: 28rpx;
color: $color-text-dark;
flex-shrink: 0;
width: 140rpx;
}
setTimeout(() => {
uni.navigateBack({ delta: 1 });
}, 1200);
} catch (err) {
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 '女';
return '保密';
};
const formatRegisterTime = (time) => {
if (!time) return '--';
return dayjs(time).format('YYYY-MM-DD');
};
</script>
.item-value {
font-size: 26rpx;
color: $color-text-middle;
margin-right: $spacing-sm;
text-align: right;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
<style scoped lang="scss">
$color-bg: #f5f5f5;
$color-white: #ffffff;
$color-text-dark: #333;
$color-text-middle: #666;
$color-text-light: #999;
$color-primary: #fdd511;
$color-border: #eee;
$radius: 12rpx;
$radius-lg: 24rpx;
$shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
$spacing-sm: 20rpx;
$spacing-md: 24rpx;
$spacing-lg: 32rpx;
$spacing-xl: 60rpx;
.profile-page {
background-color: $color-bg;
height: 100vh;
// #ifdef H5
height: calc(100vh - 44px);
// #endif
padding-bottom: env(safe-area-inset-bottom);
.avatar-section {
display: flex;
justify-content: center;
align-items: center;
padding: $spacing-xl 0 $spacing-lg;
.avatar-edit-btn {
background-color: transparent;
border: none;
padding: 0;
line-height: 0;
border-radius: 50%;
&::after {
content: none;
}
}
.picker {
font-size: 26rpx;
color: $color-text-middle;
text-align: right;
flex: 1;
padding-right: 10rpx;
.avatar-image {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
background-color: #e1e1e1;
}
}
&.placeholder-txt {
color: $color-text-light;
}
}
.info-list {
padding: 0 $spacing-sm;
}
.arrow-icon {
flex-shrink: 0;
}
.list-item {
display: flex;
align-items: center;
justify-content: space-between;
background-color: $color-white;
border-radius: $radius;
padding: $spacing-md $spacing-sm;
margin-bottom: $spacing-sm;
box-shadow: $shadow;
&.signature-item {
flex-direction: column;
align-items: flex-start;
gap: 16rpx;
.item-title {
font-size: 28rpx;
color: $color-text-dark;
flex-shrink: 0;
width: 140rpx;
}
.signature-input-wrapper {
width: 100%;
position: relative;
.signature-input {
width: 100%;
min-height: 120rpx;
max-height: 200rpx;
padding: 16rpx 20rpx 40rpx;
font-size: 26rpx;
color: $color-text-dark;
background-color: #f9f9f9;
border-radius: $radius;
border: 1rpx solid $color-border;
box-sizing: border-box;
line-height: 1.5;
}
.char-count {
position: absolute;
right: 20rpx;
bottom: 12rpx;
font-size: 22rpx;
color: $color-text-light;
}
}
}
.item-value {
font-size: 26rpx;
color: $color-text-middle;
margin-right: $spacing-sm;
text-align: right;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
// 弹出层统一样式
.popup-container {
background-color: $color-white;
border-radius: $radius-lg $radius-lg 0 0;
padding-bottom: calc(30rpx + env(safe-area-inset-bottom));
.picker {
font-size: 26rpx;
color: $color-text-middle;
text-align: right;
flex: 1;
padding-right: 10rpx;
.popup-header {
display: flex;
align-items: center;
justify-content: center;
padding: $spacing-md $spacing-sm;
border-bottom: 1rpx solid #fcfcfc;
.popup-title {
font-size: 32rpx;
color: $color-text-dark;
font-weight: 600;
&.placeholder-txt {
color: $color-text-light;
}
}
.popup-body {
padding: 30rpx $spacing-sm;
.arrow-icon {
flex-shrink: 0;
}
&.signature-item {
flex-direction: column;
align-items: flex-start;
gap: 16rpx;
.signature-input-wrapper {
width: 100%;
position: relative;
.input-wrap {
.nickname-input {
.signature-input {
width: 100%;
height: 90rpx;
min-height: 120rpx;
max-height: 200rpx;
padding: 16rpx 20rpx 40rpx;
font-size: 26rpx;
color: $color-text-dark;
background-color: #f9f9f9;
border: 1rpx solid $color-border;
border-radius: $radius;
padding: 0 $spacing-sm;
font-size: 28rpx;
border: 1rpx solid $color-border;
box-sizing: border-box;
line-height: 1.5;
}
}
.gender-options {
display: flex;
flex-direction: column;
gap: $spacing-md;
.gender-item {
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 96rpx;
font-size: 30rpx;
color: $color-text-middle;
border-radius: $radius;
background-color: #fafafa;
border: 2rpx solid $color-border;
box-sizing: border-box;
&.selected {
background-color: rgba($color-primary, 0.15);
color: #333;
border-color: $color-primary;
font-weight: bold;
}
.char-count {
position: absolute;
right: 20rpx;
bottom: 12rpx;
font-size: 22rpx;
color: $color-text-light;
}
}
}
}
}
// 弹出层统一样式
.popup-container {
background-color: $color-white;
border-radius: $radius-lg $radius-lg 0 0;
padding-bottom: calc(30rpx + env(safe-area-inset-bottom));
.popup-header {
display: flex;
align-items: center;
justify-content: center;
padding: $spacing-md $spacing-sm;
border-bottom: 1rpx solid #fcfcfc;
.popup-title {
font-size: 32rpx;
color: $color-text-dark;
font-weight: 600;
}
}
.popup-footer {
padding: 0 $spacing-sm;
.confirm-btn {
.popup-body {
padding: 30rpx $spacing-sm;
.input-wrap {
.nickname-input {
width: 100%;
height: 88rpx;
line-height: 88rpx;
background-color: $color-primary;
color: #333;
height: 90rpx;
background-color: #f9f9f9;
border: 1rpx solid $color-border;
border-radius: $radius;
padding: 0 $spacing-sm;
font-size: 28rpx;
font-weight: 500;
box-sizing: border-box;
}
}
.gender-options {
display: flex;
flex-direction: column;
gap: $spacing-md;
.gender-item {
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 96rpx;
font-size: 30rpx;
color: $color-text-middle;
border-radius: $radius;
&:after {
content: none;
background-color: #fafafa;
border: 2rpx solid $color-border;
box-sizing: border-box;
&.selected {
background-color: rgba($color-primary, 0.15);
color: #333;
border-color: $color-primary;
font-weight: bold;
}
}
}
}
.button-section {
padding: $spacing-lg $spacing-sm;
.save-btn {
.popup-footer {
padding: 0 $spacing-sm;
.confirm-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
... ... @@ -724,9 +675,30 @@
font-size: 28rpx;
font-weight: 500;
border-radius: $radius;
&:after {
content: none;
}
}
}
}
.button-section {
padding: $spacing-lg $spacing-sm;
.save-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
background-color: $color-primary;
color: #333;
font-size: 28rpx;
font-weight: 500;
border-radius: $radius;
&:after {
content: none;
}
}
}
</style>
... ...
<template>
<view class="level-page">
<!-- 导航栏 -->
<uni-nav-bar title="华创信" left-icon="left" @click-left="goBack" :fixed="true" :status-bar="true" />
<view class="top-right">
<!-- <u-icon name="document" size="24" color="#fff" @click="showRules"></u-icon> -->
<text class="right-text">华创信</text>
</view>
<!-- 用户信息 -->
<view class="user-info">
<image src="https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260316/order-empty_1773628059920.png"
mode="aspectFill" class="avatar"></image>
<text class="username">华创信开发者</text>
</view>
<!-- 更多权益 -->
<view class="more-benefits">
<view class="more-text">华创信开发者</view>
<view class="more-text">系统开发中</view>
<view class="more-text">敬请期待~</view>
</view>
</view>
</template>
<script>
export default {
methods: {
goBack() {
uni.navigateBack();
},
showRules() {
uni.showToast({ title: '查看开发内容', icon: 'success' });
},
},
};
</script>
<style scoped>
.level-page {
position: relative;
width: 100%;
min-height: 100vh;
background-color: #1a1a1a;
color: white;
padding-bottom: 40rpx;
}
.top-right {
position: absolute;
top: 20rpx;
right: 20rpx;
display: flex;
align-items: center;
gap: 10rpx;
color: white;
font-size: 24rpx;
}
.user-info {
display: flex;
align-items: center;
gap: 20rpx;
padding: 20rpx;
margin-top: 20rpx;
}
.avatar {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
object-fit: cover;
}
.username {
font-size: 28rpx;
}
.level-card {
width: 600rpx;
height: 300rpx;
background-color: rgba(173, 216, 230, 0.3);
border-radius: 20rpx;
padding: 30rpx;
margin: 20rpx auto;
position: relative;
overflow: hidden;
}
.level-name {
font-size: 40rpx;
font-weight: bold;
margin-bottom: 10rpx;
}
.level-en {
font-size: 36rpx;
font-weight: bold;
margin-bottom: 20rpx;
}
.require {
font-size: 24rpx;
margin-bottom: 20rpx;
}
.detail {
font-size: 24rpx;
color: #ccc;
}
.progress-bar {
width: 600rpx;
height: 20rpx;
background-color: #333;
border-radius: 10rpx;
margin: 20rpx auto;
position: relative;
overflow: hidden;
}
.progress-track {
width: 100%;
height: 100%;
background-color: #333;
border-radius: 10rpx;
}
.progress-fill {
width: 20%;
height: 100%;
background-color: #007aff;
border-radius: 10rpx;
}
.progress-dot {
position: absolute;
top: 50%;
left: 20%;
transform: translate(-50%, -50%);
width: 12rpx;
height: 12rpx;
background-color: #007aff;
border-radius: 50%;
}
.benefits-section {
margin: 20rpx;
padding: 0 20rpx;
}
.section-title {
font-size: 32rpx;
margin-bottom: 20rpx;
padding-left: 10rpx;
}
.benefit-list {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 16rpx;
}
.benefit-item {
width: 180rpx;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 12rpx;
padding: 16rpx 12rpx;
text-align: center;
box-sizing: border-box;
}
.icon {
width: 36rpx;
height: 36rpx;
object-fit: cover;
margin-bottom: 8rpx;
}
.benefit-name {
font-size: 22rpx;
margin-bottom: 4rpx;
font-weight: bold;
}
.benefit-desc {
font-size: 18rpx;
color: #ccc;
line-height: 1.4;
}
.time-tag {
font-size: 18rpx;
color: white;
background-color: #ff6b00;
padding: 4rpx 8rpx;
border-radius: 8rpx;
margin-top: 8rpx;
display: inline-block;
}
.more-benefits {
margin: 30rpx 20rpx 0;
padding: 20rpx;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 12rpx;
text-align: center;
}
.more-text {
font-size: 24rpx;
color: #ccc;
margin-bottom: 10rpx;
}
</style>
... ...
<template>
<view class="profile-page">
<up-navbar bgColor="transparent" :z-index="999" :autoBack="true">
<template #left>
<view class="u-nav-slot">
<up-icon name="arrow-left" color="#333" size="15"></up-icon>
</view>
</template>
</up-navbar>
<view class="header-section">
<view class="header-content">
<view class="title">我的运动资料</view>
<view class="subtitle">匹配专属训练计划</view>
</view>
<view class="header-card">
<view class="line"></view>
<view class="line"></view>
<view class="line"></view>
<view class="line"></view>
</view>
</view>
<view class="form-card-container">
<view class="form-list">
<view class="form-item" @click="openRadioPopup('gender', '性别')">
<text class="label">性别</text>
<view class="right">
<text class="value" v-if="pdata.gender">{{ pdata.gender == 1 ? '男' : '女' }}</text>
<text class="placeholder-text" v-else>请选择</text>
<up-icon name="arrow-right" size="12" color="#cccccc"></up-icon>
</view>
</view>
<view class="form-item" @click="Dateshow = true">
<text class="label">生日</text>
<view class="right">
<text class="value" v-if="pdata.birthday">{{ formatDate(pdata.birthday) }}</text>
<text class="placeholder-text" v-else>请选择</text>
<up-icon name="arrow-right" size="12" color="#cccccc"></up-icon>
</view>
</view>
<view class="form-item" @click="openInputPopup('height', '身高')">
<text class="label">身高</text>
<view class="right">
<text class="value" v-if="pdata.height">{{ pdata.height }} cm</text>
<text class="placeholder-text" v-else>未填写</text>
<up-icon name="arrow-right" size="12" color="#cccccc"></up-icon>
</view>
</view>
<view class="form-item" @click="openInputPopup('weight', '当前体重')">
<text class="label">当前体重</text>
<view class="right">
<text class="value" v-if="pdata.weight">{{ pdata.weight }} kg</text>
<text class="placeholder-text" v-else>未填写</text>
<up-icon name="arrow-right" size="12" color="#cccccc"></up-icon>
</view>
</view>
<view class="form-item" @click="openInputPopup('targetWeight', '目标体重')">
<text class="label">目标体重</text>
<view class="right">
<text class="value" v-if="pdata.targetWeight">{{ pdata.targetWeight }} kg</text>
<text class="placeholder-text" v-else>未填写</text>
<up-icon name="arrow-right" size="12" color="#cccccc"></up-icon>
</view>
</view>
<view class="form-item" @click="openRadioPopup('hasFitnessFoundation', '健身基础')">
<text class="label">健身基础</text>
<view class="right">
<text class="value" v-if="pdata.hasFitnessFoundation">{{
pdata.hasFitnessFoundation == 1 ? '有' : '无'
}}</text>
<text class="placeholder-text" v-else>请选择</text>
<up-icon name="arrow-right" size="12" color="#cccccc"></up-icon>
</view>
</view>
<view class="form-item" @click="openRadioPopup('acceptableTrainingFrequency', '可接受的训练频次')">
<text class="label">可接受的训练频次</text>
<view class="right">
<text class="value" v-if="pdata.acceptableTrainingFrequency">
{{ pdata.acceptableTrainingFrequency == 1 ? '1练/2练/3练' : '4练/5练/6练' }}
</text>
<text class="placeholder-text" v-else>请选择</text>
<up-icon name="arrow-right" size="12" color="#cccccc"></up-icon>
</view>
</view>
<view class="form-item" @click="openCheckPopup('targetMuscleParts', '想锻炼的肌肉部位')">
<text class="label">想锻炼的肌肉部位</text>
<view class="right">
<text class="value" v-if="pdata.targetMuscleParts && pdata.targetMuscleParts.length">
{{ pdata.targetMuscleParts.join('、') }}
</text>
<text class="placeholder-text" v-else>请选择(多选)</text>
<up-icon name="arrow-right" size="12" color="#cccccc"></up-icon>
</view>
</view>
<view class="form-item" @click="openRadioPopup('trainingGoal', '训练目标')">
<text class="label">训练目标</text>
<view class="right">
<text class="value" v-if="pdata.trainingGoal">{{
formatGoal(pdata.trainingGoal)
}}</text>
<text class="placeholder-text" v-else>请选择</text>
<up-icon name="arrow-right" size="12" color="#cccccc"></up-icon>
</view>
</view>
<view class="form-item" @click="openCheckPopup('painAreas', '身体疼痛部位')">
<text class="label">身体疼痛部位</text>
<view class="right">
<text class="value" v-if="pdata.painAreas && pdata.painAreas.length">
{{ pdata.painAreas.join('、') }}
</text>
<text class="placeholder-text" v-else>无明显疼痛</text>
<up-icon name="arrow-right" size="12" color="#cccccc"></up-icon>
</view>
</view>
<view class="form-item" @click="openRadioPopup('hasDisease', '当前是否存在疾病')">
<text class="label">当前是否存在疾病</text>
<view class="right">
<text class="value" v-if="pdata.hasDisease">{{
pdata.hasDisease == 1 ? '有' : '无'
}}</text>
<text class="placeholder-text" v-else>请选择</text>
<up-icon name="arrow-right" size="12" color="#cccccc"></up-icon>
</view>
</view>
<view class="form-item" @click="openRadioPopup('isTakingMedication', '当前是否在服药')">
<text class="label">当前是否在服药</text>
<view class="right">
<text class="value" v-if="pdata.isTakingMedication">{{
pdata.isTakingMedication == 1 ? '是' : '否'
}}</text>
<text class="placeholder-text" v-else>请选择</text>
<up-icon name="arrow-right" size="12" color="#cccccc"></up-icon>
</view>
</view>
<view class="form-item" @click="openRadioPopup('rewardMethod', '达成目标如何奖励自己')">
<text class="label">达成目标如何奖励自己</text>
<view class="right">
<text class="value" v-if="pdata.rewardMethod">{{
formatReward(pdata.rewardMethod)
}}</text>
<text class="placeholder-text" v-else>请选择</text>
<up-icon name="arrow-right" size="12" color="#cccccc"></up-icon>
</view>
</view>
<view class="form-item" @click="openRadioPopup('fitnessScene', '平常在哪里健身')">
<text class="label">平常在哪里健身</text>
<view class="right">
<text class="value" v-if="pdata.fitnessScene">{{
formatScene(pdata.fitnessScene)
}}</text>
<text class="placeholder-text" v-else>请选择</text>
<up-icon name="arrow-right" size="12" color="#cccccc"></up-icon>
</view>
</view>
</view>
</view>
<view class="footer-action-bar">
<button class="confirm-btn" :loading="isSaving" @click="saveHealthInfo">
生成专属训练计划
</button>
</view>
<up-popup :show="radioPopup" @close="closeRadioPopup" round="16rpx" mode="bottom" closeable>
<view class="popup-box">
<view class="popup-header">{{ currentTitle }}</view>
<scroll-view scroll-y class="popup-scroll-area">
<view class="select-list">
<view class="select-item" :class="{ active: item.value === bufferValue }"
v-for="(item, index) in formOptions[currentKey]" :key="index" @click="bufferValue = item.value">
<text class="item-text">{{ item.label }}</text>
<up-icon v-if="item.value === bufferValue" name="checkbox-mark" color="#ffde00" size="18"></up-icon>
</view>
</view>
</scroll-view>
<view class="popup-footer">
<button class="action-btn" @click="submitRadio">确 定</button>
</view>
</view>
</up-popup>
<up-popup :show="inputPopup" @close="closeInputPopup" round="16rpx" mode="bottom" closeable>
<view class="popup-box">
<view class="popup-header">{{ currentTitle }}</view>
<view class="input-content-area">
<up-input placeholder="请输入有效数字" border="surround" type="digit" v-model="bufferValue" clearable
customStyle="background-color: #f9f9f9; padding: 24rpx;"></up-input>
<text class="unit-text">{{ currentKey === 'height' ? 'cm' : 'kg' }}</text>
</view>
<view class="popup-footer">
<button class="action-btn" @click="submitInput">确 定</button>
</view>
</view>
</up-popup>
<up-popup :show="checkPopup" @close="closeCheckPopup" round="16rpx" mode="bottom" closeable>
<view class="popup-box">
<view class="popup-header">{{ currentTitle }}</view>
<scroll-view scroll-y class="popup-scroll-area">
<view class="select-list grid-layout">
<view class="select-item-chip" :class="{ active: bufferArray.includes(item.value) }"
v-for="(item, index) in formOptions[currentKey]" :key="index" @click="toggleCheckItem(item.value)">
{{ item.label }}
</view>
</view>
</scroll-view>
<view class="popup-footer">
<button class="action-btn" @click="submitCheck">确 定</button>
</view>
</view>
</up-popup>
<up-datetime-picker :show="Dateshow" v-model="datePickerValue" mode="date" title="选择生日" @cancel="Dateshow = false"
@confirm="handleSelectDate" :minDate="-1577836800000" :maxDate="Date.now()"></up-datetime-picker>
</view>
</template>
<script setup>
import { ref, reactive, toRaw } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import UserApi from '@/sheep/api/member/user';
import dayjs from 'dayjs';
// ========================================================
// 响应式单态核心数据集
// ========================================================
const Dateshow = ref(false);
const radioPopup = ref(false);
const inputPopup = ref(false);
const checkPopup = ref(false);
const isSaving = ref(false);
// 状态机寄存指针管理
const currentKey = ref('');
const currentTitle = ref('');
const bufferValue = ref(null); // 纯量缓冲区
const bufferArray = ref([]); // 数组队列多选缓冲区
const datePickerValue = ref(Date.now());
const pdata = reactive({
id: '',
birthday: '',
gender: 0,
height: '',
weight: '',
targetWeight: '',
hasFitnessFoundation: 0,
acceptableTrainingFrequency: 0,
targetMuscleParts: [],
trainingGoal: 0,
painAreas: [],
hasDisease: 0,
isTakingMedication: 0,
rewardMethod: 0,
fitnessScene: 0,
});
// 数据字典隔离配置
const formOptions = {
gender: [
{ label: '男', value: 1 },
{ label: '女', value: 2 },
],
hasFitnessFoundation: [
{ label: '有', value: 1 },
{ label: '无', value: 2 },
],
acceptableTrainingFrequency: [
{ label: '1练/2练/3练', value: 1 },
{ label: '4练/5练/6练', value: 2 },
],
targetMuscleParts: [
{ label: '肩颈', value: '肩颈' },
{ label: '斜方肌', value: '斜方肌' },
{ label: '手臂', value: '手臂' },
{ label: '胸部', value: '胸部' },
{ label: '背部', value: '背部' },
{ label: '腹部', value: '腹部' },
{ label: '臀部', value: '臀部' },
{ label: '腿部', value: '腿部' },
],
trainingGoal: [
{ label: '减脂', value: 1 },
{ label: '增肌', value: 2 },
{ label: '塑形', value: 3 },
{ label: '拉伸/体态调整', value: 4 },
],
painAreas: [
{ label: '肩颈', value: '肩颈' },
{ label: '手腕', value: '手腕' },
{ label: '腰部', value: '腰部' },
{ label: '脚踝', value: '脚踝' },
{ label: '膝盖', value: '膝盖' },
],
hasDisease: [
{ label: '有', value: 1 },
{ label: '无', value: 2 },
],
isTakingMedication: [
{ label: '是', value: 1 },
{ label: '否', value: 2 },
],
rewardMethod: [
{ label: '买件新衣服/装备', value: 1 },
{ label: '去旅行', value: 2 },
{ label: '和朋友聚会', value: 3 },
{ label: '其他', value: 4 },
],
fitnessScene: [
{ label: '健身房', value: 1 },
{ label: '家', value: 2 },
{ label: '宿舍', value: 3 },
],
};
// ========================================================
// 文本高阶转义映射器
// ========================================================
const formatDate = (val) => (val ? dayjs(val).format('YYYY-MM-DD') : '');
const formatGoal = (val) => ({ 1: '减脂', 2: '增肌', 3: '塑形', 4: '拉伸/体态调整' }[val] || '');
const formatReward = (val) =>
({ 1: '买件新衣服/装备', 2: '去旅行', 3: '和朋友聚会', 4: '其他' }[val] || '');
const formatScene = (val) => ({ 1: '健身房', 2: '家', 3: '宿舍' }[val] || '');
// ========================================================
// 核心弹窗调度器(精细化防污染管控)
// ========================================================
const openRadioPopup = (key, title) => {
currentKey.value = key;
currentTitle.value = title;
bufferValue.value = pdata[key] || null; // 载入安全缓冲区
radioPopup.value = true;
};
const closeRadioPopup = () => {
radioPopup.value = false;
};
const submitRadio = () => {
if (bufferValue.value !== null) {
pdata[currentKey.value] = bufferValue.value; // 仅在此处准许回填修改
}
radioPopup.value = false;
};
const openInputPopup = (key, title) => {
currentKey.value = key;
currentTitle.value = title;
bufferValue.value = pdata[key] !== '' ? String(pdata[key]) : '';
inputPopup.value = true;
};
const closeInputPopup = () => {
inputPopup.value = false;
};
const submitInput = () => {
const num = parseFloat(bufferValue.value);
// 安全阈值边界拦截校验
if (isNaN(num) || num <= 0 || num > 300) {
uni.showToast({ title: '请输入合理区间值', icon: 'none' });
return;
}
pdata[currentKey.value] = num.toFixed(1);
inputPopup.value = false;
};
const openCheckPopup = (key, title) => {
currentKey.value = key;
currentTitle.value = title;
bufferArray.value = Array.isArray(pdata[key]) ? [...pdata[key]] : []; // 解耦深拷贝
checkPopup.value = true;
};
const toggleCheckItem = (val) => {
const idx = bufferArray.value.indexOf(val);
if (idx > -1) {
bufferArray.value.splice(idx, 1);
} else {
bufferArray.value.push(val);
}
};
const closeCheckPopup = () => {
checkPopup.value = false;
};
const submitCheck = () => {
pdata[currentKey.value] = [...bufferArray.value];
checkPopup.value = false;
};
const handleSelectDate = (e) => {
Dateshow.value = false;
pdata.birthday = dayjs(e.value).format('YYYY-MM-DD');
};
// ========================================================
// 网络数据交互编排
// ========================================================
const getHealthData = async () => {
try {
const res = await UserApi.getHealthInfo();
const data = res?.data || {};
Object.keys(pdata).forEach((key) => {
if (key === 'hasDisease' || key === 'isTakingMedication') {
pdata[key] = data[key] === true ? 1 : data[key] === false ? 2 : 0;
} else if (key === 'targetMuscleParts' || key === 'painAreas') {
pdata[key] = Array.isArray(data[key]) ? data[key] : [];
} else {
pdata[key] = data[key] !== undefined && data[key] !== null ? data[key] : '';
}
});
if (pdata.birthday) {
datePickerValue.value = dayjs(pdata.birthday).valueOf();
}
} catch (error) {
console.error('拉取档案失败:', error);
}
};
const saveHealthInfo = async () => {
// 基础必填拦截防空检测
if (!pdata.gender || !pdata.birthday || !pdata.height || !pdata.weight) {
uni.showToast({ title: '请完整填写真实核心资料', icon: 'none' });
return;
}
if (isSaving.value) return;
isSaving.value = true;
try {
const payload = {
...toRaw(pdata),
hasDisease: pdata.hasDisease === 1,
isTakingMedication: pdata.isTakingMedication === 1,
};
if (pdata.id) {
await UserApi.UpdateHealthInfo(payload);
} else {
await UserApi.createHealthInfo(payload);
}
uni.showToast({ title: '专属计划已生成', icon: 'success' });
setTimeout(() => {
uni.navigateBack();
}, 1500);
} catch (error) {
console.error('提交健康资料异常:', error);
uni.showToast({ title: '保存失败,请重试', icon: 'none' });
} finally {
isSaving.value = false;
}
};
onShow(() => {
getHealthData();
});
</script>
<style lang="scss" scoped>
.profile-page {
background-color: #f7f8fa;
min-height: 100vh;
/* 解决长页面底部被悬浮动作条覆盖的问题 */
padding-bottom: 180rpx;
box-sizing: border-box;
.u-nav-slot {
background-color: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(4px);
padding: 12rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.header-section {
width: 100%;
height: 22vh;
/* #ifdef MP-WEIXIN */
height: 26vh;
/* #endif */
background: linear-gradient(135deg, #ffd166 0%, #ffb74d 100%);
position: relative;
padding-left: 44rpx;
padding-bottom: 50rpx;
display: flex;
align-items: flex-end;
box-sizing: border-box;
.header-content {
display: flex;
flex-direction: column;
z-index: 2;
.title {
font-size: 46rpx;
font-weight: bold;
color: #4a2c00;
margin-bottom: 8rpx;
}
.subtitle {
font-size: 26rpx;
color: rgba(74, 44, 0, 0.7);
}
}
.header-card {
position: absolute;
right: 30rpx;
bottom: -10rpx;
width: 150rpx;
height: 180rpx;
background: linear-gradient(135deg, #ff6b6b, #ff8e53);
border-radius: 20rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16rpx;
transform: rotate(12deg);
box-shadow: 0 12rpx 24rpx rgba(255, 107, 107, 0.25);
z-index: 1;
.line {
width: 55%;
height: 8rpx;
background-color: rgba(255, 255, 255, 0.9);
border-radius: 6rpx;
}
}
}
/* 修复:移除有隐患的全局 translateY 移动,改用规范的卡片间距布局 */
.form-card-container {
padding: 0 30rpx;
margin-top: 30rpx;
.form-list {
background-color: #ffffff;
border-radius: 24rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.015);
overflow: hidden;
/* 完美约束子项按圆角外接圆渲染 */
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 36rpx 30rpx;
border-bottom: 1rpx solid #f2f3f5;
transition: background-color 0.2s;
&:active {
background-color: #f9f9f9;
}
&:last-child {
border-bottom: none;
}
.label {
font-size: 28rpx;
color: #323233;
font-weight: 600;
}
.right {
display: flex;
align-items: center;
gap: 12rpx;
max-width: 65%;
.value {
font-size: 28rpx;
color: #323233;
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.placeholder-text {
font-size: 28rpx;
color: #c8c9cc;
}
}
}
}
}
/* 固定悬浮底部动作栏 */
.footer-action-bar {
position: fixed;
bottom: 0;
left: 0;
width: 100vw;
background-color: #ffffff;
padding: 24rpx 40rpx calc(24rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
box-shadow: 0 -4rpx 24rpx rgba(0, 0, 0, 0.04);
z-index: 99;
.confirm-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
background: linear-gradient(90deg, #ffe100 0%, #ffc400 100%);
color: #323233;
font-size: 32rpx;
font-weight: bold;
border-radius: 44rpx;
border: none;
&::after {
border: none;
}
}
}
/* 弹出层统一样式规范 */
.popup-box {
background-color: #ffffff;
border-radius: 24rpx 24rpx 0 0;
padding: 30rpx 40rpx calc(30rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
.popup-header {
font-size: 32rpx;
font-weight: bold;
color: #323233;
text-align: center;
padding-bottom: 30rpx;
}
.popup-scroll-area {
max-height: 40vh;
}
.input-content-area {
display: flex;
align-items: center;
gap: 20rpx;
padding: 20rpx 0 40rpx;
.unit-text {
font-size: 30rpx;
color: #323233;
font-weight: bold;
}
}
.select-list {
display: flex;
flex-direction: column;
gap: 16rpx;
&.grid-layout {
flex-direction: row;
flex-wrap: wrap;
gap: 20rpx;
}
.select-item {
display: flex;
justify-content: space-between;
align-items: center;
background-color: #f7f8fa;
padding: 30rpx 40rpx;
border-radius: 16rpx;
transition: all 0.2s;
&.active {
background-color: #fffdf0;
border: 2rpx solid #ffe100;
}
.item-text {
font-size: 28rpx;
color: #323233;
font-weight: 500;
}
}
/* 多选网格纸片样式 */
.select-item-chip {
padding: 20rpx 36rpx;
background-color: #f7f8fa;
border-radius: 40rpx;
font-size: 26rpx;
color: #646566;
transition: all 0.2s;
border: 2rpx solid transparent;
&.active {
background-color: #fffdf0;
color: #323233;
font-weight: bold;
border-color: #ffe100;
}
}
}
.popup-footer {
margin-top: 40rpx;
.action-btn {
width: 100%;
height: 80rpx;
line-height: 80rpx;
background-color: #ffe100;
color: #323233;
font-size: 28rpx;
font-weight: bold;
border-radius: 40rpx;
border: none;
&::after {
border: none;
}
}
}
}
}
</style>
... ...
<template>
<view class="level-page">
<!-- 导航栏 -->
<uni-nav-bar title="华创信" left-icon="left" @click-left="goBack" :fixed="true" :status-bar="true" />
<view class="top-right">
<!-- <u-icon name="document" size="24" color="#fff" @click="showRules"></u-icon> -->
<text class="right-text">华创信</text>
</view>
<!-- 用户信息 -->
<view class="user-info">
<image src="https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260316/order-empty_1773628059920.png"
mode="aspectFill" class="avatar"></image>
<text class="username">华创信开发者</text>
</view>
<!-- 更多权益 -->
<view class="more-benefits">
<view class="more-text">华创信开发者</view>
<view class="more-text">系统开发中</view>
<view class="more-text">敬请期待~</view>
</view>
</view>
</template>
<script>
export default {
methods: {
goBack() {
uni.navigateBack();
},
showRules() {
uni.showToast({ title: '查看开发内容', icon: 'success' });
},
},
};
</script>
<style scoped>
.level-page {
position: relative;
width: 100%;
min-height: 100vh;
background-color: #1a1a1a;
color: white;
padding-bottom: 40rpx;
}
.top-right {
position: absolute;
top: 20rpx;
right: 20rpx;
display: flex;
align-items: center;
gap: 10rpx;
color: white;
font-size: 24rpx;
}
.user-info {
display: flex;
align-items: center;
gap: 20rpx;
padding: 20rpx;
margin-top: 20rpx;
}
.avatar {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
object-fit: cover;
}
.username {
font-size: 28rpx;
}
.level-card {
width: 600rpx;
height: 300rpx;
background-color: rgba(173, 216, 230, 0.3);
border-radius: 20rpx;
padding: 30rpx;
margin: 20rpx auto;
position: relative;
overflow: hidden;
}
.level-name {
font-size: 40rpx;
font-weight: bold;
margin-bottom: 10rpx;
}
.level-en {
font-size: 36rpx;
font-weight: bold;
margin-bottom: 20rpx;
}
.require {
font-size: 24rpx;
margin-bottom: 20rpx;
}
.detail {
font-size: 24rpx;
color: #ccc;
}
.progress-bar {
width: 600rpx;
height: 20rpx;
background-color: #333;
border-radius: 10rpx;
margin: 20rpx auto;
position: relative;
overflow: hidden;
}
.progress-track {
width: 100%;
height: 100%;
background-color: #333;
border-radius: 10rpx;
}
.progress-fill {
width: 20%;
height: 100%;
background-color: #007aff;
border-radius: 10rpx;
}
.progress-dot {
position: absolute;
top: 50%;
left: 20%;
transform: translate(-50%, -50%);
width: 12rpx;
height: 12rpx;
background-color: #007aff;
border-radius: 50%;
}
.benefits-section {
margin: 20rpx;
padding: 0 20rpx;
}
.section-title {
font-size: 32rpx;
margin-bottom: 20rpx;
padding-left: 10rpx;
}
.benefit-list {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 16rpx;
}
.benefit-item {
width: 180rpx;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 12rpx;
padding: 16rpx 12rpx;
text-align: center;
box-sizing: border-box;
}
.icon {
width: 36rpx;
height: 36rpx;
object-fit: cover;
margin-bottom: 8rpx;
}
.benefit-name {
font-size: 22rpx;
margin-bottom: 4rpx;
font-weight: bold;
}
.benefit-desc {
font-size: 18rpx;
color: #ccc;
line-height: 1.4;
}
.time-tag {
font-size: 18rpx;
color: white;
background-color: #ff6b00;
padding: 4rpx 8rpx;
border-radius: 8rpx;
margin-top: 8rpx;
display: inline-block;
}
.more-benefits {
margin: 30rpx 20rpx 0;
padding: 20rpx;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 12rpx;
text-align: center;
}
.more-text {
font-size: 24rpx;
color: #ccc;
margin-bottom: 10rpx;
}
</style>
... ...
<template>
<view class="settings-page">
<!-- 手机号设置 -->
<view class="setting-item">
<view class="setting-label">手机号</view>
<view class="setting-value">{{ phone || '未设置' }}</view>
</view>
<!-- 微信绑定状态 -->
<view class="setting-item">
<view class="setting-label">微信绑定</view>
<view class="setting-status" :class="{ bound: isBindWx === 1, unbound: isBindWx === 0 }">
{{ isBindWx === 1 ? '已绑定' : '未绑定' }}
</view>
<!-- <button v-if="isBindWx === 0" class="bind-btn" @click="bindWeChat">绑定微信</button>
<button v-else class="unbind-btn" @click="unbindWeChat">解绑</button> -->
</view>
<!-- 推送通知开关 -->
<!-- <view class="setting-item notice">
<view class="content">
<view class="setting-label">推送通知</view>
<switch :checked="pushNoticeSwitch === 1" @change="onPushNoticeChange" color="#007AFF" />
</view>
<view class="tip">包含订单状态、优惠促销等重要信息的推送</view>
</view> -->
<!-- 个性化推荐开关 -->
<!-- <view class="setting-item">
<view class="setting-label">个性化推荐</view>
<switch :checked="personRecommendSwitch === 1" @change="onRecommendChange" color="#007AFF" />
</view> -->
<!-- 退出登录 -->
<view class="logout-section">
<button class="logout-btn" @click="logout">退出登录</button>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import SettingApi from '@/sheep/api/setting/setting';
import AuthUtil from '@/sheep/api/member/auth';
import useUserStore from '@/sheep/store/user';
import UserApi from '@/sheep/api/member/user';
// 数据状态
const phone = ref('');
const isBindWx = ref(0); // 0:未绑定, 1:已绑定
const pushNoticeSwitch = ref(0); // 0:关闭, 1:开启
const personRecommendSwitch = ref(0); // 0:关闭, 1:开启
const userStore = useUserStore();
// 生命周期
onShow(() => {
loadUserSettings();
});
// 加载用户设置
const loadUserSettings = async () => {
// 这里调用后端接口获取设置数据
const res = await SettingApi.getSetting();
phone.value = res.data.phone;
isBindWx.value = res.data.isBindWx;
pushNoticeSwitch.value = res.data.pushNoticeSwitch;
personRecommendSwitch.value = res.data.personRecommendSwitch;
};
// // 微信绑定
// const bindWeChat = () => {
// uni.showModal({
// title: '绑定微信',
// content: '确定要绑定微信吗?',
// success: async (res) => {
// if (res.confirm) {
// try {
// // 这里调用绑定微信接口
// // await uni.request({
// // url: '/api/user/bind-wechat',
// // method: 'POST'
// // })
// isBindWx.value = 1;
// uni.showToast({
// title: '绑定成功',
// icon: 'success',
// });
// } catch (error) {
// console.error('绑定失败:', error);
// uni.showToast({
// title: '绑定失败',
// icon: 'none',
// });
// }
// }
// },
// });
// };
// // 微信解绑
// const unbindWeChat = () => {
// uni.showModal({
// title: '解绑微信',
// content: '确定要解绑微信吗?',
// success: async (res) => {
// if (res.confirm) {
// try {
// // 这里调用解绑微信接口
// // await uni.request({
// // url: '/api/user/unbind-wechat',
// // method: 'POST'
// // })
// isBindWx.value = 0;
// uni.showToast({
// title: '解绑成功',
// icon: 'success',
// });
// } catch (error) {
// console.error('解绑失败:', error);
// uni.showToast({
// title: '解绑失败',
// icon: 'none',
// });
// }
// }
// },
// });
// };
// 推送通知开关改变
const onPushNoticeChange = async (e) => {
const newValue = e.detail.value ? 1 : 0;
try {
// 这里调用更新设置接口
await SettingApi.updateNoticeSetting({
pushNoticeSwitch: newValue,
personRecommendSwitch: personRecommendSwitch.value,
});
pushNoticeSwitch.value = newValue;
uni.showToast({
title: newValue ? '已开启推送' : '已关闭推送',
icon: 'success',
});
} catch (error) {
console.error('更新推送设置失败:', error);
// 恢复原状态
pushNoticeSwitch.value = pushNoticeSwitch.value === 1 ? 0 : 1;
uni.showToast({
title: '设置失败',
icon: 'none',
});
}
};
// 个性化推荐开关改变
const onRecommendChange = async (e) => {
const newValue = e.detail.value ? 1 : 0;
try {
// 这里调用更新设置接口
await SettingApi.updateNoticeSetting({
personRecommendSwitch: newValue,
pushNoticeSwitch: pushNoticeSwitch.value,
});
personRecommendSwitch.value = newValue;
uni.showToast({
title: newValue ? '已开启推荐' : '已关闭推荐',
icon: 'success',
});
} catch (error) {
console.error('更新推荐设置失败:', error);
// 恢复原状态
personRecommendSwitch.value = personRecommendSwitch.value === 1 ? 0 : 1;
uni.showToast({
title: '设置失败',
icon: 'none',
});
}
};
// 退出登录
const logout = () => {
uni.showModal({
title: '提示',
content: '确定要退出登录吗?',
success: (res) => {
if (res.confirm) {
// 调用退出登录接口
AuthUtil.logout().then(() => {
// 清空用户信息
userStore.logout();
// 跳转到登录页
uni.reLaunch({ url: '/pages/index/index' });
});
}
},
});
};
</script>
<style lang="scss" scoped>
// 定义全局变量,方便统一管理
$primary-color: #007aff;
$danger-color: #ff4757;
$danger-active-color: #e63946;
$bg-color: #f5f5f5;
$white-color: #ffffff;
$text-primary: #333333;
$text-secondary: #666666;
$text-tertiary: #999999;
$border-radius-base: 16rpx;
$border-radius-small: 8rpx;
$padding-base: 30rpx;
$padding-small: 20rpx;
.settings-page {
background-color: $bg-color;
padding: $padding-small;
// 设置项通用样式
.setting-item {
background-color: $white-color;
margin-bottom: $padding-small;
padding: $padding-base;
border-radius: $border-radius-base;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
// 标签样式
.setting-label {
font-size: 32rpx;
color: $text-primary;
font-weight: 500;
}
// 值样式
.setting-value {
font-size: 28rpx;
color: $text-secondary;
}
// 状态样式
.setting-status {
font-size: 28rpx;
font-weight: 500;
margin-right: 20rpx;
&.bound {
color: $primary-color;
}
&.unbound {
color: $text-tertiary;
}
}
// 绑定/解绑按钮
.bind-btn,
.unbind-btn {
background-color: $primary-color;
color: $white-color;
border: none;
border-radius: $border-radius-small;
padding: 16rpx 24rpx;
font-size: 26rpx;
&.unbind-btn {
background-color: $danger-color;
}
}
// 通知设置特殊样式
&.notice {
display: block;
.content {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.tip {
font-size: 24rpx;
color: $text-tertiary;
line-height: 1.4;
}
}
}
.notice {
display: flex;
flex-direction: column;
}
// 退出登录区域
.logout-section {
margin-top: 60rpx;
padding: $padding-small;
.logout-btn {
width: 100%;
background-color: $danger-color;
color: $white-color;
border: none;
border-radius: $border-radius-base;
padding: $padding-base;
font-size: 32rpx;
font-weight: 500;
&:active {
background-color: $danger-active-color;
}
}
}
}
</style>
... ...
<template>
<view class="privacy-settings-page">
<!-- 隐私设置列表 -->
<view class="settings-container">
<view class="section">
<view class="section-title">主页展示</view>
<!-- 运动轨迹 -->
<view class="setting-item">
<view class="setting-info">
<view class="setting-name">运动轨迹</view>
<view class="setting-desc">开启后,将对其他用户展示您近半年内的运动活跃程度</view>
</view>
<switch :checked="movementLocusSwitch === 1" @change="onMovementLocusChange" color="#007AFF" />
</view>
<!-- 共同喜好 -->
<view class="setting-item">
<view class="setting-info">
<view class="setting-name">共同喜好</view>
<view class="setting-desc">开启后,将对其他用户显示你们共同喜好的教练和课程</view>
</view>
<switch :checked="commonInterestSwitch === 1" @change="onCommonInterestChange" color="#007AFF" />
</view>
</view>
<view class="section">
<view class="section-title">门店TV展示</view>
<!-- 勋章和好友竞赛 -->
<view class="setting-item">
<view class="setting-info">
<view class="setting-name">勋章和好友竞赛</view>
<view class="setting-desc">开启后,门店TV电视将展示你的勋章排行、获得播报、好友竞赛情况等信息</view>
</view>
<switch :checked="medalFriendSwitch === 1" @change="onMedalFriendChange" color="#007AFF" />
</view>
</view>
<view class="section">
<view class="section-title">课程预约</view>
<!-- 团课约课信息 -->
<view class="setting-item">
<view class="setting-info">
<view class="setting-name">团课约课信息</view>
<view class="setting-desc">开启后,预约团课时将对外展示您的头像和昵称</view>
</view>
<switch :checked="leagueClassSwitch === 1" @change="onLeagueClassChange" color="#007AFF" />
</view>
<!-- 私教训练报告 -->
<view class="setting-item">
<view class="setting-info">
<view class="setting-name">私教训练报告</view>
<view class="setting-desc">开启后,将允许教练对外分享您的训练报告</view>
</view>
<switch :checked="personalTrainingSwitch === 1" @change="onPersonalTrainingChange" color="#007AFF" />
</view>
<!-- 小班课战队信息 -->
<view class="setting-item">
<view class="setting-info">
<view class="setting-name">小班课战队信息</view>
<view class="setting-desc">开启后,战队详情将不对外展示您的信息</view>
</view>
<switch :checked="miniClassTeamSwitch === 1" @change="onMiniClassTeamChange" color="#007AFF" />
</view>
</view>
<view class="section">
<view class="section-title">账号设置</view>
<!-- 主页私密账户 -->
<view class="setting-item">
<view class="setting-info">
<view class="setting-name">主页私密账户</view>
<view class="setting-desc">开启后,您的会员主页将不对外展示任何信息</view>
</view>
<switch :checked="homepagePrivacySwitch === 1" @change="onHomepagePrivacyChange" color="#007AFF" />
</view>
<!-- 排行榜 -->
<view class="setting-item">
<view class="setting-info">
<view class="setting-name">排行榜</view>
<view class="setting-desc">开启后,在会员排行榜上将展示您的头像和昵称</view>
</view>
<switch :checked="rankingSwitch === 1" @change="onRankingChange" color="#007AFF" />
</view>
</view>
</view>
</view>
</template>
<script setup>
import SettingApi from '@/sheep/api/setting/setting';
import { onShow, onUnload } from '@dcloudio/uni-app';
import { ref, computed } from 'vue';
// 隐私设置数据状态
const movementLocusSwitch = ref(0); // 运动轨迹
const commonInterestSwitch = ref(0); // 共同喜好
const medalFriendSwitch = ref(0); // 勋章和好友竞赛
const leagueClassSwitch = ref(0); // 团课约课信息
const personalTrainingSwitch = ref(0); // 私教训练报告
const miniClassTeamSwitch = ref(0); // 小班课战队信息
const homepagePrivacySwitch = ref(0); // 主页私密账户
const rankingSwitch = ref(0); // 排行榜
// 生命周期
onShow(() => {
loadPrivacySettings();
});
// 页面隐藏时保存变更
onUnload(() => {
saveSettings();
});
// 获取当前设置
const getCurrentSettings = () => ({
movementLocusSwitch: movementLocusSwitch.value,
commonInterestSwitch: commonInterestSwitch.value,
medalFriendSwitch: medalFriendSwitch.value,
leagueClassSwitch: leagueClassSwitch.value,
personalTrainingSwitch: personalTrainingSwitch.value,
miniClassTeamSwitch: miniClassTeamSwitch.value,
homepagePrivacySwitch: homepagePrivacySwitch.value,
rankingSwitch: rankingSwitch.value,
});
// 加载隐私设置
const loadPrivacySettings = async () => {
try {
// 这里调用后端接口获取隐私设置
const res = await SettingApi.getPrivacySetting();
movementLocusSwitch.value = res.data.movementLocusSwitch || 0; // 运动轨迹
commonInterestSwitch.value = res.data.commonInterestSwitch || 0; // 共同喜好
medalFriendSwitch.value = res.data.medalFriendSwitch || 0; // 勋章和好友竞赛
leagueClassSwitch.value = res.data.leagueClassSwitch || 0; // 团课约课信息
personalTrainingSwitch.value = res.data.personalTrainingSwitch || 0; // 私教训练报告
miniClassTeamSwitch.value = res.data.miniClassTeamSwitch || 0; // 小班课战队信息
homepagePrivacySwitch.value = res.data.homepagePrivacySwitch || 0; // 主页私密账户
rankingSwitch.value = res.data.rankingSwitch || 0; // 排行榜
} catch (error) {
console.error('加载隐私设置失败:', error);
uni.showToast({
title: '加载设置失败',
icon: 'none',
});
}
};
// 保存隐私设置
const saveSettings = async () => {
const settings = getCurrentSettings();
try {
// 这里调用后端接口保存隐私设置
await SettingApi.updatePrivacySetting(settings);
} catch (error) {
console.error('保存隐私设置失败:', error);
uni.showToast({
title: '保存失败',
icon: 'none',
});
}
};
// 各个开关的变更处理函数
const onMovementLocusChange = (e) => {
const newValue = e.detail.value ? 1 : 0;
updateSetting('movementLocusSwitch', newValue);
};
const onCommonInterestChange = (e) => {
const newValue = e.detail.value ? 1 : 0;
updateSetting('commonInterestSwitch', newValue);
};
const onMedalFriendChange = (e) => {
const newValue = e.detail.value ? 1 : 0;
updateSetting('medalFriendSwitch', newValue);
};
const onLeagueClassChange = (e) => {
const newValue = e.detail.value ? 1 : 0;
updateSetting('leagueClassSwitch', newValue);
};
const onPersonalTrainingChange = (e) => {
const newValue = e.detail.value ? 1 : 0;
updateSetting('personalTrainingSwitch', newValue);
};
const onMiniClassTeamChange = (e) => {
const newValue = e.detail.value ? 1 : 0;
updateSetting('miniClassTeamSwitch', newValue);
};
const onHomepagePrivacyChange = (e) => {
const newValue = e.detail.value ? 1 : 0;
updateSetting('homepagePrivacySwitch', newValue);
};
const onRankingChange = (e) => {
const newValue = e.detail.value ? 1 : 0;
updateSetting('rankingSwitch', newValue);
};
// 更新设置的通用方法
const updateSetting = (key, value) => {
const refMap = {
movementLocusSwitch: movementLocusSwitch,
commonInterestSwitch: commonInterestSwitch,
medalFriendSwitch: medalFriendSwitch,
leagueClassSwitch: leagueClassSwitch,
personalTrainingSwitch: personalTrainingSwitch,
miniClassTeamSwitch: miniClassTeamSwitch,
homepagePrivacySwitch: homepagePrivacySwitch,
rankingSwitch: rankingSwitch,
};
if (refMap[key]) {
refMap[key].value = value;
}
};
// 返回上一页
// const goBack = () => {
// if (hasChanges.value) {
// uni.showModal({
// title: '提示',
// content: '您有未保存的修改,确定要离开吗?',
// success: (res) => {
// if (res.confirm) {
// uni.navigateBack();
// }
// },
// });
// } else {
// uni.navigateBack();
// }
// };
</script>
<style lang="scss" scoped>
// 定义全局样式变量,便于统一管理和修改
$bg-color: #f5f5f5;
$white-color: #ffffff;
$text-primary: #333333;
$text-secondary: #666666;
$text-tertiary: #999999;
$primary-color: #007aff;
$border-radius-base: 16rpx;
$border-radius-large: 50rpx;
$padding-base: 30rpx;
$padding-small: 20rpx;
$padding-xs: 10rpx;
.privacy-settings-page {
background-color: $bg-color;
min-height: 100vh;
// 设置容器样式
.settings-container {
padding: $padding-small;
// 分区标题
.section-title {
font-size: 28rpx;
color: $text-tertiary;
margin-bottom: $padding-small;
padding-left: $padding-xs;
}
// 设置项样式
.setting-item {
background-color: $white-color;
margin-bottom: $padding-small;
padding: $padding-base;
border-radius: $border-radius-base;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
// 设置项信息区域
.setting-info {
flex: 1;
margin-right: $padding-small;
// 设置项名称
.setting-name {
font-size: 32rpx;
color: $text-primary;
font-weight: 500;
margin-bottom: 8rpx;
}
// 设置项描述
.setting-desc {
font-size: 26rpx;
color: $text-tertiary;
line-height: 1.4;
}
}
}
}
// 保存提示框样式
// .save-notice {
// position: fixed;
// bottom: 60rpx;
// left: 40rpx;
// right: 40rpx;
// background-color: $white-color;
// border-radius: $border-radius-large;
// padding: $padding-small $padding-base;
// display: flex;
// align-items: center;
// justify-content: space-between;
// box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
// .notice-content {
// display: flex;
// align-items: center;
// .notice-icon {
// font-size: 32rpx;
// margin-right: 16rpx;
// }
// .notice-text {
// font-size: 28rpx;
// color: $text-secondary;
// }
// }
// .save-text {
// font-size: 28rpx;
// color: $primary-color;
// font-weight: 500;
// }
// }
}
</style>
... ...
... ... @@ -7,6 +7,8 @@ const FileApi = {
uni.showLoading({
title: '上传中',
});
console.log(file, 'file');
return new Promise((resolve, reject) => {
uni.uploadFile({
url: baseUrl + apiPath + '/infra/file/upload',
... ... @@ -21,6 +23,8 @@ const FileApi = {
directory,
},
success: (uploadFileRes) => {
console.log(uploadFileRes, 'uploadFileRes-');
let result = JSON.parse(uploadFileRes.data);
if (result.error === 1) {
uni.showToast({
... ...
... ... @@ -250,7 +250,7 @@ const UserApi = {
// 获得我的信息
getUserInfo: () => {
return request({
url: '/app/student/myInfo',
url: '/app/user/myInfo',
method: 'GET',
custom: {
showLoading: false,
... ...