Authored by Bad

修复bug

... ... @@ -32,10 +32,14 @@ SHOPRO_STATIC_URL=http://test.yudao.iocoder.cn
### SHOPRO_STATIC_URL = https://file.sheepjs.com
# 前端 H5 访问域名
SHOPRO_H5_URL=http://127.0.0.1:3000
SHOPRO_H5_URL=https://xunji.geaktec.com
# 是否开启直播 1 开启直播 | 0 关闭直播
SHOPRO_MPLIVE_ON=0
# 租户ID 默认 1
SHOPRO_TENANT_ID=1
# 默认头像
DEFAULT_AVATAR=https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260526/默认头像_1779779926983.png
... ...
... ... @@ -6,6 +6,8 @@
:placeholder="false"
activeColor="#000"
:fixed="true"
:bottom="0"
:safeAreaInsetBottom="false"
>
<up-tabbar-item
v-for="(item, index) in tabList"
... ...
{
"name": "智能健身",
"appid": "__UNI__7CAA18B",
"appid": "__UNI__8F24C84",
"description": "基于 uni-app + Vue3 技术驱动的在线商城系统,内含诸多功能与丰富的活动,期待您的使用和反馈。",
"versionName": "2025.10",
"versionCode": "183",
... ...
... ... @@ -100,7 +100,6 @@
"backgroundColor": "#1a1a1a"
}
},
{
"path": "pages/xunji/xunji-dongzuo-lianxi",
"style": {
... ... @@ -113,14 +112,12 @@
"navigationBarTitleText": "新增超级组"
}
},
{
"path": "pages/xunji/wode-jihua-paike",
"style": {
"navigationBarTitleText": "我的计划详情的排课设置"
}
},
{
"path": "pages/xunji/dongzuo-muluguanli",
"style": {
... ... @@ -131,7 +128,8 @@
{
"path": "pages/xunji/jihua-search",
"style": {
"navigationBarTitleText": "训记-计划-搜索"
"navigationBarTitleText": "计划-搜索",
"navigationStyle": "default"
}
},
{
... ... @@ -140,7 +138,7 @@
"navigationBarTitleText": ""
}
},
{
{
"path": "pages/xunji/xunji-wode-jihua",
"style": {
"navigationBarTitleText": ""
... ... @@ -158,14 +156,14 @@
"navigationBarTitleText": "登录",
"navigationStyle": "default"
}
},
{
"path": "pages/index/reset-password",
"style": {
"navigationBarTitleText": "找回密码",
},
{
"path": "pages/index/reset-password",
"style": {
"navigationBarTitleText": "找回密码",
"navigationStyle": "default"
}
}
}
}
]
},
{
... ... @@ -185,7 +183,6 @@
"navigationBarTitleText": "我的常见问题"
}
},
{
"path": "pages/user/wode-guanyu-hongxing",
"style": {
... ... @@ -210,7 +207,7 @@
"navigationBarTitleText": "设置",
"navigationStyle": "default"
}
}
}
]
}
],
... ... @@ -237,4 +234,4 @@
}
]
}
}
}
\ No newline at end of file
... ...
... ... @@ -11,9 +11,7 @@
<text v-if="memberLevelName" class="tag">{{ memberLevelName }}</text>
</view>
</view>
<view class="right">
<uni-icons type="right" size="16" color="#000" />
</view>
<uni-icons type="right" size="16" color="#999" />
</view>
<!-- 未登录引导区 -->
... ... @@ -25,57 +23,8 @@
<button class="login-btn" hover-class="btn-hover" @click="goLogin">立即登录</button>
</view>
<!-- -->
<!-- <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">
{{ userInfo.deposit === 1 ? '鸿星·尊享会员 | 已开通' : '鸿星·会员 | 开通只需押金¥199' }}
</text>
</view>
<view class="vip-btn">{{ userInfo.deposit === 1 ? '查看权益' : '立即开通' }}</view>
</view> -->
<!-- 课程状态快速入口 -->
<!-- <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 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 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">
<image :src="item.icon" class="img" mode="aspectFit" />
</view>
<text class="text">{{ item.text }}</text>
</view>
</view> -->
<!-- 资产账户网格区 -->
<!-- <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>
<text class="acc-val">
{{ formatAccountValue(userInfo[acc.key], acc.isFloat) }}
<text v-if="acc.unit" class="unit">{{ acc.unit }}</text>
</text>
</view>
</view>
</view> -->
<!-- 功能矩阵九宫格 -->
<!-- 功能 -->
<view class="section-card icon-grid-box">
<view class="icon-grid">
<view v-for="(item, index) in FUNCTION_CONFIG_LIST" :key="index" class="icon-item"
... ... @@ -95,15 +44,17 @@
</view>
<!-- 设置区 -->
<view v-if="userStore.isLogin" class="section-card">
<view class="setting-item" @click="authNavigateTo('/pages5/pages/user/wode-shezhi')">
<text class="setting-text">个人设置</text>
<view v-if="userStore.isLogin" class="section-card settings-section">
<view class="settings-title">个人设置</view>
<view class="settings-item" @click="goToForgetPassword">
<text class="settings-label">修改密码</text>
<uni-icons type="right" size="14" color="#E0E0E0" />
</view>
<!-- <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 class="logout-wrap">
<button class="logout-btn" @click="handleLogout">退出登录</button>
</view>
</view>
<Tabbar />
... ... @@ -113,21 +64,23 @@
<script setup>
import { ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import UserApi from '@/sheep/api/member/user';
import MemberApi from '@/sheep/api/member/member';
import AuthUtil from '@/sheep/api/member/auth';
import useUserStore from '@/sheep/store/user';
import { onShow } from '@dcloudio/uni-app';
import TrainingFloating from '@/pages/TrainingFloating.vue'
import { useTrainingStore } from '@/sheep/store/trainingStore';
import TrainingFloating from '@/pages/TrainingFloating.vue';
const trainingStore = useTrainingStore();
// 响应式数据挂载
// 状态
const userStore = useUserStore();
const userInfo = ref({});
const memberLevelName = ref('');
// 固定的 UI 配置
// 配置
const APPLY_CONFIG_LIST = [];
const FUNCTION_CONFIG_LIST = [
... ... @@ -155,31 +108,23 @@ const FUNCTION_CONFIG_LIST = [
];
// 课程计数状态配置映射
const quickEntryConfig = [
{ type: 1, key: 'courseNum', label: '待上课' },
{ type: 2, key: 'courseWaitNum', label: '等待中' },
{ type: 3, key: 'courseEvaluationWaitNum', label: '历史课程' },
];
// 资产账户字段清洗配置映射 (对应接口数据类型:number 与 integer)
// 资产账户
const accountConfig = [];
/**
* JSDoc 核心资产数值洗涤函数
* @param {number|undefined} val 原始金钱/数量值
* @param {boolean} isFloat 是否需要保留两位小数
* @returns {string|number} 格式化后的安全渲染字符串
*/
const formatAccountValue = (val, isFloat) => {
if (val === undefined || val === null) return isFloat ? '0.00' : 0;
return isFloat ? Number(val).toFixed(2) : Math.floor(val);
};
/**
* 路由守卫拦截转发
* @param {string} url 目标绝对/相对地址
*/
/** 路由守卫 */
const authNavigateTo = (url) => {
if (!userStore.isLogin) {
goLogin();
... ... @@ -191,51 +136,26 @@ const authNavigateTo = (url) => {
});
};
/**
* 统一处理功能网格的点击分发
* @param {Object} item 节点配置项
*/
/** 功能网格点击分发 */
const handleGridItemClick = (item) => {
if (item.text === '联系客服') {
// #ifndef MP-WEIXIN
// 兜底非微信小程序平台(如H5、App),可以正常走原来的普通客服页面路由
authNavigateTo(item.url);
// #endif
return;
}
// 其他正常功能,正常走路由拦截守卫
authNavigateTo(item.url);
};
/**
* 异步高内聚合并请求
* 解决因先后触发 setData 导致微信小程序底层 AppService 与 WebView 之间高频拥堵卡顿的问题
*/
/** 拉取用户信息 */
const fetchPageData = async () => {
try {
// const [userRes, levelRes] = await Promise.all([
// UserApi.getUserInfo(),
// // MemberApi.getMemberLevel(),
// ]);
const userRes = await UserApi.getUserInfo();
console.log('后端返回个人信息:', userRes.data);
const rawUser = userRes.data || {};
userInfo.value = rawUser;
// 架构重构:数据拉取后一次性计算出等级映射结果,拒绝在 computed 内部循环执行实例化
// 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 = '';
// }
userInfo.value = userRes.data || {};
} catch (error) {
console.error('[API Error] 拉取个人资产信息流失败:', error);
console.error('[API Error] 拉取个人信息失败:', error);
}
};
... ... @@ -248,7 +168,33 @@ onShow(() => {
}
});
// 路由跳转原子原子层
// 设置
const goToForgetPassword = () => {
uni.navigateTo({
url: '/pages7/pages/index/reset-password?isModify=true',
});
};
const handleLogout = () => {
uni.showModal({
title: '提示',
content: '确定要退出登录吗?',
success: async (res) => {
if (!res.confirm) return;
try {
await AuthUtil.logout();
userStore.logout();
uni.reLaunch({ url: '/pages7/pages/index/login' });
} catch (err) {
console.error('[API Error] 退出登录失败:', err);
uni.showToast({ title: '退出失败', icon: 'none' });
}
},
});
};
// 导航
const goLogin = () => uni.navigateTo({ url: '/pages7/pages/index/login' });
const goMyPersonalData = () => authNavigateTo('/pages5/pages/user/wode-geren-ziliao');
const goAddVip = () => authNavigateTo('/pages5/pages/user/wode-hongxing-huiyuan');
... ... @@ -258,286 +204,215 @@ const handleQuickEntry = (type) => authNavigateTo(`/pages5/pages/user/wode-shang
</script>
<style scoped lang="scss">
$brand-color: #ff6b00;
$page-bg: #f8f8f8;
// 变量
// 主色
$color-primary: #ff6b00;
$color-danger: #ff4757;
$color-danger-active: #e63946;
$color-badge: #ff4d4f;
// 文字
$color-text-primary: #333333;
$color-text-secondary: #555555;
$color-text-tertiary: #999999;
$color-text-white: #ffffff;
// 背景
$color-bg-page: #f7f7f8;
$color-bg-card: #ffffff;
$color-bg-avatar: #f0f0f0;
$color-bg-hover: #fcfcfc;
// 边框
$color-border: #f0f0f0;
// 圆角
$radius-card: 24rpx;
$radius-button: 16rpx;
$radius-round: 50%;
$radius-tag: 8rpx;
$radius-badge: 16rpx;
// 间距
$gap-section: 24rpx;
$gap-page-h: 28rpx;
$padding-card: 30rpx 28rpx;
// 字号
$font-title: 32rpx;
$font-body: 28rpx;
$font-caption: 24rpx;
$font-small: 22rpx;
$font-mini: 18rpx;
// 效果
$shadow-card: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);
$transition-tap: background-color 0.2s ease, opacity 0.2s ease;
// 页面
.my-page {
min-height: 100vh;
background-color: $page-bg;
padding: 20rpx 28rpx calc(40rpx + env(safe-area-inset-bottom));
/* 解决部分 iOS 底部 Tabbar 高度塌陷 */
background-color: $color-bg-page;
padding: 20rpx $gap-page-h calc(40rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
// 卡片
.section-card {
background: #ffffff;
border-radius: 24rpx;
padding: 30rpx 20rpx;
margin-bottom: 24rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.03);
background: $color-bg-card;
border-radius: $radius-card;
padding: $padding-card;
margin-bottom: $gap-section;
box-shadow: $shadow-card;
}
// 登录头部
.user-header {
display: flex;
align-items: center;
padding: 34rpx 30rpx;
padding: 36rpx $gap-page-h;
.avatar {
width: 110rpx;
height: 110rpx;
border-radius: 50%;
background: #f0f0f0;
border: 4rpx solid #fff;
border-radius: $radius-round;
background: $color-bg-avatar;
border: 4rpx solid $color-bg-card;
flex-shrink: 0;
}
.info-content {
margin-left: 24rpx;
flex: 1;
min-width: 0;
.name-row {
display: flex;
flex-direction: column;
gap: 8rpx;
.nickname {
font-size: 34rpx;
font-weight: bold;
color: #333;
font-size: 36rpx;
font-weight: 600;
color: $color-text-primary;
}
.tag {
font-size: 20rpx;
color: $brand-color;
display: inline-block;
font-size: $font-mini;
color: $color-primary;
padding: 4rpx 12rpx;
border-radius: 8rpx;
vertical-align: middle;
border-radius: $radius-tag;
background: rgba($color-primary, 0.08);
}
}
}
.right {
display: flex;
align-items: center;
gap: 60rpx;
.img {
width: 60rpx;
height: 60rpx;
}
}
}
// 未登录引导
.login-guide-box {
display: flex;
justify-content: space-between;
align-items: center;
padding: 36rpx $gap-page-h;
.title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.guide-txt {
.title {
font-size: $font-title;
font-weight: 600;
color: $color-text-primary;
display: block;
}
.desc {
font-size: 22rpx;
color: #999;
margin-top: 4rpx;
display: block;
.desc {
font-size: $font-small;
color: $color-text-tertiary;
margin-top: 6rpx;
display: block;
}
}
.login-btn {
margin: 0;
background: $brand-color;
color: #fff;
font-size: 24rpx;
flex-shrink: 0;
background: $color-primary;
color: $color-text-white;
font-size: $font-caption;
height: 64rpx;
line-height: 64rpx;
border-radius: 32rpx;
padding: 0 30rpx;
padding: 0 32rpx;
transition: $transition-tap;
&::after {
border: none;
}
/* 清理小程序 button 默认黑边线 */
}
}
.vip-banner {
background: #2b2b2b;
border-radius: 20rpx;
padding: 24rpx 30rpx;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
.vip-info {
display: flex;
align-items: center;
}
.vip-text {
color: #f1c40f;
font-size: 24rpx;
margin-left: 14rpx;
}
.vip-btn {
background: linear-gradient(90deg, #f1c40f, #f39c12);
color: #333;
font-size: 20rpx;
font-weight: bold;
padding: 8rpx 20rpx;
border-radius: 30rpx;
}
}
.quick-entry {
display: flex;
justify-content: space-around;
.entry-item {
text-align: center;
.num {
font-size: 38rpx;
font-weight: bold;
color: #333;
}
.label {
font-size: 22rpx;
color: #999;
margin-top: 4rpx;
display: block;
}
}
}
.banner-box {
height: 160rpx;
margin-bottom: 24rpx;
border-radius: 20rpx;
overflow: hidden;
.banner-img {
width: 100%;
height: 100%;
}
}
.apply-section {
display: grid;
grid-template-columns: repeat(5, 1fr);
.apply-item {
display: flex;
flex-direction: column;
align-items: center;
.icon-bg {
width: 80rpx;
height: 80rpx;
background: #f9f9f9;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12rpx;
.img {
width: 44rpx;
height: 44rpx;
}
}
.text {
font-size: 22rpx;
color: #666;
}
}
}
.account-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
row-gap: 34rpx;
.account-item {
display: flex;
flex-direction: column;
align-items: center;
.acc-val {
font-size: 30rpx;
font-weight: bold;
color: #333;
.unit {
font-size: 20rpx;
font-weight: normal;
color: #666;
margin-left: 2rpx;
}
}
.acc-lab {
font-size: 22rpx;
color: #999;
margin-top: 4rpx;
&:active {
opacity: 0.85;
}
}
}
// 功能区
.icon-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
row-gap: 40rpx;
row-gap: 36rpx;
.icon-item {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
transition: $transition-tap;
&:active {
opacity: 0.7;
}
.icon-img-wrap {
position: relative;
width: 90rpx;
height: 90rpx;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12rpx;
background: #f5f6f8;
border-radius: 22rpx;
.icon-img {
width: 46rpx;
height: 46rpx;
width: 44rpx;
height: 44rpx;
}
.badge {
position: absolute;
top: -12rpx;
right: -42rpx;
/* 适当拓宽右移,容纳后端多字文本 */
background: #ff4d4f;
color: #fff;
font-size: 18rpx;
top: -10rpx;
right: -36rpx;
background: $color-badge;
color: $color-text-white;
font-size: $font-mini;
padding: 2rpx 10rpx;
border-radius: 16rpx;
border-radius: $radius-badge;
white-space: nowrap;
}
/* 未读状态小红点 */
.dot-badge {
position: absolute;
top: -4rpx;
right: -4rpx;
top: -2rpx;
right: -2rpx;
width: 14rpx;
height: 14rpx;
background: #ff4d4f;
border-radius: 50%;
background: $color-badge;
border-radius: $radius-round;
}
}
.icon-text {
font-size: 24rpx;
color: #555;
font-size: $font-caption;
color: $color-text-secondary;
}
.mp-contact-overlay-btn {
... ... @@ -547,12 +422,10 @@ $page-bg: #f8f8f8;
width: 100% !important;
height: 100% !important;
opacity: 0 !important;
/* 核心:完全透明 */
border: none !important;
padding: 0 !important;
margin: 0 !important;
z-index: 10;
/* 确保盖在图表和文字的最上方 */
&::after {
border: none !important;
... ... @@ -561,29 +434,58 @@ $page-bg: #f8f8f8;
}
}
.setting-item {
// 设置区
.settings-section {
padding: 30rpx $gap-page-h;
.settings-title {
font-size: 30rpx;
font-weight: 600;
color: $color-text-primary;
margin-bottom: 8rpx;
}
}
.settings-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 0;
border-bottom: 1rpx solid #f9f9f9;
&:last-child {
border-bottom: none;
}
padding: 28rpx 0;
border-bottom: 1rpx solid $color-border;
.setting-text {
font-size: 28rpx;
color: #444;
.settings-label {
font-size: $font-body;
color: $color-text-primary;
}
}
.opacity-hover {
opacity: 0.7;
.logout-wrap {
margin-top: 40rpx;
.logout-btn {
width: 100%;
background-color: $color-danger;
color: $color-text-white;
border: none;
border-radius: $radius-button;
padding: 26rpx 0;
font-size: 30rpx;
font-weight: 500;
transition: $transition-tap;
&::after {
border: none;
}
&:active {
background-color: $color-danger-active;
}
}
}
// 触摸反馈
.card-hover {
background-color: #fcfcfc;
background-color: $color-bg-hover;
}
}
</style>
... ...
<template>
<up-popup :show="actionShow" mode="bottom" minHeight="90vh" @close="actionShow = false" bgColor="#1a1a1a">
<scroll-view class="action-container" scroll-y>
<view class="header" v-if="type == 1">
<view class="header" v-if="type === 1">
<view class="explain">
<!-- 3D 固定图片 -->
<image v-if="modeTab === 0" :src="actionDetail.url3dAnimation" class="media-content" mode="aspectFill" />
... ... @@ -32,15 +32,15 @@
</view>
<view class="mode-tabs" v-if="actionDetail.urlRealPerson || actionDetail.urlTutorial">
<view class="tab-item" v-if="actionDetail.url3dAnimation" :class="{ active: modeTab == 0 }"
<view class="tab-item" v-if="actionDetail.url3dAnimation" :class="{ active: modeTab === 0 }"
@click="switchModeTab(0)">
3D
</view>
<view class="tab-item" v-if="actionDetail.urlRealPerson" :class="{ active: modeTab == 1 }"
<view class="tab-item" v-if="actionDetail.urlRealPerson" :class="{ active: modeTab === 1 }"
@click="switchModeTab(1)">
真人
</view>
<view class="tab-item" v-if="actionDetail.urlTutorial" :class="{ active: modeTab == 2 }"
<view class="tab-item" v-if="actionDetail.urlTutorial" :class="{ active: modeTab === 2 }"
@click="switchModeTab(2)">
讲解
</view>
... ... @@ -56,17 +56,18 @@
<!-- 只有个人创建的动作,才有这个编辑按钮-->
<template v-if="actionDetail?.isSystem === 0">
<view class="edit-icon" @click="edit">
<uni-icons type="more" size="24" color="#fff"></uni-icons>
<up-icon name="more-dot-fill" size="24" color="#fff" />
</view>
</template>
<!-- #ifdef H5 -->
<button class="share-btn" open-type="share">
<uni-icons type="paperplane" size="24" color="#fff"></uni-icons>
<up-icon name="share-fill" size="24" color="#fff" />
</button>
<!-- #endif -->
<up-icon :name="isFavorite ? 'star-fill' : 'star'" :color="isFavorite ? '#fedc1f' : '#fff'" size="24"
@click="toggleCollect">
</up-icon>
<!-- <FavoriteBtn :id="actionId" :type="type" /> -->
</view>
</view>
<!-- 要点,历史,平替动作标签 -->
... ... @@ -106,16 +107,16 @@
</view>
</view>
<template v-if="type == 1">
<template v-if="type === 1">
<textarea class="textarea" v-model="actionDetail.note" placeholder="点击填写备注"></textarea>
</template>
<template v-if="type == 2">
<template v-if="type === 2">
<textarea class="textarea" v-model="actionDetail.userNote" placeholder="点击填写备注"></textarea>
</template>
</view>
<view class="memo-box" v-if="type == 1">
<view class="memo-box" v-if="type === 1">
<view class="memo-title">
<view class="section-title">步骤</view>
<view class="pencil-icon">
... ... @@ -168,18 +169,7 @@
</view>
</view>
</view>
<!-- 2 历史 -->
<view v-if="contentTab === 1" class="tab-pane slide-up">
<!-- <view class="stat-card highlight">
<text class="label">最长时长</text>
<text class="value">01:02:03</text>
<text class="date">记录于 2026/03/04</text>
</view> -->
<!-- <view class="chart-container" v-if="chartData && chartData.series">
<qiun-data-charts type="line" :opts="chartOpts" :chartData="chartData" />
</view> -->
<view class="history-list">
<template v-if="historyList && historyList.length > 0">
<view class="history-item" v-for="item in historyList" :key="item.id">
... ... @@ -194,7 +184,7 @@
<!-- <view class="difficulty">困难</view> -->
<view class="difficulty">{{
item.weight ? item.weight + 'kg' : '无负重'
}}</view>
}}</view>
</view>
<view class="group-chips">
<view class="chip" v-for="(set, index) in item.setConfigList" :key="index">
... ... @@ -216,7 +206,7 @@
<view v-if="contentTab === 2 && type === 1" class="tab-pane slide-up">
<view class="substitute-list">
<view class="sub-item" v-for="item in alternativeActions" :key="item.id" @click="openActionItem(item)">
<image :src="item.url3dAnimation || lostImage" mode="aspectFill" class="img" />
<image :src="item.url3dAnimation" mode="aspectFill" class="img" />
<view class="sub-info">
<text class="name">{{ item.name }}</text>
... ... @@ -238,47 +228,35 @@
</template>
<script setup>
import { onMounted, ref, nextTick } from 'vue';
import { ref, nextTick } from 'vue';
import dayjs from 'dayjs';
import ExercisesApi from '@/sheep/api/motion/exercises';
import beizhu from '@/pages/xunji/components/beizhu.vue';
import SupersetsApi from '@/sheep/api/motion/supersets';
import TrainingApi from '@/sheep/api/Training/traininghistory';
import { onShareAppMessage } from '@dcloudio/uni-app';
import { useTrainingStore } from '@/sheep/store/trainingStore'
import { useTrainingStore } from '@/sheep/store/trainingStore';
const trainingStore = useTrainingStore()
const trainingStore = useTrainingStore();
const lostImage = 'https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260316/order-empty_1773628059920.png';
// 静态配置
const alternativeActions = ref([]); // 平替动作列表接口数据
const loadedHistory = ref(false)
const loadedAlternative = ref(false)
// 响应式状态
const actionShow = ref(false);
const modeTab = ref(0);
const contentTab = ref(0);
const isFavorite = ref(false);
// 记录当前动作的详细数据
const actionDetail = ref({});
// 记录当前动作的id
const actionId = ref(0);
// 记录是超级组还是动作组 1=动作组,2=超级组
const type = ref(0);
const showBeizhuRef = ref(null);
const historyList = ref([]);
const alternativeActions = ref([]);
const loadedHistory = ref(false);
const loadedAlternative = ref(false);
const historyList = ref([]); // 训练历史列表接口数据
const lostImage =
'https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260316/order-empty_1773628059920.png';
// 切换图表模式
const switchModeTab = (index) => {
modeTab.value = index;
};
// 判断链接是否为视频链接
const isVideoUrl = (url) => {
if (!url) return false;
const videoExts = ['.mp4', '.mov', '.avi', '.mkv', '.webm'];
... ... @@ -286,13 +264,10 @@ const isVideoUrl = (url) => {
return videoExts.some(ext => lowerUrl.includes(ext));
};
// 跳转到视频播放的页面
const playVideo = (url) => {
uni.navigateTo({
url: '/pages4/pages/xunji/xunji-shiping?url=' + url,
});
uni.navigateTo({ url: '/pages4/pages/xunji/xunji-shiping?url=' + url });
};
// 获取动作收藏状态
const checkExerciseFavorited = async () => {
try {
const res = await ExercisesApi.checkExerciseFavorited(actionId.value);
... ... @@ -308,21 +283,16 @@ const toggleCollect = async () => {
try {
const status = isFavorite.value ? 0 : 1;
console.log('actionId.value=', actionId.value, 'status=', status);
if (type.value == 1) {
console.log('动作收藏传递的参数', actionId.value, status);
if (type.value === 1) {
await ExercisesApi.toggleFavorite(actionId.value, status);
} else {
console.log('超级组收藏传递的参数', actionId.value, status);
await SupersetsApi.toggleFavorite(actionId.value, status);
}
isFavorite.value = !isFavorite.value;
uni.showToast({ title: status === 1 ? '收藏成功' : '取消收藏成功' });
} catch (err) {
console.log(err);
// 请求失败强制回滚UI
// isFavorite.value = originState;
uni.showToast({ title: '操作失败,请重试', icon: 'none' });
}
};
... ... @@ -343,7 +313,7 @@ const startTraining = () => {
title: '当前已有正在进行的训练',
icon: 'none'
});
return;
return;
}
console.log('动作详情页面的trainingStore.isTraining', trainingStore.isTraining);
... ... @@ -375,26 +345,16 @@ const open = async (id, typeData) => {
contentTab.value = 0;
console.log('动作详情页面接收id和类型:', actionId.value, type.value);
// 如何判断是动作还是超级组
if (typeData == 1) {
if (typeData === 1) {
await loadexercisedetail(actionId.value);
// await loadAlternativeActions(actionId.value);
console.log('进入动作收藏检查接口');
await checkExerciseFavorited();
} else {
await loadsuperdetail(actionId.value);
console.log('进入超级组收藏检查接口');
await checkSupersetFavorited();
}
console.log('请求参数actionId/typeData', actionId.value, type.value);
// 重置加载训练历史列表和平替动作列表标记
loadedHistory.value = false
loadedAlternative.value = false
// loadTrainHistoryDetail(actionId.value, type.value);
loadedHistory.value = false;
loadedAlternative.value = false;
actionShow.value = true;
};
// 打开备注弹窗
... ... @@ -413,7 +373,7 @@ const handleNoteSave = async (content) => {
console.log('type.value=', type.value);
try {
if (type.value == 2) {
if (type.value === 2) {
await SupersetsApi.addNotes({
supersetsId: actionId.value,
content: content,
... ... @@ -428,7 +388,7 @@ const handleNoteSave = async (content) => {
} catch (e) {
console.log(e);
} finally {
if (type.value == 1) {
if (type.value === 1) {
await loadexercisedetail(actionId.value)
} else {
await loadsuperdetail(actionId.value)
... ... @@ -451,7 +411,7 @@ onShareAppMessage((res) => {
title: actionDetail.value.name || '健身动作分享', // 分享标题
// path: `/pages4/pages/xunji/xunji-dongzuo-xiangqing?id=${id.value}`, // 分享路径
path: `/pages/xunji/xunji?currentTab=${3}`,
imageUrl: actionDetail.value.urlImage || lostImage, // 分享图片
imageUrl: actionDetail.value.urlImage, // 分享图片
};
});
... ... @@ -517,53 +477,18 @@ const loadTrainHistoryDetail = async (id, type) => {
// 格式化时间
const formatDate = (dateArr) => {
if (!Array.isArray(dateArr) || dateArr.length < 3) return '';
const [year, month, day] = dateArr;
const date = new Date(year, month - 1, day);
const weekArr = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
const week = weekArr[date.getDay()];
return `${year}/${String(month).padStart(2, '0')}/${String(day).padStart(2, '0')} ${week}`;
return dayjs(new Date(dateArr[0], dateArr[1] - 1, dateArr[2])).format('YYYY/MM/DD dd');
};
// 格式化每组数据:自动拼接 weight/reps/duration/distance
const formatSetData = (set) => {
const parts = [];
// 重量
if (set.weight != null && set.weight !== '') {
parts.push(`${set.weight}kg`);
}
// 次数
if (set.reps != null && set.reps !== '') {
parts.push(`${set.reps}次`);
}
// 时长(自动转 时:分:秒)
if (set.duration != null && set.duration !== '') {
parts.push(formatTime(set.duration));
}
// 距离
if (set.distance != null && set.distance !== '') {
parts.push(`${set.distance}m`);
}
if (set.weight != null && set.weight !== '') parts.push(`${set.weight}kg`);
if (set.reps != null && set.reps !== '') parts.push(`${set.reps}次`);
if (set.duration != null && set.duration !== '') parts.push(dayjs.duration(set.duration, 'seconds').format('HH:mm:ss'));
if (set.distance != null && set.distance !== '') parts.push(`${set.distance}m`);
return parts.length > 0 ? parts.join(' × ') : '无数据';
};
// 新增:秒数转 00:00:00 格式
const formatTime = (seconds) => {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
const hh = h > 0 ? String(h).padStart(2, '0') + ':' : '';
const mm = String(m).padStart(2, '0') + ':';
const ss = String(s).padStart(2, '0');
return hh + mm + ss;
};
const edit = () => {
uni.navigateTo({
url: `/pages4/pages/xunji/xunji-dongzuo-xinzeng?isEdit=true&id=${actionId.value}`
... ... @@ -577,8 +502,6 @@ const openActionItem = (item) => {
};
defineExpose({ open });
onMounted(() => { });
</script>
<style lang="scss" scoped>
... ... @@ -772,7 +695,6 @@ onMounted(() => { });
}
}
// 要点板块样式
.video-grid {
display: grid;
grid-template-columns: 1fr 1fr;
... ... @@ -816,7 +738,6 @@ onMounted(() => { });
color: #fff;
height: 160rpx;
border-radius: 16rpx;
// padding: 20rpx;
font-size: 30rpx;
border: 0;
}
... ... @@ -827,7 +748,6 @@ onMounted(() => { });
flex-direction: column;
gap: 16rpx;
background: #464646;
// padding: 20rpx;
box-sizing: border-box;
border-radius: 16rpx;
... ... @@ -877,7 +797,6 @@ onMounted(() => { });
color: #fff;
}
// 历史记录样式
.stat-card {
background: linear-gradient(135deg, #333, #222);
padding: 40rpx;
... ... @@ -933,8 +852,6 @@ onMounted(() => { });
}
.tag {
// font-size: 20rpx;
// background: #444;
margin: 5rpx 5rpx;
border-radius: 4rpx;
}
... ... @@ -948,7 +865,6 @@ onMounted(() => { });
margin-top: 20rpx;
.chip {
// background: #333;
padding: 8rpx 20rpx;
font-size: 24rpx;
display: flex;
... ... @@ -965,7 +881,6 @@ onMounted(() => { });
}
}
// 平替动作样式
.sub-item {
display: flex;
align-items: center;
... ... @@ -1024,7 +939,6 @@ onMounted(() => { });
}
}
// 动画
.slide-up {
animation: slideUp 0.4s ease-out;
}
... ...
... ... @@ -24,138 +24,83 @@
</template>
<script setup>
import dayjs from 'dayjs';
import { ref, computed, watch } from 'vue';
const emit = defineEmits(['dateTypeChange', 'dateRangeChange']);
// 当前日期类型: 'week' | 'month'
// 当前日期类型与基准日期
const currentDateType = ref('week');
// 基准日期,用于计算范围
const baseDate = ref(new Date());
// 格式化日期辅助函数 YYYY/MM/DD
const formatDate = (date) => {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}/${m}/${d}`;
};
// 格式化日期辅助函数 YYYY-MM-DD
const formatDateYmd = (date) => {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
};
const baseDate = ref(dayjs());
const currentDateRange = computed(() => {
const current = new Date(baseDate.value);
let start, end;
// ==================== 公共日期范围计算 ====================
/** 根据当前类型计算周期起止日期(dayjs 实例) */
const periodRange = computed(() => {
const d = baseDate.value;
if (currentDateType.value === 'week') {
const day = current.getDay();
const diffToMonday = day === 0 ? -6 : 1 - day;
start = new Date(current);
start.setDate(current.getDate() + diffToMonday);
end = new Date(start);
end.setDate(start.getDate() + 6);
} else {
start = new Date(current.getFullYear(), current.getMonth(), 1);
end = new Date(current.getFullYear(), current.getMonth() + 1, 0);
const dayOfWeek = d.day(); // 0=周日, 1=周一, ...
const start = d.subtract(dayOfWeek === 0 ? 6 : dayOfWeek - 1, 'day');
const end = start.add(6, 'day');
return { start, end };
}
return {
start: formatDateYmd(start),
end: formatDateYmd(end)
};
// month
return { start: d.startOf('month'), end: d.endOf('month') };
});
// 计算显示的日期范围文本
const dateRangeText = computed(() => {
const current = new Date(baseDate.value);
if (currentDateType.value === 'week') {
// 计算本周的周一和周日
const day = current.getDay();
const diffToMonday = day === 0 ? -6 : 1 - day; // 调整到周一
const startOfWeek = new Date(current);
startOfWeek.setDate(current.getDate() + diffToMonday);
const endOfWeek = new Date(startOfWeek);
endOfWeek.setDate(startOfWeek.getDate() + 6);
// ==================== 计算属性 ====================
return `${formatDate(startOfWeek)} - ${formatDate(endOfWeek)}`;
} else {
// 计算本月的一号和最后一天
const startOfMonth = new Date(current.getFullYear(), current.getMonth(), 1);
const endOfMonth = new Date(current.getFullYear(), current.getMonth() + 1, 0);
/** 对外暴露的日期范围(YYYY-MM-DD) */
const currentDateRange = computed(() => ({
start: periodRange.value.start.format('YYYY-MM-DD'),
end: periodRange.value.end.format('YYYY-MM-DD'),
}));
return `${formatDate(startOfMonth)} - ${formatDate(endOfMonth)}`;
}
/** 显示的日期范围文本 "YYYY/MM/DD - YYYY/MM/DD" */
const dateRangeText = computed(() => {
const { start, end } = periodRange.value;
return `${start.format('YYYY/MM/DD')} - ${end.format('YYYY/MM/DD')}`;
});
// 新增: 计算下一个日期是否不可用(即是否为未来日期)
/** 下一周期是否超过今天(超出则隐藏前进按钮) */
const isNextDisabled = computed(() => {
const newDate = new Date(baseDate.value);
if (currentDateType.value === 'week') {
newDate.setDate(newDate.getDate() + 7);
} else {
newDate.setMonth(newDate.getMonth() + 1);
}
// 重置时分秒进行比较
const now = new Date();
now.setHours(0, 0, 0, 0);
const checkDate = new Date(newDate);
checkDate.setHours(0, 0, 0, 0);
return checkDate > now;
const nextBase = currentDateType.value === 'week'
? baseDate.value.add(7, 'day')
: baseDate.value.add(1, 'month');
return nextBase.startOf('day').isAfter(dayjs().startOf('day'));
});
// 切换周/月
// ==================== 事件处理 ====================
const switchDateType = (type) => {
currentDateType.value = type;
emit('dateTypeChange', type);
};
// 上一段时间
const handlePrev = () => {
const newDate = new Date(baseDate.value);
if (currentDateType.value === 'week') {
newDate.setDate(newDate.getDate() - 7);
} else {
newDate.setMonth(newDate.getMonth() - 1);
}
baseDate.value = newDate;
baseDate.value = currentDateType.value === 'week'
? baseDate.value.subtract(7, 'day')
: baseDate.value.subtract(1, 'month');
};
// 下一段时间
const handleNext = () => {
// 虽然按钮已隐藏,但保留逻辑判断以防直接调用或其他边界情况
if (isNextDisabled.value) {
return;
}
const newDate = new Date(baseDate.value);
if (currentDateType.value === 'week') {
newDate.setDate(newDate.getDate() + 7);
} else {
newDate.setMonth(newDate.getMonth() + 1);
}
baseDate.value = newDate;
if (isNextDisabled.value) return;
baseDate.value = currentDateType.value === 'week'
? baseDate.value.add(7, 'day')
: baseDate.value.add(1, 'month');
};
// ==================== 双向绑定 ====================
watch(
[baseDate, currentDateType],
() => {
emit('dateRangeChange', {
startDate: currentDateRange.value.start,
endDate: currentDateRange.value.end
endDate: currentDateRange.value.end,
});
},
{ immediate: true }
{ immediate: true },
);
</script>
... ... @@ -180,10 +125,6 @@ watch(
padding: 6rpx;
box-sizing: border-box;
// #ifdef H5
margin-top: 30px;
// #endif
.tab-item {
display: flex;
align-items: center;
... ...
<template>
<view class="exercise-page" @click="closeAddMenu">
<!-- 搜索框 -->
<view class="search-bar">
<!-- <view class="search">
<uni-icons type="search" size="18"></uni-icons>
<input class="search-input" type="text" placeholder="搜索动作名称" @input="onSearch" />
</view> -->
<view class="add-btn" @click.stop="addExercise">
<uni-icons type="plus" size="35" color="#333"></uni-icons>
<view class="floating-menu" v-show="addshow" @click.stop>
<view class="menu-item">
<uni-icons type="plus" size="24" color="#333"></uni-icons>
<text class="menu-text" @click="addnewmotion">新增动作</text>
<view class="exercise-page">
<!-- 顶部操作栏:搜索 + 快捷新增 -->
<view class="top-bar">
<view class="search-wrapper">
<up-search v-model="searchKeyword" placeholder="搜索动作名称" :showAction="false" shape="round" bgColor="#f3f4f6"
placeholderColor="#9ca3af" searchIconColor="#6b7280" @clear="searchKeyword = ''" />
</view>
<view class="add-btn-wrapper">
<view class="add-icon-btn" @click.stop="addShow = !addShow">
<up-icon name="plus" size="20" color="#111827" bold />
</view>
<!-- 下拉浮动菜单 -->
<view class="floating-menu" v-if="addShow" @click.stop>
<view class="menu-item" @click="addnewmotion">
<up-icon name="plus-circle" size="18" color="#10b981" />
<text class="menu-text">新增动作</text>
</view>
<view class="menu-item">
<uni-icons type="link" size="24" color="#333"></uni-icons>
<text class="menu-text" @click="addnewsupersets">新增超级组</text>
<view class="menu-item" @click="addnewsupersets">
<up-icon name="attach" size="18" color="#3b82f6" />
<text class="menu-text">新增超级组</text>
</view>
<view class="menu-item">
<uni-icons type="list" size="24" color="#333"></uni-icons>
<text class="menu-text" @click="actionmanagement">动作目录管理</text>
<view class="menu-item" @click="actionmanagement">
<up-icon name="list-dot" size="18" color="#f59e0b" />
<text class="menu-text">动作目录管理</text>
</view>
</view>
<view class="menu-mask" v-if="addShow" @click="addShow = false" />
</view>
</view>
<!-- 浮动菜单 -->
<!-- 左右布局 -->
<!-- 主体左右双栏布局 -->
<view class="layout-container">
<scroll-view scroll-y class="left-nav" enable-flex>
<view class="nav-item" @click="handleCollectClick('collect')" :class="{ active: activeNav === 'collect' }">
收藏
<!-- 左侧一级分类导航 -->
<scroll-view scroll-y class="left-nav" :show-scrollbar="false">
<view class="nav-item" :class="{ active: activeNav === 'collect' }" @click="handleCollectClick">
<up-icon name="star-fill" size="14" :color="activeNav === 'collect' ? '#10b981' : '#9ca3af'"></up-icon>
<text class="nav-text">收藏</text>
</view>
<view v-for="nav in navItems" :key="nav.id" class="nav-item" :class="{ active: activeNav === nav.id }"
@click="switchNav(nav.id)">
{{ nav.name }}
<text class="nav-text">{{ nav.name }}</text>
</view>
</scroll-view>
<!-- 小动作列表 -->
<scroll-view scroll-y class="right-content" enable-flex>
<view class="tip" v-if="motionPart.length > 0">
<view class="item" @click="handlePartClick('')" :class="{ active: activeMotionPart == '' }">全部</view>
<view class="item" v-for="item in motionPart" :key="item.id" :class="{ active: activeMotionPart == item.id }"
@click="handlePartClick(item.id)">
{{ item.name }}
<!-- 右侧二级筛选与动作网格内容 -->
<scroll-view scroll-y class="right-content" :show-scrollbar="false">
<!-- 二级子部位 Chips 筛选条 -->
<scroll-view scroll-x class="sub-part-scroll" v-if="motionPart.length > 0">
<view class="sub-part-wrapper">
<view class="part-chip" :class="{ active: activeMotionPart === '' }" @click="handlePartClick('')">
全部
</view>
<view v-for="item in motionPart" :key="item.id" class="part-chip"
:class="{ active: activeMotionPart === item.id }" @click="handlePartClick(item.id)">
{{ item.name }}
</view>
</view>
</view>
</scroll-view>
<!-- 动作与超级组网格卡片区 -->
<view class="exercise-grid">
<view class="content" v-if="exercises.length > 0 || superGroupInfo.length > 0">
<view class="equipment-list">
<view class="equipment-item" v-for="item in exercises" :key="item.equipmentId">
<view class="equipment-name"> {{ item.equipmentName }} </view>
<view class="action-list">
<view class="action-item" v-for="e in item.exercises" :key="e.id">
<image :src="e.url3dAnimation" mode="aspectFill" lazy-load class="action-img"
@click="goToDetail(e.id, 1)"></image>
<view class="action-name">{{ e.name }}</view>
<view class="trainingReps" v-if="e.trainingReps">{{ e.trainingReps }}次</view>
</view>
</view>
<template v-if="displayExercises.length > 0 || superGroupInfo.length > 0">
<!-- 器械分组列表 -->
<view class="equipment-group" v-for="item in displayExercises" :key="item.equipmentId">
<view class="equipment-title">
<view class="title-bar"></view>
<text>{{ item.equipmentName || '通用器械' }}</text>
</view>
<view class="supers" v-if="superGroupInfo.length > 0">
<view class="supers-name"> 超级组 </view>
<view class="super" v-for="item in superGroupInfo" :key="item.id" @click="goToDetail(item.id, 2)">
<image src="https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260507/超级组_1778117889451.png"
mode="aspectFill" lazy-load class="super-img"></image>
<view class="right">
<view class="super-name">{{ item.name }}</view>
<view class="parts">{{ item.primaryMuscles.join() }}</view>
<view class="action-grid">
<view class="action-card" v-for="e in item.exercises" :key="e.id" @click="goToDetail(e.id, 1)">
<view class="image-wrapper">
<image :src="e.url3dAnimation" mode="aspectFil" lazy-load class="action-img"></image>
<view class="reps-badge" v-if="e.trainingReps">
{{ e.trainingReps }}次
</view>
</view>
<view class="action-info">
<text class="action-name">{{ e.name }}</text>
</view>
</view>
</view>
</view>
<!-- 超级组展示模块 -->
<view class="supers-group" v-if="superGroupInfo.length > 0">
<view class="equipment-title">
<view class="title-bar blue"></view>
<text>超级组</text>
</view>
<!-- <view class="add-super">
<view class="header">
<up-icon name="plus" color="#000" size="15" bold></up-icon>
<text>添加自定义动作</text>
<view class="super-card" v-for="item in superGroupInfo" :key="item.id" @click="goToDetail(item.id, 2)">
<image src="https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260507/超级组_1778117889451.png"
mode="aspectFill" lazy-load class="super-img"></image>
<view class="super-info">
<text class="super-name">{{ item.name }}</text>
<text class="super-muscles" v-if="item.primaryMuscles">
{{ Array.isArray(item.primaryMuscles) ? item.primaryMuscles.join(' · ') : item.primaryMuscles }}
</text>
</view>
<view class="tip">添加官方库没有的动作</view>
</view> -->
<up-icon name="arrow-right" size="14" color="#9ca3af"></up-icon>
</view>
</view>
</view>
</template>
<!-- 空状态 -->
<view class="empty-state" v-else>
<text class="empty-text">暂无动作数据</text>
<!-- 优雅的空状态 -->
<view class="empty-wrapper" v-else>
<up-empty mode="data" text="暂无相关动作数据"></up-empty>
</view>
</view>
</scroll-view>
</view>
<!-- 详情弹窗 -->
<!-- 动作详情弹窗组件 -->
<dongzuoXianqing ref="actionDetailRef" />
<!-- 底部安全区占位(关键!解决不同设备适配) -->
<view class="safe-area-bottom"></view>
</view>
</template>
<script setup>
import { ref, onMounted, nextTick } from 'vue';
import { ref, onMounted, computed } from 'vue';
import ExercisesApi from '@/sheep/api/motion/exercises';
import SupersetsApi from '@/sheep/api/motion/supersets';
import dongzuoXianqing from '@/pages/xunji/components/dongzuo-xianqing.vue';
... ... @@ -111,29 +132,35 @@ import { useActionStore } from '@/sheep/store/action';
const actionStore = useActionStore();
// 响应式数据
const activeNav = ref('');
const navItems = ref([]);
const exercises = ref([]);
const superGroupInfo = ref([]);
const motionPart = ref([]);
const activeMotionPart = ref('');
const currentCategoryId = ref('');
const allExercises = ref([]);
const addshow = ref(false);
const superFavoriteList = ref([]);
const searchKeyword = ref('');
const addShow = ref(false);
const actionDetailRef = ref(null);
// 关闭菜单
const closeAddMenu = () => {
addshow.value = false;
};
const displayExercises = computed(() => {
if (!searchKeyword.value.trim()) {
return exercises.value;
}
const kw = searchKeyword.value.toLowerCase().trim();
return exercises.value
.map((group) => {
const filtered = (group.exercises || []).filter((ex) =>
ex.name && ex.name.toLowerCase().includes(kw)
);
return {
...group,
exercises: filtered,
};
})
.filter((group) => group.exercises && group.exercises.length > 0);
});
// 部位筛选
const handlePartClick = async (id) => {
try {
activeMotionPart.value = id;
... ... @@ -143,37 +170,40 @@ const handlePartClick = async (id) => {
});
exercises.value = exerciseRes.data || [];
} catch (e) {
console.error('加载动作失败:', e);
console.error('加载子部位动作失败:', e);
}
};
// 收藏
const handleCollectClick = async (id) => {
activeNav.value = id;
const handleCollectClick = () => {
activeNav.value = 'collect';
activeMotionPart.value = '';
motionPart.value = [];
loadCollectList();
loadSuperFavoriteList();
};
// 动作收藏
// 加载动作收藏列表
const loadCollectList = async () => {
try {
const res = await ExercisesApi.getFavoriteExercises();
exercises.value = res.data || [];
} catch (err) {
console.error('加载动作收藏失败', err);
console.error('加载动作收藏失败:', err);
}
};
// 超级组收藏
// 加载超级组收藏列表
const loadSuperFavoriteList = async () => {
try {
const res = await SupersetsApi.getFavoriteSuperset();
superGroupInfo.value = res.data || [];
} catch (err) {
superFavoriteList.value = [];
console.error('加载超级组收藏失败', err);
superGroupInfo.value = [];
console.error('加载超级组收藏失败:', err);
}
};
// 加载
// 加载
const loadCategories = async () => {
try {
await actionStore.getloadCategories();
... ... @@ -187,26 +217,27 @@ const loadCategories = async () => {
}
};
// 加载动作
// 加载动作列表
const loadExercises = async (categoriesId) => {
try {
const partRes = await ExercisesApi.getMotionPart(categoriesId);
motionPart.value = partRes.data || [];
console.log(motionPart.value, 'motionPart.value');
const exerciseRes = await ExercisesApi.getexercises({ categoriesId });
exercises.value = exerciseRes.data;
exercises.value = exerciseRes.data || [];
} catch (error) {
console.error('加载动作失败:', error);
}
};
// 切换导航
// 切换一级导航
const switchNav = (id) => {
if (activeNav.value === id) return;
activeNav.value = id;
activeMotionPart.value = '';
if (id == 'super') {
searchKeyword.value = '';
if (id === 'super') {
exercises.value = [];
motionPart.value = [];
loadsupersetsinfo();
... ... @@ -216,21 +247,7 @@ const switchNav = (id) => {
}
};
// 搜索
const onSearch = (e) => {
const searchText = e.detail.value.trim();
if (!currentCategoryId.value) return;
searchText ? searchExercises(searchText) : loadExercises(currentCategoryId.value);
};
const searchExercises = (keyword) => {
const key = keyword.toLowerCase().trim();
exercises.value = allExercises.value.filter((item) => {
return item.name && item.name.toLowerCase().includes(key);
});
};
// 加载超级组列表
const loadsupersetsinfo = async () => {
try {
const response = await SupersetsApi.getsupersets();
... ... @@ -240,31 +257,22 @@ const loadsupersetsinfo = async () => {
}
};
// 页面跳转
const goToDetail = (id, type) => {
console.log('type=', type);
nextTick(() => {
actionDetailRef.value.open(id, type);
});
};
const addExercise = () => {
addshow.value = !addshow.value;
actionDetailRef.value?.open(id, type);
};
const addnewmotion = () => {
addShow.value = false;
uni.navigateTo({ url: '/pages4/pages/xunji/xunji-dongzuo-xinzeng' });
};
const addnewsupersets = () => {
uni.navigateTo({
url: `/pages4/pages/xunji/dongzuo-xinzengchaojizu?navItems=${encodeURIComponent(
JSON.stringify(navItems.value),
)}`,
});
addShow.value = false;
uni.navigateTo({ url: `/pages4/pages/xunji/dongzuo-xinzengchaojizu?navItems=${encodeURIComponent(JSON.stringify(navItems.value))}` });
};
const actionmanagement = () => {
addShow.value = false;
uni.navigateTo({ url: '/pages4/pages/xunji/dongzuo-muluguanli' });
};
... ... @@ -272,352 +280,324 @@ onMounted(() => {
loadCategories();
});
</script>
<style lang="scss" scoped>
.exercise-page {
background-color: white;
box-sizing: border-box;
height: 100vh;
display: flex;
flex-direction: column;
box-sizing: border-box;
.search-bar {
box-sizing: border-box;
width: 100%;
height: 87vh;
// #ifdef H5
height: calc(100vh - 44px);
// #endif
background-color: #ffffff;
overflow: hidden;
/* 顶部搜索操作栏 */
.top-bar {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 20rpx;
// background-color: #f5f5f5;
border-radius: 12rpx;
margin-top: 20rpx;
/* #ifdef H5 */
margin-top: 20px;
/* #endif */
.search {
display: flex;
align-items: center;
padding: 10rpx 20rpx;
flex: 1;
border-radius: 30rpx;
background-color: #f5f5f5;
.search-icon {
width: 30rpx;
height: 30rpx;
}
padding: 16rpx 24rpx;
background-color: #ffffff;
border-bottom: 1rpx solid #f3f4f6;
z-index: 50;
.search-input {
flex: 1;
padding-left: 15rpx;
font-size: 26rpx;
color: #333;
border: none;
outline: none;
background: transparent;
}
.search-wrapper {
flex: 1;
margin-right: 20rpx;
}
.add-btn {
width: 60rpx;
height: 60rpx;
background-color: transparent;
border: none;
margin-left: 10rpx;
display: flex;
justify-content: center;
align-items: center;
z-index: 99;
.add-btn-wrapper {
position: relative;
.plus-icon {
width: 40rpx;
height: 40rpx;
.add-icon-btn {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background-color: #f3f4f6;
display: flex;
justify-content: center;
align-items: center;
transition: background-color 0.2s;
&:active {
background-color: #e5e7eb;
}
}
.floating-menu {
position: absolute;
top: 30rpx;
right: 30rpx;
width: 300rpx;
background-color: #fff;
border-radius: 12rpx;
box-shadow: 0 10rpx 30rpx rgba(0, 0, 0, 0.1);
padding: 20rpx;
top: 84rpx;
right: 0;
width: 320rpx;
background-color: #ffffff;
border-radius: 20rpx;
box-shadow: 0 12rpx 36rpx rgba(0, 0, 0, 0.12);
padding: 12rpx 0;
z-index: 100;
border: 1rpx solid #f3f4f6;
.menu-item {
display: flex;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid #eee;
gap: 16rpx;
padding: 24rpx 28rpx;
transition: background-color 0.15s;
.u-icon {
margin-right: 16rpx;
&:active {
background-color: #f9fafb;
}
.menu-text {
font-size: 28rpx;
color: #333;
}
&:last-child {
border-bottom: none;
color: #1f2937;
font-weight: 500;
}
}
}
.menu-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 90;
background-color: transparent;
}
}
}
.layout-container {
box-sizing: border-box;
height: 100%;
display: flex;
height: calc(82vh + 25rpx);
background-color: #f5f5f5;
overflow: hidden;
.left-nav {
width: 160rpx;
background-color: white;
border-right: 1px solid #eee;
// 添加底部 padding,避免最后一项被底部导航栏遮挡
padding-bottom: 20rpx;
// #ifdef MP-WEIXIN
// 微信小程序端可能需要更多底部空间
padding-bottom: 200rpx;
// #endif
width: 180rpx;
height: 100%;
background-color: #f9fafb;
border-right: 1rpx solid #f3f4f6;
.nav-item {
padding: 24rpx 0;
text-align: center;
font-size: 26rpx;
color: #333;
}
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 32rpx 16rpx;
position: relative;
transition: all 0.2s;
.nav-text {
font-size: 26rpx;
color: #4b5563;
font-weight: 400;
text-align: center;
line-height: 1.3;
}
.active {
color: #16ad40;
background-color: #e1f6e9;
border-right: 3px solid #16ad40;
font-weight: bold;
&.active {
background-color: #ffffff;
.nav-text {
color: #10b981;
font-weight: 600;
}
&::before {
content: '';
position: absolute;
left: 0;
top: 24rpx;
bottom: 24rpx;
width: 6rpx;
background-color: #10b981;
border-radius: 0 4rpx 4rpx 0;
}
}
}
}
/* 右侧内容滚动区 */
.right-content {
box-sizing: border-box;
flex: 1;
padding: 20rpx;
width: calc(100% - 180rpx);
height: 100%;
// 添加底部 padding,避免内容被底部导航栏遮挡
// 底部导航栏高度约 100rpx(50px),加上安全区域
padding-bottom: 20rpx;
// #ifdef MP-WEIXIN
// 微信小程序端可能需要更多底部空间
padding-bottom: 100rpx;
// #endif
.tip {
display: flex;
padding-left: 20rpx;
align-items: center;
column-gap: 15rpx;
margin-bottom: 20rpx;
.item {
margin: 0;
padding: 12rpx 20rpx;
font-size: 22rpx;
line-height: 1;
background-color: #fff;
border-radius: 20rpx;
&.active {
background-color: #d8ede0;
color: #43b05e;
background-color: #efefef;
padding: 20rpx 24rpx 200rpx;
box-sizing: border-box;
/* 二级子部位芯片滚动条 */
.sub-part-scroll {
width: 100%;
white-space: nowrap;
margin-bottom: 24rpx;
.sub-part-wrapper {
display: inline-flex;
gap: 16rpx;
padding-bottom: 4rpx;
.part-chip {
display: inline-block;
padding: 10rpx 24rpx;
font-size: 24rpx;
color: #4b5563;
background-color: #f3f4f6;
border-radius: 30rpx;
transition: all 0.2s;
&.active {
background-color: #ecfdf5;
color: #10b981;
font-weight: 600;
}
}
}
}
/* 动作网格区 */
.exercise-grid {
display: flex;
flex-direction: column;
gap: 24rpx;
gap: 32rpx;
.title {
font-weight: 600;
padding-left: 20rpx;
font-size: 30rpx;
}
.equipment-group {
.equipment-title {
display: flex;
align-items: center;
gap: 12rpx;
font-size: 28rpx;
font-weight: 600;
color: #111827;
margin-bottom: 20rpx;
.title-bar {
width: 8rpx;
height: 28rpx;
background-color: #10b981;
border-radius: 4rpx;
.content {
width: 100%;
// display: grid;
// grid-template-columns: repeat(2, 1fr);
// /* 改为3列布局 */
// gap: 16rpx;
// .exercise-item {
// display: flex;
// height: 150rpx;
// /* 减小项目高度 */
// flex-direction: column;
// align-items: center;
// justify-content: center;
// background-color: white;
// padding: 16rpx 8rpx;
// box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.08);
// .exercise-img {
// width: 90%;
// height: 140rpx;
// /* 固定图片高度 */
// border-radius: 8rpx;
// margin-bottom: 12rpx;
// }
// .exercise-title {
// font-size: 24rpx;
// color: #333;
// text-align: center;
// line-height: 1.3;
// max-width: 100%;
// text-overflow: ellipsis;
// display: -webkit-box;
// -webkit-box-orient: vertical;
// }
// }
.equipment-item {
width: 100%;
// margin-bottom: 40rpx;
.equipment-name {
color: #000;
font-size: 32rpx;
margin-bottom: 20rpx;
&:last-child {
margin-bottom: 50rpx;
&.blue {
background-color: #3b82f6;
}
}
}
.action-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-gap: 10rpx;
.action-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 15rpx;
.action-card {
background-color: #ffffff;
border-radius: 16rpx;
border: 1rpx solid #f3f4f6;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.03);
overflow: hidden;
display: flex;
flex-direction: column;
transition: transform 0.15s;
&:active {
transform: scale(0.98);
}
.action-item {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 10rpx;
background: #fff;
border-radius: 10rpx;
padding: 20rpx;
box-sizing: border-box;
.image-wrapper {
position: relative;
width: 100%;
height: 200rpx;
background-color: #f9fafb;
.action-img {
width: 100%;
height: 250rpx;
border-radius: 10rpx;
height: 200rpx;
}
.trainingReps {
.reps-badge {
position: absolute;
top: 20rpx;
right: 20rpx;
z-index: 1;
font-size: 22rpx;
top: 12rpx;
right: 12rpx;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8px);
color: #ffffff;
font-size: 20rpx;
padding: 4rpx 12rpx;
border-radius: 20rpx;
font-weight: 500;
}
}
&:last-child {
margin-bottom: 50rpx;
padding-bottom: env(safe-area-inset-bottom);
}
.action-info {
padding: 16rpx 12rpx;
display: flex;
align-items: center;
justify-content: center;
.action-name {
font-size: 24rpx;
font-weight: 500;
color: #1f2937;
text-align: center;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
width: 100%;
}
}
}
}
}
.supers-name {
color: #000;
font-size: 32rpx;
margin-bottom: 20rpx;
}
.super {
display: flex;
align-items: center;
background: #fff;
padding: 20rpx;
box-sizing: border-box;
border-radius: 10rpx;
gap: 20rpx;
font-size: 26rpx;
margin-bottom: 20rpx;
.super-img {
width: 120rpx;
height: 120rpx;
border-radius: 10rpx;
}
/* 超级组卡片样式 */
.supers-group {
.super-card {
display: flex;
align-items: center;
background-color: #ffffff;
border-radius: 16rpx;
border: 1rpx solid #f3f4f6;
padding: 20rpx;
margin-bottom: 16rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.03);
gap: 20rpx;
&:active {
background-color: #f9fafb;
}
.super-name {
margin-bottom: 10rpx;
}
}
.super-img {
width: 100rpx;
height: 100rpx;
border-radius: 12rpx;
background-color: #f3f4f6;
flex-shrink: 0;
}
.add-super {
background: #fff;
padding: 20rpx;
box-sizing: border-box;
border-radius: 10rpx;
.super-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
.header {
display: flex;
gap: 10rpx;
align-items: center;
margin-bottom: 20rpx;
}
.super-name {
font-size: 28rpx;
font-weight: 600;
color: #111827;
}
.tip {
margin: 0rpx;
padding: 0rpx;
font-size: 22rpx;
.super-muscles {
font-size: 22rpx;
color: #6b7280;
}
}
}
}
}
.nav-item .category-group {
/* 超级组特殊样式 */
background-color: #f0f9ff;
font-weight: bold;
border-top: 1px solid #eee;
.empty-wrapper {
padding: 80rpx 0;
}
}
.empty-state {
display: flex;
justify-content: center;
align-items: center;
.empty-text {
font-size: 28rpx;
color: #999;
}
}
}
}
}
.safe-area-bottom {
height: env(safe-area-inset-bottom); // 适配iOS安全区
// 额外加上 Tabbar 的高度,你的 Tabbar 高度是 85px = 170rpx
padding-bottom: 170rpx;
box-sizing: border-box;
}
</style>
</style>
\ No newline at end of file
... ...
<!-- 第一层 模板大类 -->
<template>
<view class="template-page" @click="closeAllDropdowns">
<!-- 筛选按钮 -->
<view class="filter-section" @click.stop>
<!-- 部位筛选 -->
<view class="template-page">
<!-- 顶部筛选操作栏(固定不缩放) -->
<view class="filter-section">
<!-- 部位筛选胶囊 -->
<view class="filter-wrapper">
<view class="filter-btn" :class="{ active: showPartDropdown }" @click="togglePartDropdown">
<view class="filter-btn" :class="{ active: showPartDropdown, selected: activePartId !== '0' }"
@click.stop="togglePartDropdown">
<text class="text">{{ activePart }}</text>
<uni-icons :type="showPartDropdown ? 'up' : 'down'" size="13"></uni-icons>
<up-icon :name="showPartDropdown ? 'arrow-up-fill' : 'arrow-down-fill'" size="10"
:color="showPartDropdown || activePartId !== '0' ? '#10b981' : '#6b7280'"></up-icon>
</view>
<!-- 部位下拉菜单 -->
<view class="dropdown-menu" v-if="showPartDropdown">
<view v-for="(item, index) in partList" :key="index" class="dropdown-item"
:class="{ selected: activePart === item.title }" @click="selectPart(item)">
{{ item.title }}
<view class="dropdown-menu" v-if="showPartDropdown" @click.stop>
<view v-for="item in partList" :key="item.id" class="dropdown-item"
:class="{ selected: activePartId === item.id }" @click="selectPart(item)">
<text>{{ item.title }}</text>
<up-icon v-if="activePartId === item.id" name="checkbox-mark" size="14" color="#10b981" />
</view>
</view>
</view>
<!-- 场景筛选 -->
<!-- 场景筛选胶囊 -->
<view class="filter-wrapper">
<view class="filter-btn" :class="{ active: showSceneDropdown }" @click="toggleSceneDropdown">
<view class="filter-btn" :class="{ active: showSceneDropdown, selected: activeSceneId !== '0' }"
@click.stop="toggleSceneDropdown">
<text class="text">{{ activeScene }}</text>
<uni-icons :type="showSceneDropdown ? 'up' : 'down'" size="13"></uni-icons>
<up-icon :name="showSceneDropdown ? 'arrow-up-fill' : 'arrow-down-fill'" size="10"
:color="showSceneDropdown || activeSceneId !== '0' ? '#10b981' : '#6b7280'"></up-icon>
</view>
<!-- 场景下拉菜单 -->
<view class="dropdown-menu" v-if="showSceneDropdown">
<view v-for="(item, index) in sceneList" :key="index" class="dropdown-item"
<view class="dropdown-menu" v-if="showSceneDropdown" @click.stop>
<view v-for="item in sceneList" :key="item.id" class="dropdown-item"
:class="{ selected: activeSceneId === item.id }" @click="selectScene(item)">
{{ item.title }}
<text>{{ item.title }}</text>
<up-icon v-if="activeSceneId === item.id" name="checkbox-mark" size="14" color="#10b981" />
</view>
</view>
</view>
<!-- 下拉全屏遮罩蒙层 -->
<view class="dropdown-mask" v-if="showPartDropdown || showSceneDropdown" @click="closeAllDropdowns"></view>
</view>
<!-- 模板列表 (根据状态自动切换显示) -->
<scroll-view class="template-list" enable-flex scroll-y>
<!-- 用父容器包裹 v-if 分支,解决 key 冲突 -->
<view v-if="!isFiltering" class="sub-template-list">
<view v-for="(item, index) in templateList" :key="index" class="template-item">
<image :src="item.urlCover" mode="aspectFill" class="template-img"></image>
<view>
<view class="template-content" @click="gototemplate(item)">
<view class="template-title">{{ item.name }}</view>
<view class="template-count">{{ item.templatesCount }}个模板</view>
<view class="template-desc">{{ item.description }}</view>
</view>
</view>
</view>
</view>
<!-- 用父容器包裹 v-else 分支,解决 key 冲突 -->
<view v-else>
<view v-for="(template, index) in filteredTemplateList" :key="index" class="template-item">
<image :src="template.urlCover" mode="aspectFill" class="template-img"></image>
<view>
<view class="template-content" @click="goToTemplateDetail(template)">
<view class="template-title">{{ template.name }}</view>
<!-- 空数据判断移到了这里 -->
<view class="template-count" v-if="template.primaryMuscles">
主要训练部位:{{ template.primaryMuscleNames.join(', ') || '无' }}
<!-- 模板列表展示区(自适应滚动) -->
<view class="list-container">
<scroll-view class="template-scroll" scroll-y :show-scrollbar="false">
<view class="scroll-inner">
<!-- 模式一:未筛选状态,展示【模板大类列表】 -->
<template v-if="!isFiltering">
<view v-for="item in templateList" :key="item.id" class="template-card" @click="gototemplate(item)">
<image :src="item.urlCover" mode="aspectFill" class="cover-img" lazy-load />
<view class="card-content">
<view class="card-header">
<text class="title">{{ item.name }}</text>
<text class="count-badge">{{ item.templatesCount || 0 }}个模板</text>
</view>
<text class="desc" v-if="item.description">{{ item.description }}</text>
</view>
<view class="template-desc" v-if="template.secondaryMuscles">
次要训练部位:{{ template.secondaryMuscleNames.join(', ') || '无' }}
</view>
</template>
<!-- 模式二:已筛选状态,展示【条件检索后的细分模板列表】 -->
<template v-else>
<view v-for="template in filteredTemplateList" :key="template.id" class="template-card"
@click="goToTemplateDetail(template)">
<image :src="template.urlCover" mode="aspectFill" class="cover-img" lazy-load />
<view class="card-content">
<text class="title">{{ template.name }}</text>
<!-- 肌群说明 Chips -->
<view class="muscle-tags">
<view class="tag primary" v-if="template.primaryMuscleNames && template.primaryMuscleNames.length">
<text class="tag-label">主要:</text>
<text class="tag-val">{{ template.primaryMuscleNames.join(', ') }}</text>
</view>
<view class="tag secondary"
v-if="template.secondaryMuscleNames && template.secondaryMuscleNames.length">
<text class="tag-label">次要:</text>
<text class="tag-val">{{ template.secondaryMuscleNames.join(', ') }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 空数据提示:移到 view-else 内部,且作为独立块 -->
<view v-if="filteredTemplateList.length === 0" class="empty-tip">
暂无符合条件的训练模板
<!-- 筛选无结果空状态 -->
<view v-if="filteredTemplateList.length === 0" class="empty-wrapper">
<up-empty mode="search" text="暂无符合条件的训练模板"></up-empty>
</view>
</template>
</view>
</view>
</scroll-view>
<!-- 底部安全区垫高 -->
<view class="bottom-spacer"></view>
</scroll-view>
</view>
</view>
</template>
... ... @@ -80,326 +104,411 @@
import { onMounted, ref } from 'vue';
import TemplatesApi from '@/sheep/api/Template/Templates';
// 列表响应式数据
const templateList = ref([]);
const filteredTemplateList = ref([]);
// 2. 控制页面显示:false=显示大类,true=显示筛选后的模板
// 页面模式标记:false = 显示大类, true = 显示筛选后的细分模板
const isFiltering = ref(false);
const activeSceneId = ref('0');
// 下拉框部位筛选相关状态
// 部位筛选状态
const partList = ref([]);
const rawPartData = ref([]); // 存储接口返回的原始部位数据
const rawPartData = ref([]);
const showPartDropdown = ref(false);
const activePart = ref('部位');
const activePartId = ref('0'); // 新增:默认选中"不限",id=0
// 场景筛选相关状态
const activePartId = ref('0'); // '0' 代表不限
// 场景筛选状态
const sceneList = ref([
{ id: '0', title: '不限' },
{ id: '1', title: '健身房' },
{ id: '2', title: '仅哑铃' },
{ id: '3', title: '仅哑铃+杠铃' },
// { id: 4, title: '办公室' },
]);
const showSceneDropdown = ref(false);
const activeScene = ref('场景');
const activeSceneId = ref('0'); // '0' 代表不限
// 获取模板大类列表
const TemplatesList = async () => {
// ====== API 数据加载 ======
/**
* 获取模板大类列表
*/
const loadCategoryTemplates = async () => {
try {
const response = await TemplatesApi.getTemplates();
templateList.value = response.data;
console.log('模板大类接口返回数据', templateList.value)
templateList.value = response.data || [];
} catch (error) {
console.log('模板大类获取失败', error);
console.error('模板大类获取失败:', error);
}
};
// 获取所有细分锻炼部位(接口)
/**
* 获取所有细分锻炼部位
*/
const getPartCategories = async () => {
try {
const res = await TemplatesApi.getPartAllCategories();
if (res.code === 0) {
rawPartData.value = res.data;
console.log('部位分类接口返回数据rawPartData.value:', rawPartData.value)
// 下拉列表赋值
rawPartData.value = res.data || [];
partList.value = [
{ title: '不限', id: '0' }, // 给"不限"加id='0',方便后续筛选
...res.data.map(item => ({
title: item.name, // 接口返回的name作为显示标题
id: String(item.id) // 保存接口的id,用于后续筛选传参
}))
{ title: '不限', id: '0' },
...rawPartData.value.map((item) => ({
title: item.name,
id: String(item.id),
})),
];
}
} catch (error) {
console.log('部位列表获取失败', error);
console.error('部位列表获取失败:', error);
}
};
// 切换部位下拉菜单
// ====== 交互与筛选逻辑 ======
/**
* 切换部位下拉框
*/
const togglePartDropdown = () => {
showPartDropdown.value = !showPartDropdown.value;
showSceneDropdown.value = false;
};
// 切换场景下拉菜单
/**
* 切换场景下拉框
*/
const toggleSceneDropdown = () => {
showSceneDropdown.value = !showSceneDropdown.value;
showPartDropdown.value = false;
};
// 选择部位
/**
* 选择部位
*/
const selectPart = (item) => {
activePartId.value = item.id;
// 判断是否是不限id=0
if (item.id === '0') {
activePart.value = '部位';
} else {
activePart.value = item.title;
}
activePart.value = item.id === '0' ? '部位' : item.title;
showPartDropdown.value = false;
doFilter();
};
// 选择场景
/**
* 选择场景
*/
const selectScene = (item) => {
activeSceneId.value = item.id;
// 判断是否是不限id=0
if (item.id === '0') {
activeScene.value = '场景';
} else {
activeScene.value = item.title;
}
activeScene.value = item.id === '0' ? '场景' : item.title;
showSceneDropdown.value = false;
doFilter();
};
// 点击页面其他地方关闭所有下拉菜单
/**
* 关闭所有下拉菜单
*/
const closeAllDropdowns = () => {
showPartDropdown.value = false;
showSceneDropdown.value = false;
};
// 核心:执行筛选逻辑
/**
* 执行筛选逻辑
*/
const doFilter = async () => {
const musclesId = activePartId.value; // 对应接口 musclesId
const scene = activeSceneId.value; // 对应接口 scene
// 2. 判断:是否是【全部不限】
const musclesId = activePartId.value;
const scene = activeSceneId.value;
// 条件判断:若均为"不限",切回展示【大类列表】
if (musclesId === '0' && scene === '0') {
// 情况A:都不限 -> 恢复显示【模板大类】
isFiltering.value = false;
// 重置按钮文字为默认
activePart.value = '部位';
activeScene.value = '场景';
// 大类列表之前已经加载过了,直接显示
return;
}
// 情况B:有筛选条件 -> 请求【筛选接口】
// 存在有效条件:发请求获取筛选列表
try {
isFiltering.value = true; // 切换成模板列表模式
isFiltering.value = true;
const res = await TemplatesApi.queryTemplatesByCondition({
musclesId: musclesId,
scene: scene
musclesId,
scene,
});
if (res.code === 0) {
// 把接口返回的模板数据存起来,页面会自动刷新
filteredTemplateList.value = res.data;
console.log('筛选接口返回数据filteredTemplateList.value:', filteredTemplateList.value)
filteredTemplateList.value = res.data || [];
}
} catch (error) {
console.log('筛选失败', error);
// 失败了也清空数据
console.error('筛选模板失败:', error);
filteredTemplateList.value = [];
}
};
// 跳转到模板详情页,传入模板id
// 跳转地址是F:\hongxing-app\hongxing-new\pages4\pages\xunji\xunji-moban-xiangqing.vue
const goToTemplateDetail = (template) => {
// ====== 路由跳转 ======
/**
* 跳转至大类包含的模板列表
*/
const gototemplate = (item) => {
uni.navigateTo({
url: `/pages4/pages/xunji/xunji-moban-xiangqing?id=${template.id}`,
url: `/pages4/pages/xunji/xunji-moban?id=${item.id}&isBigTemOffice=true`,
});
};
const gototemplate = (item) => {
// 传入的是模板大类的id,可以通过模板大类id查询到模板大类包含的模板,这个页面在page4
/**
* 跳转至具体模板详情页
*/
const goToTemplateDetail = (template) => {
uni.navigateTo({
url: `/pages4/pages/xunji/xunji-moban?id=${item.id}&isBigTemOffice=true`,
url: `/pages4/pages/xunji/xunji-moban-xiangqing?id=${template.id}`,
});
};
onMounted(async () => {
await TemplatesList();
await getPartCategories();
console.log('进入模板大类详情');
await Promise.all([loadCategoryTemplates(), getPartCategories()]);
});
</script>
<style lang="scss" scoped>
.template-page {
display: flex;
flex-direction: column;
height: calc(100vh - 44px);
// #ifdef MP-WEIXIN
height: calc(100vh - 110px);
// #endif
width: 100vw;
height: 100vh;
box-sizing: border-box;
background-color: #f8fafc;
overflow: hidden;
/* 顶部筛选控制栏 */
.filter-section {
position: fixed;
// top: 80rpx;
// #ifdef MP-WEIXIN
top: 234rpx;
// #endif
/* #ifdef H5 */
top: 77px;
/* #endif */
left: 0;
right: 0;
z-index: 999;
flex-shrink: 0;
position: relative;
z-index: 50;
display: flex;
gap: 24rpx;
flex-wrap: wrap;
padding: 20rpx;
background-color: white;
.filter-btn {
display: flex;
align-items: center;
justify-content: center;
width: auto;
min-width: 70rpx;
height: 50rpx;
background: #fff;
color: #333;
font-size: 24rpx;
border: 2rpx solid #ddd;
border-radius: 25rpx;
padding: 0 20rpx;
white-space: nowrap;
}
.filter-btn .text {
margin-right: 8rpx;
}
.filter-btn.active {
border-color: #43b05e;
background: #e6f7f0;
color: #43b05e;
}
gap: 20rpx;
padding: 20rpx 30rpx;
background-color: #ffffff;
border-bottom: 1rpx solid #f1f5f9;
.filter-wrapper {
position: relative;
z-index: 10;
}
/* 下拉菜单样式 */
.dropdown-menu {
position: absolute;
top: calc(100% + 8rpx);
left: 0;
background: #fff;
border-radius: 14rpx;
box-shadow: 0 6rpx 16rpx rgba(0, 0, 0, 0.08);
padding: 12rpx 0;
z-index: 100;
min-width: 150rpx;
max-height: 320rpx;
overflow-y: auto;
border: 1rpx solid #f0f0f0;
}
/* 下拉菜单项样式 */
.dropdown-item {
padding: 16rpx 24rpx;
font-size: 26rpx;
color: #333;
white-space: nowrap;
}
.filter-btn {
display: flex;
align-items: center;
gap: 10rpx;
height: 58rpx;
padding: 0 24rpx;
background-color: #f8fafc;
border: 1rpx solid #e2e8f0;
border-radius: 30rpx;
transition: all 0.2s ease;
.text {
font-size: 26rpx;
color: #334155;
font-weight: 500;
}
&.active,
&.selected {
background-color: #ecfdf5;
border-color: #10b981;
.text {
color: #10b981;
font-weight: 600;
}
}
}
/* 下拉菜单项悬停样式 */
.dropdown-item:hover {
background-color: #43b05e;
/* 下拉浮层 */
.dropdown-menu {
position: absolute;
top: 72rpx;
left: 0;
z-index: 100;
min-width: 220rpx;
max-height: 380rpx;
background-color: #ffffff;
border-radius: 16rpx;
box-shadow: 0 12rpx 32rpx rgba(0, 0, 0, 0.1);
border: 1rpx solid #f1f5f9;
padding: 8rpx 0;
overflow-y: auto;
.dropdown-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 28rpx;
font-size: 26rpx;
color: #334155;
transition: background-color 0.15s;
&:active {
background-color: #f8fafc;
}
&.selected {
background-color: #f0fdf4;
color: #10b981;
font-weight: 600;
}
}
}
}
/* 下拉菜单项选中样式 */
.dropdown-item.selected {
background-color: #f0f9f4;
color: #2e9d5a;
font-weight: 500;
/* 遮罩蒙层 */
.dropdown-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 40;
background-color: rgba(0, 0, 0, 0.1);
}
}
.template-list {
margin-top: 120rpx;
padding: 0 30rpx;
box-sizing: border-box;
/* 列表自适应滚动容器 */
.list-container {
flex: 1;
display: flex;
flex-direction: column;
// #ifdef MP-WEIXIN
padding-bottom: 200rpx;
height: calc(100vh - 274rpx);
// #endif
/* #ifdef H5 */
margin-top: 87px;
// 关键修复:给H5固定高度,扣除顶部筛选栏77px + 自身margin-top87px
height: calc(100vh - 77px - 87px);
/* #endif */
.sub-template-list {
display: flex;
flex-direction: column;
gap: 10rpx;
}
.template-item {
display: flex;
background-color: white;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
padding: 20rpx;
&:first-child {
margin-top: 24rpx;
height: 0;
/* 关键:接触 Flex 内容支撑限制 */
min-height: 0;
overflow: hidden;
.template-scroll {
width: 100%;
height: 100%;
.scroll-inner {
padding: 24rpx 30rpx;
display: flex;
flex-direction: column;
gap: 20rpx;
/* 模板卡片组件样式 */
.template-card {
display: flex;
align-items: center;
background-color: #ffffff;
border-radius: 20rpx;
padding: 20rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.02);
border: 1rpx solid #f1f5f9;
transition: transform 0.15s;
&:active {
transform: scale(0.985);
background-color: #f8fafc;
}
.cover-img {
width: 128rpx;
height: 128rpx;
border-radius: 16rpx;
margin-right: 20rpx;
background-color: #f1f5f9;
flex-shrink: 0;
}
.card-content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8rpx;
.title {
font-size: 30rpx;
font-weight: 600;
color: #0f172a;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
flex: 1;
margin-right: 12rpx;
}
.count-badge {
font-size: 22rpx;
color: #10b981;
background-color: #ecfdf5;
padding: 4rpx 12rpx;
border-radius: 20rpx;
font-weight: 500;
flex-shrink: 0;
}
}
.title {
font-size: 30rpx;
font-weight: 600;
color: #0f172a;
margin-bottom: 8rpx;
}
.desc {
font-size: 24rpx;
color: #64748b;
line-height: 1.4;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
/* 肌群 Tag 列表 */
.muscle-tags {
display: flex;
flex-direction: column;
gap: 6rpx;
margin-top: 4rpx;
.tag {
display: flex;
align-items: center;
font-size: 22rpx;
line-height: 1.3;
.tag-label {
color: #94a3b8;
flex-shrink: 0;
}
.tag-val {
color: #475569;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
&.primary .tag-val {
color: #059669;
font-weight: 500;
}
}
}
}
}
.empty-wrapper {
padding: 100rpx 0;
}
}
.template-img {
width: 120rpx;
height: 120rpx;
border-radius: 12rpx;
margin-right: 20rpx;
}
.template-content {
flex: 1;
}
.template-title {
font-size: 32rpx;
color: #333;
margin-bottom: 10rpx;
}
.template-count {
font-size: 24rpx;
color: #666;
margin-bottom: 10rpx;
}
.template-desc {
font-size: 24rpx;
color: #666;
line-height: 1.4;
}
&:last-child {
margin-bottom: 5rpx;
/* #ifdef H5 */
margin-bottom: 30rpx;
/* #endif */
.bottom-spacer {
height: env(safe-area-inset-bottom);
padding-bottom: 40rpx;
}
}
}
}
</style>
</style>
\ No newline at end of file
... ...
<template>
<view class="calendar-page">
<!-- 顶部栏:年月选择器和功能按钮 -->
<!-- 顶部操作栏 -->
<view class="top-bar">
<view class="month-picker">
<uni-datetime-picker type="date" v-model="currentMonth" :clear-icon="false" />
<up-icon name="arrow-down" color="#333" size="15"></up-icon>
</view>
<!-- 年月选择胶囊 -->
<picker mode="date" fields="month" :value="currentMonth" @change="onMonthChange">
<view class="month-picker-pill">
<text class="month-text">{{ displayMonthText }}</text>
<up-icon name="arrow-down-fill" color="#333333" size="10"></up-icon>
</view>
</picker>
<!-- 说明按钮 -->
<view class="action-buttons">
<button class="action-btn" @click="ShowExplainPopup = true">说明</button>
<up-button text="说明" type="info" plain shape="circle" size="mini"
customStyle="padding: 0 24rpx; height: 56rpx; font-size: 24rpx; border-color: #dcdfe6; color: #606266;"
@click="showExplainPopup = true" />
</view>
</view>
<!-- 日历网格 -->
<view class="calendar-grid">
<!-- 日历卡片容器 -->
<view class="calendar-card">
<!-- 星期表头 -->
<view class="week-header">
<text v-for="(day, index) in weekDays" :key="index" class="week-day">
{{ day }}
</text>
</view>
<!-- 日期网格 (动态 5行 或 6行) -->
<view class="date-grid">
<view v-for="(date, index) in dates" :key="index" class="date-cell" :class="{ today: date.today }"
@tap="selectDate(date)">
<text v-if="!date.today" class="date-number">{{ date.day }}</text>
<text v-else class="today-text">今</text>
<view v-for="(date, index) in dates" :key="index" class="date-cell" :class="{
'has-data': date.planList.length || date.noteList.length || (date.weight && date.weight > 0)
}" :style="{ backgroundColor: getCellBgColor(date) }" @tap="selectDate(date)">
<!-- 日期数字 / 今日徽章 -->
<view class="date-header">
<text v-if="date.today" class="today-badge">今</text>
<text v-else class="date-number">
{{ date.day }}
</text>
</view>
<!-- 内容显示区域 -->
<!-- 计划/容量数据列表 -->
<view class="plan-list"
v-if="date.planList.length || date.noteList.length || (date.weight && date.weight > 0)">
<template v-for="(item, idx) in getVisibleItems(date).items" :key="idx">
<view class="plan-name" :class="{
'weight-item': item.type === 'weight',
'note-item': item.type === 'note'
}" :style="item.bg ? { backgroundColor: item.bg } : ''">
{{ item.text }}
<template v-for="(item, idx) in getCellVisibleItems(date).items" :key="idx">
<view class="plan-tag" :class="`tag-type-${item.type}`"
:style="item.bg ? { backgroundColor: item.bg } : {}">
<text class="tag-text">{{ item.text }}</text>
</view>
</template>
<!-- 统一只显示一个 + 剩余总数 -->
<text v-if="getVisibleItems(date).remaining > 0" class="plan-extra">
+{{ getVisibleItems(date).remaining }}
</text>
<!-- 溢出剩余条数 -->
<view v-if="getCellVisibleItems(date).remaining > 0" class="plan-extra">
+{{ getCellVisibleItems(date).remaining }}
</view>
</view>
</view>
</view>
</view>
<!-- <view class="gridCellContent" v-if="ShowGridCellPopup && selectedDate">
<GridCellContentPopup :date="selectedDate" @close="ShowGridCellPopup = false" />
</view> -->
<!-- 单元格详情弹窗 -->
<GridCellContentPopup ref="gridPopupRef" :date="selectedDate" @refreshCalendar="handleRefreshCalendar" />
<!-- 日历说明弹窗 -->
<view class="explain-popup-overlay" v-if="ShowExplainPopup" @click.self="ShowExplainPopup = false">
<view class="explain-popup">
<!-- 日历图标说明弹窗 -->
<up-popup :show="showExplainPopup" mode="bottom" round="24rpx" :safeAreaInsetBottom="true"
@close="showExplainPopup = false">
<view class="explain-popup-content">
<view class="explain-header">
<text class="explain-title">日历说明</text>
<view class="close-btn" @click="ShowExplainPopup = false">
<text>×</text>
</view>
<text class="explain-title">日历图标说明</text>
<up-icon name="close" color="#909399" size="18" @click="showExplainPopup = false" />
</view>
<view class="explain-list">
<view class="explain-item">
<view class="color-block blue"></view>
<text class="item-label">分钟</text>
<text class="item-desc">在场馆的运动时长</text>
<view class="item-info">
<text class="item-label">时长</text>
<text class="item-desc">在场馆的实际运动时长</text>
</view>
</view>
<view class="explain-item">
<view class="color-block green"></view>
<text class="item-label">容量</text>
<text class="item-desc">力量训练的总重量</text>
<view class="item-info">
<text class="item-label">容量</text>
<text class="item-desc">力量训练累计产生的总负重重量</text>
</view>
</view>
<view class="explain-item">
<view class="color-block yellow"></view>
<text class="item-label">课程</text>
<text class="item-desc">训记或已结课的线下课程</text>
<view class="item-info">
<text class="item-label">课程</text>
<text class="item-desc">训记完成或已结课的线下课程</text>
</view>
</view>
<view class="explain-item">
<view class="color-block gray"></view>
<text class="item-label">课程</text>
<text class="item-desc">训记排课或待上课的线下课程</text>
<view class="item-info">
<text class="item-label">排课</text>
<text class="item-desc">训记排课计划或待上课的课程</text>
</view>
</view>
</view>
</view>
</view>
</up-popup>
</view>
</template>
<script setup>
import { ref, watch, computed, nextTick } from 'vue';
import dayjs from 'dayjs';
import DailyTemplateApi from '@/sheep/api/calendar/date';
import GridCellContentPopup from '@/pages/xunji/components/rili-components/grid-cell-content-popup.vue'
import GridCellContentPopup from '@/pages/xunji/components/rili-components/grid-cell-content-popup.vue';
// ====== 响应式数据 ======
// 常量定义
const MAX_CELL_LINES = 3;
const weekDays = ref(['一', '二', '三', '四', '五', '六', '日']);
// 响应式状态
const dates = ref([]);
const today = new Date();
const ShowExplainPopup = ref(false)
const showExplainPopup = ref(false);
const selectedDate = ref('');
// const ShowGridCellPopup = ref(false)
const gridPopupRef = ref(null);
// 自动获取当前年月
const now = new Date();
const currentMonth = ref(
`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
);
const currentMonth = ref(dayjs().format('YYYY-MM-DD'));
// ====== 工具函数 ======
const formatDateKey = (y, m, d) => {
return `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
};
// 顶部年月显示
const displayMonthText = computed(() => {
return dayjs(currentMonth.value).format('YYYY年M月');
});
const arrayToDateStr = (arr) => {
if (!Array.isArray(arr) || arr.length !== 3) return null;
const [y, m, d] = arr;
return `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
// 年月 Picker 触发
const onMonthChange = (e) => {
if (e.detail && e.detail.value) {
currentMonth.value = dayjs(e.detail.value).startOf('month').format('YYYY-MM-DD');
}
};
// ====== 生成日历数据 ======
const generateCalendar = (year, month) => {
const firstDay = new Date(year, month - 1, 1);
const lastDay = new Date(year, month, 0).getDate();
let startWeekDay = firstDay.getDay();
const adjustedStart = startWeekDay === 0 ? 6 : startWeekDay - 1;
// ====== 计算每个日期格子的背景颜色 ======
const getCellBgColor = (date) => {
const calendarDates = [];
// 上月填充
const prevMonthLastDay = new Date(year, month - 1, 0).getDate();
for (let i = adjustedStart; i > 0; i--) {
const day = prevMonthLastDay - i + 1;
const y = month === 1 ? year - 1 : year;
const m = month === 1 ? 12 : month - 1;
calendarDates.push({
day,
today: false,
dateKey: formatDateKey(y, m, day),
});
// 2. 如果存在训练计划/备注/重量,优先采用训练本身的背景色
if (date.planList && date.planList.length > 0 && date.planList[0].bg) {
return date.planList[0].bg;
}
if (date.noteList && date.noteList.length > 0 && date.noteList[0].backgroundColor) {
return date.noteList[0].backgroundColor;
}
// 本月
for (let d = 1; d <= lastDay; d++) {
const isToday =
year === today.getFullYear() &&
month === today.getMonth() + 1 &&
d === today.getDate();
calendarDates.push({
day: d,
today: isToday,
dateKey: formatDateKey(year, month, d),
});
// 3. 有容量负重但无特定背景色:赋予柔和浅绿
if (date.weight && date.weight > 0) {
return '#f0fdf4';
}
// 4. 有计划但未指定背景色:赋予柔和浅黄
if (date.planList && date.planList.length > 0) {
return '#fefce8';
}
// 下月填充
const remaining = 42 - calendarDates.length;
for (let d = 1; d <= remaining; d++) {
const y = month === 12 ? year + 1 : year;
const m = month === 12 ? 1 : month + 1;
// 6. 普通无训练的本月休息日:基础卡片白色/浅灰
return '#ededed';
};
const generateCalendar = (monthStr) => {
const current = dayjs(monthStr);
const startOfMonth = current.startOf('month');
const daysInMonth = current.daysInMonth();
const dayOfWeek = startOfMonth.day(); // 0 表示周日
const startOffset = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
// 动态计算总单元格:35(5行)或 42(6行)
const totalCellsNeeded = startOffset + daysInMonth;
const totalGridSize = totalCellsNeeded > 35 ? 42 : 35;
const gridStartDate = startOfMonth.subtract(startOffset, 'day');
const todayStr = dayjs().format('YYYY-MM-DD');
const calendarDates = [];
for (let i = 0; i < totalGridSize; i++) {
const cellDate = gridStartDate.add(i, 'day');
const dateKey = cellDate.format('YYYY-MM-DD');
calendarDates.push({
day: d,
today: false,
dateKey: formatDateKey(y, m, d),
day: cellDate.date(),
today: dateKey === todayStr,
isCurrentMonth: cellDate.month() === current.month(),
dateKey,
});
}
... ... @@ -168,26 +201,19 @@ const generateCalendar = (year, month) => {
};
// ====== 加载计划数据 ======
const loadPlans = async (year, month) => {
const loadPlans = async (monthStr) => {
try {
// 关键:获取 上个月、当前月、下个月 → 保证前后日期都有数据
const prevMonth = month - 1 < 1 ? 12 : month - 1;
const prevYear = month - 1 < 1 ? year - 1 : year;
const nextMonth = month + 1 > 12 ? 1 : month + 1;
const nextYear = month + 1 > 12 ? year + 1 : year;
// 并行请求三个月数据
// const [prevRes, currRes, nextRes] = await Promise.all([
// DailyTemplateApi.getCalendarPlans(prevYear, prevMonth),
// DailyTemplateApi.getCalendarPlans(year, month),
// DailyTemplateApi.getCalendarPlans(nextYear, nextMonth),
// ]);
const prevRes = await DailyTemplateApi.getCalendarPlans(prevYear, prevMonth);
const currRes = await DailyTemplateApi.getCalendarPlans(year, month);
const nextRes = await DailyTemplateApi.getCalendarPlans(nextYear, nextMonth);
// 合并三个月数据
const curr = dayjs(monthStr);
const prev = curr.subtract(1, 'month');
const next = curr.add(1, 'month');
// 并行请求 3 个月数据
const [prevRes, currRes, nextRes] = await Promise.all([
DailyTemplateApi.getCalendarPlans(prev.year(), prev.month() + 1).catch(() => ({ data: [] })),
DailyTemplateApi.getCalendarPlans(curr.year(), curr.month() + 1).catch(() => ({ data: [] })),
DailyTemplateApi.getCalendarPlans(next.year(), next.month() + 1).catch(() => ({ data: [] })),
]);
const allPlans = [
...(prevRes.data || []),
...(currRes.data || []),
... ... @@ -199,24 +225,25 @@ const loadPlans = async (year, month) => {
const weightMap = {};
allPlans.forEach((plan) => {
let dateStr;
let dateStr = null;
if (Array.isArray(plan.trainDate)) {
dateStr = arrayToDateStr(plan.trainDate);
const [y, m, d] = plan.trainDate;
dateStr = dayjs(`${y}-${m}-${d}`).format('YYYY-MM-DD');
} else if (typeof plan.trainDate === 'string') {
dateStr = plan.trainDate;
dateStr = dayjs(plan.trainDate).format('YYYY-MM-DD');
}
if (!dateStr) return;
if (!dateStr || dateStr === 'Invalid Date') return;
// 处理多条备注
// 备注处理
if (Array.isArray(plan.notes) && plan.notes.length > 0) {
noteMap[dateStr] = plan.notes.map(note => ({
noteMap[dateStr] = plan.notes.map((note) => ({
content: note.content || '',
backgroundColor: note.backgroundColor || '#ccc'
backgroundColor: note.backgroundColor || '#e2e8f0',
}));
}
// 处理计划和重量
// 训练计划与容量处理
planMap[dateStr] = planMap[dateStr] || [];
if (Array.isArray(plan.templates)) {
let totalWeightForDay = 0;
... ... @@ -224,7 +251,7 @@ const loadPlans = async (year, month) => {
if (template && template.name) {
planMap[dateStr].push({
name: template.name,
bg: template.backgroundColor || '#ffdd44'
bg: template.backgroundColor || '#fef08a',
});
}
if (template && typeof template.totalWeight === 'number') {
... ... @@ -237,57 +264,37 @@ const loadPlans = async (year, month) => {
}
});
const newDates = generateCalendar(year, month).map((date) => ({
dates.value = generateCalendar(monthStr).map((date) => ({
...date,
planList: planMap[date.dateKey] || [],
noteList: noteMap[date.dateKey] || [],
weight: weightMap[date.dateKey] || null,
}));
dates.value = newDates;
} catch (err) {
console.error('加载日历失败:', err);
const [y, m] = [
parseInt(currentMonth.value.split('-')[0]),
parseInt(currentMonth.value.split('-')[1]),
];
dates.value = generateCalendar(y, m);
console.error('加载日历数据失败:', err);
dates.value = generateCalendar(monthStr);
}
console.log('日历主页面处理的完的是数据:dates', dates);
};
const selectedDateData = computed(() => {
return dates.value.find(item => item.dateKey === selectedDate.value) || null;
});
// ====================== 核心显示逻辑(已修复)======================
const maxLines = 3; // 一个格子最多显示3行
const getVisibleItems = (date) => {
// ====== 视图格子显示控制 ======
const getCellVisibleItems = (date) => {
const items = [];
let remaining = 0;
// 1. 重量优先显示
if (date.weight && date.weight > 0) {
items.push({ type: 'weight', text: `${date.weight}kg` });
}
// 2. 显示训练计划(已修改)
for (const plan of date.planList) {
if (items.length < maxLines) {
items.push({
type: 'plan',
text: plan.name,
bg: plan.bg
});
if (items.length < MAX_CELL_LINES) {
items.push({ type: 'plan', text: plan.name, bg: plan.bg });
} else {
remaining++;
}
}
// 3. 显示备注
for (const note of date.noteList) {
if (items.length < maxLines) {
if (items.length < MAX_CELL_LINES) {
items.push({ type: 'note', text: note.content, bg: note.backgroundColor });
} else {
remaining++;
... ... @@ -297,153 +304,79 @@ const getVisibleItems = (date) => {
return { items, remaining };
};
// 接收子组件通知,刷新整个日历数据
const handleRefreshCalendar = () => {
const [year, month] = currentMonth.value.split('-').map(Number);
loadPlans(year, month);
loadPlans(currentMonth.value);
};
// ====================================================================
// ====== 监听月份变化 ======
watch(
currentMonth,
(newVal) => {
const [year, month] = newVal.split('-').map(Number);
loadPlans(year, month);
if (newVal) {
loadPlans(newVal);
}
},
{ immediate: true },
{ immediate: true }
);
const selectDate = async (date) => {
selectedDate.value = date.dateKey;
// ShowGridCellPopup.value = true;
await nextTick()
await nextTick();
gridPopupRef.value?.openPopup();
};
</script>
<style lang="scss" scoped>
.calendar-page {
height: 100%;
padding: 40rpx;
background-color: white;
padding: 30rpx 15rpx 20rpx;
background-color: #fff;
box-sizing: border-box;
.top-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
margin-top: 35rpx;
.month-picker {
height: 40rpx;
border-radius: 50rpx;
overflow: hidden;
padding: 0 20rpx;
gap: 10rpx;
margin-bottom: 24rpx;
:deep(.uni-date-x--border) {
border: none !important;
}
:deep(.uni-date__x-input) {
height: 40rpx;
line-height: 40rpx;
padding: 0 10rpx;
}
// 微信小程序端样式适配
// #ifdef MP-WEIXIN
.month-picker-pill {
display: flex;
align-items: center;
justify-content: center;
min-width: 200rpx;
box-sizing: border-box;
:deep(.uni-date-x) {
height: 35rpx;
// min-height: 40rpx;
border-radius: 50rpx;
background-color: transparent;
padding: 0;
margin: 0;
border: none !important;
}
:deep(.uni-date-editor--x) {
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
border: none !important;
}
:deep(.uni-date__x-input) {
// height: 40rpx;
// line-height: 40rpx;
font-size: 24rpx;
color: #333;
padding: 0 10rpx;
text-align: center;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:deep(.icon-calendar) {
display: none !important;
gap: 12rpx;
background-color: #ececec;
padding: 12rpx 28rpx;
border-radius: 40rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
border: 1rpx solid #fff;
.month-text {
font-size: 30rpx;
font-weight: 600;
color: #1a1a1a;
}
// #endif
}
}
.action-buttons {
display: flex;
justify-content: flex-start;
gap: 10rpx;
// .action-btn {
// padding: 20rpx 20rpx;
// margin: 0;
// line-height: 1;
// border-radius: 20rpx;
// border: 1px solid #666;
// font-size: 20rpx;
// }
:deep(uni-button),
:deep(wx-button) {
padding: 10rpx 20rpx;
margin: 0;
line-height: 1;
border-radius: 20rpx;
border: 1px solid #666;
font-size: 20rpx;
.action-buttons {
display: flex;
align-items: center;
}
}
.calendar-grid {
padding-bottom: 120rpx;
// #ifdef MP-WEIXIN
padding-bottom: 140rpx;
// #endif
.calendar-card {
background-color: #ffffff;
border-radius: 24rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.03);
.week-header {
display: flex;
justify-content: space-between;
margin-bottom: 10rpx;
margin-bottom: 20rpx;
.week-day {
flex: 1;
text-align: center;
font-size: 28rpx;
color: #666;
font-size: 26rpx;
font-weight: 550;
}
}
... ... @@ -453,179 +386,182 @@ const selectDate = async (date) => {
gap: 10rpx;
}
/* 日期格子基础样式:保证每个格子都有独立背景与微细边框 */
.date-cell {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
border: 1px solid #eee;
font-size: 30rpx;
color: #333;
background-color: #f9fafc;
padding-top: 10rpx;
border-radius: 12rpx;
height: 200rpx;
overflow: hidden;
box-sizing: border-box;
}
.plan-list {
width: 100%;
display: flex;
flex-direction: column;
gap: 6rpx;
margin-top: 10rpx;
padding: 0;
padding: 8rpx 4rpx;
box-sizing: border-box;
}
transition: all 0.2s ease;
.weight,
.plan-extra,
.note-item,
.plan-name {
font-size: 18rpx;
background: #ffdd44;
padding: 4rpx 0;
text-align: center;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
width: 100%;
box-sizing: border-box;
}
.weight,
.weight-item {
background: #b8ff66;
}
}
}
&:active {
filter: brightness(0.96);
}
/* 日历说明弹窗样式 */
.explain-popup-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.4);
display: flex;
justify-content: center;
align-items: flex-end;
z-index: 999;
.explain-popup {
width: 100%;
background-color: #ffffff;
border-radius: 24rpx 24rpx 0 0;
padding: 40rpx;
box-sizing: border-box;
&.other-month {
opacity: 0.5;
border-color: #f1f5f9;
}
.explain-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 40rpx;
&.is-today {
border: 2rpx solid #3b82f6;
}
.explain-title {
font-size: 32rpx;
font-weight: 60;
color: #333333;
&.has-data {
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.02);
}
.close-btn {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background-color: #f5f5f5;
.date-header {
height: 36rpx;
display: flex;
justify-content: center;
align-items: center;
justify-content: center;
margin-bottom: 6rpx;
.date-number {
font-size: 26rpx;
font-weight: 600;
color: #1f2937;
}
.today-badge {
font-weight: 600;
text {
font-size: 36rpx;
color: #666666;
line-height: 1;
}
}
}
.explain-list {
.explain-item {
.plan-list {
width: 100%;
display: flex;
align-items: center;
margin-bottom: 32rpx;
flex-direction: column;
gap: 6rpx;
.plan-tag {
width: 100%;
border-radius: 6rpx;
padding: 2rpx 4rpx;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(255, 255, 255, 0.85);
/* 使 Tag 悬浮于彩色格子之上 */
.tag-text {
font-size: 18rpx;
line-height: 22rpx;
color: #111827;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
text-align: center;
width: 100%;
}
.color-block {
width: 40rpx;
height: 40rpx;
border-radius: 8rpx;
margin-right: 20rpx;
&.tag-type-weight {
background-color: #bbf7d0 !important;
&.blue {
background-color: #a8d8ff;
.tag-text {
color: #14532d;
font-weight: 700;
}
}
&.green {
background-color: #b8ff66;
}
&.tag-type-note {
background-color: #ffffff;
&.yellow {
background-color: #ffdd44;
.tag-text {
color: #374151;
}
}
}
&.gray {
background-color: #f0f0f0;
border: 1rpx solid #eee;
}
.plan-extra {
font-size: 16rpx;
color: #4b5563;
text-align: center;
line-height: 20rpx;
font-weight: 600;
}
}
}
}
}
.explain-popup-content {
padding: 36rpx 32rpx 48rpx 32rpx;
background-color: #ffffff;
.explain-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32rpx;
.explain-title {
font-size: 32rpx;
font-weight: 600;
color: #111827;
}
}
.explain-list {
display: flex;
flex-direction: column;
gap: 28rpx;
.explain-item {
display: flex;
align-items: center;
.color-block {
width: 36rpx;
height: 36rpx;
border-radius: 8rpx;
margin-right: 24rpx;
&.blue {
background-color: #bae6fd;
}
&.green {
background-color: #bbf7d0;
}
&.yellow {
background-color: #fef08a;
}
&.gray {
background-color: #e5e7eb;
}
}
.item-info {
display: flex;
align-items: center;
gap: 16rpx;
.item-label {
font-size: 28rpx;
color: #333333;
width: 80rpx;
margin-right: 20rpx;
font-weight: 600;
color: #1f2937;
width: 72rpx;
}
.item-desc {
font-size: 28rpx;
color: #666666;
flex: 1;
font-size: 26rpx;
color: #6b7280;
}
}
}
}
}
// .gridCellContent {
// position: fixed;
// top: 0;
// left: 0;
// right: 0;
// bottom: 0;
// background: rgba(0, 0, 0, 0.5);
// z-index: 9999;
// display: flex;
// align-items: flex-end;
// padding: 0 !important;
// margin: 0 !important;
// width: 100vw !important;
// box-sizing: border-box;
// }
</style>
<!-- #ifdef MP-WEIXIN -->
<style lang="scss">
.month-picker {
.uni-date-editor--x.uni-date-x--border,
.uni-date-x--border {
border: none !important;
}
.uni-date-x,
.uni-date-editor--x {
border: none !important;
}
}
</style>
<!-- #endif -->
\ No newline at end of file
</style>
\ No newline at end of file
... ...
<template>
<view class="training-data-page">
<timeSelect @dateTypeChange="handleTypeChange" @dateRangeChange="handleDateChange" />
<!-- 过渡动画 -->
<up-transition :show="true" mode="slide-right" duration="300">
<view class="content-scroll">
<!-- 肌肉部位数据区域 -->
<view class="section">
<view class="muscle-data">
<view class="title">主训练肌肉数据</view>
<view class="tabs">
<view class="tab" :class="{ active: partTabIndex === 0 }" @click="partTabIndex = 0">容量</view>
<view class="tab" :class="{ active: partTabIndex === 1 }" @click="partTabIndex = 1">组数</view>
</view>
<view class="tip">{{ tabTip[partTabIndex] }}</view>
<!-- 网格布局:一行4格 + 正方形 + 总计自适应 -->
<view class="detail-part" :style="`--remain-span: ${remainSpan}`">
<view class="part-item" v-for="item in detailPartOptions" :key="item.id"
@click="openMuscleDetail(item.id)">
<text class=" txt">{{ item.name }}</text>
<text class="num">{{ item.value }}</text>
<!-- 顶部时间范围选择器 -->
<view class="top-time-picker">
<timeSelect @dateTypeChange="handleTypeChange" @dateRangeChange="handleDateChange" />
</view>
<!-- 主体内容可滚动区域 -->
<view class="main-content">
<scroll-view scroll-y class="scroll-area" :show-scrollbar="false">
<up-transition :show="true" mode="slide-right" duration="300">
<view class="scroll-inner">
<!-- 1. 肌肉部位数据区域 -->
<view class="card-section">
<view class="section-header">
<text class="title">主训练肌肉数据</text>
</view>
<view class="part-item total-item">
<text class="txt">总计</text>
<text class="num">{{ totalPartValue }}</text>
<!-- 容量 / 组数 切换标签 -->
<view class="tab-group">
<view class="tab-item" :class="{ active: partTabIndex === 0 }" @click="partTabIndex = 0">
容量
</view>
<view class="tab-item" :class="{ active: partTabIndex === 1 }" @click="partTabIndex = 1">
组数
</view>
</view>
</view>
</view>
</view>
<text class="tip-text">{{ TREND_TIPS[partTabIndex] }}</text>
<!-- 肌肉部位网格(1行4格 + 正方形 + 总计自适应占格) -->
<view class="muscle-grid">
<view class="grid-item" v-for="item in detailPartOptions" :key="item.id"
@click="openMuscleDetail(item.id)">
<text class="item-name">{{ item.name }}</text>
<text class="item-value">{{ item.value }}</text>
</view>
<!-- 趋势图表区域 -->
<view class="section">
<view class="header">
<view class="title">趋势</view>
<view class="mean">
<up-select v-model:current="partId" :label="partOptions.find(item => item.id === partId)?.name || '全部'"
:options="partOptions" @select="onPartChange"></up-select>
</view>
</view>
<view class="tabs">
<view class="tab" v-for="item in trendTabs" :key="item.id" :class="{ active: NumberTabIndex === item.id }"
@click="NumberTabIndex = item.id">
{{ item.name }}
<!-- 总计格子 -->
<view class="grid-item total-item" :style="{ gridColumn: `span ${remainSpan}` }">
<text class="item-name">总计</text>
<text class="item-value highlight">{{ totalPartValue }}</text>
</view>
</view>
</view>
</view>
<view class="tip">{{ tabTip[NumberTabIndex] }}</view>
<view class="statistical">
<template v-if="!showLastPeriod">
<LineChart v-if="chartCategories.length > 0" :categories="chartCategories" :series="chartSeries"
chartType="column" :reshow="true" :extra="{ column: { width: trendChartColumnWidth } }" />
</template>
<template v-else>
<LineChart v-if="chartCategories.length > 0" :categories="chartCategories" :series="chartSeries"
chartType="line" :reshow="true" :extra="{ column: { width: trendChartColumnWidth } }" />
</template>
</view>
<view class="chose">
<checkbox :checked="showLastPeriod" @click="toggleLastWeek" />
{{ MainCurrentDateType === 'week' ? '显示上周数据' : '显示上月数据' }}
</view>
<!-- 数据对比 -->
<view class="compare">
<view class="header-row">
<text class="title">对比</text>
<view class="legend-group">
<view class="legend-item">
<view class="dot blue"></view>
<text>{{ MainCurrentDateType === 'week' ? '上周' : '上月' }}</text>
</view>
<view class="legend-item">
<view class="dot yellow"></view>
<text>{{ MainCurrentDateType === 'week' ? '本周' : '本月' }}</text>
<!-- 2. 趋势图表区域 -->
<view class="card-section">
<view class="section-header space-between">
<text class="title">趋势</text>
<view class="part-select-wrapper">
<up-select v-model:current="partId"
:label="partOptions.find((item) => item.id === partId)?.name || '全部'" :options="partOptions"
@select="onPartChange"></up-select>
</view>
<view class="legend-item">
<text>对比</text>
</view>
<!-- 趋势维度切换 Tabs -->
<view class="tab-group flex-wrap">
<view class="tab-item" v-for="item in trendTabs" :key="item.id"
:class="{ active: NumberTabIndex === item.id }" @click="NumberTabIndex = item.id">
{{ item.name }}
</view>
</view>
</view>
<view class="compare-card">
<view class="data-list">
<view v-for="(item, index) in listData" :key="index" class="data-row">
<view class="label">{{ item.label }}</view>
<view class="value">{{ item.lastPeriod }}</view>
<view class="value">{{ item.currPeriod }}</view>
<view class="value diff" :class="{ negative: item.diff < 0 }">
{{ item.diff > 0 ? '+' + item.diff : item.diff }}
<text class="tip-text">{{ TREND_TIPS[NumberTabIndex] }}</text>
<!-- 统计图表(根据 showLastPeriod 切换 柱状图 / 折线图) -->
<view class="chart-container">
<template v-if="!showLastPeriod">
<LineChart v-if="chartCategories.length > 0" :categories="chartCategories" :series="chartSeries"
chartType="column" :reshow="true" :extra="{ column: { width: trendChartColumnWidth } }" />
</template>
<template v-else>
<LineChart v-if="chartCategories.length > 0" :categories="chartCategories" :series="chartSeries"
chartType="line" :reshow="true" :extra="{ column: { width: trendChartColumnWidth } }" />
</template>
</view>
<!-- 显示上周/上月数据勾选框 -->
<view class="toggle-checkbox" @click="toggleLastWeek">
<checkbox :checked="showLastPeriod" color="#10b981" style="transform: scale(0.8);" />
<text class="toggle-label">
{{ MainCurrentDateType === 'week' ? '显示上周数据' : '显示上月数据' }}
</text>
</view>
<!-- 3. 数据对比区域 -->
<view class="compare-section">
<view class="compare-header">
<text class="sub-title">对比</text>
<view class="legend-group">
<view class="legend-item">
<view class="dot blue"></view>
<text>{{ MainCurrentDateType === 'week' ? '上周' : '上月' }}</text>
</view>
<view class="legend-item">
<view class="dot yellow"></view>
<text>{{ MainCurrentDateType === 'week' ? '本周' : '本月' }}</text>
</view>
<view class="legend-item">
<text>对比</text>
</view>
</view>
</view>
<!-- 对比数据卡片 -->
<view class="compare-card">
<view class="data-list">
<view v-for="(item, index) in listData" :key="index" class="data-row">
<view class="label">{{ item.label }}</view>
<view class="value text-secondary">{{ item.lastPeriod }}</view>
<view class="value text-main">{{ item.currPeriod }}</view>
<view class="value diff" :class="{ positive: item.diff > 0, negative: item.diff < 0 }">
{{ item.diff > 0 ? '+' + item.diff : item.diff }}
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</up-transition>
</up-transition>
<!-- 底部安全区域间距 -->
<view class="bottom-spacer"></view>
</scroll-view>
</view>
<!-- 肌肉详情弹窗 -->
<!-- <up-popup :show="muscleShow" mode="top" @close="muscleShow = false" safeAreaInsetTop>
<view class="muscleShow-popup" :style="{ paddingTop: statusBarHeight + 'px' }">
<timeSelect @dateTypeChange="handleTypeChange" @dateRangeChange="handleDateChange" />
<view class="statistical">
<LineChart v-if="muscleShow" @close="closeMuscleDetail" :categories="muscleChartCategories" :series="[
{ name: '容量', data: muscleVolumeArr },
{ name: '组数', data: muscleSetsArr }
]" chartType="column" />
</view>
</view>
</up-popup> -->
<up-popup :show="muscleShow" mode="top" @close="closeMuscleDetail" safeAreaInsetTop>
<up-popup :show="muscleShow" mode="top" round="24rpx" @close="closeMuscleDetail" safeAreaInsetTop>
<view class="muscleShow-popup" :style="{ paddingTop: statusBarHeight + 'px' }">
<timeSelect @dateTypeChange="handleTypeChange" @dateRangeChange="handleDateChange" />
<!-- 肌肉名称 -->
<view class="muscle-name">
{{ currentMuscleName }}
</view>
<view class="popup-body">
<!-- 肌肉名称 -->
<view class="muscle-name">
{{ currentMuscleName }}
</view>
<!-- 容量/组数 切换标签 -->
<view class="muscle-tabs">
<view class="tab" :class="{ active: muscleTabIndex === 0 }" @click="muscleTabIndex = 0">容量</view>
<view class="tab" :class="{ active: muscleTabIndex === 1 }" @click="muscleTabIndex = 1">组数</view>
</view>
<view class="tip">{{ tabTip[muscleTabIndex] }}</view>
<!-- 图表区域 -->
<view class="statistical">
<!-- <LineChart v-if="muscleShow" :categories="muscleChartCategories" :series="muscleChartSeries"
chartType="column" /> -->
<LineChart v-if="muscleShow && muscleChartCategories.length"
:key="`muscle-chart-${currentMuscleId}-${MainCurrentDateType}-${muscleTabIndex}`"
:categories="muscleChartCategories" :series="muscleChartSeries" chartType="column" :reshow="true"
:extra="{ column: { width: muscleChartColumnWidth } }" />
<!-- 容量/组数 切换标签 -->
<view class="muscle-tabs">
<view class="tab-item" :class="{ active: muscleTabIndex === 0 }" @click="muscleTabIndex = 0">
容量
</view>
<view class="tab-item" :class="{ active: muscleTabIndex === 1 }" @click="muscleTabIndex = 1">
组数
</view>
</view>
<view class="tip">{{ TREND_TIPS[muscleTabIndex] }}</view>
<!-- 图表区域 -->
<view class="statistical">
<LineChart v-if="muscleShow && muscleChartCategories.length"
:key="`muscle-chart-${currentMuscleId}-${MainCurrentDateType}-${muscleTabIndex}`"
:categories="muscleChartCategories" :series="muscleChartSeries" chartType="column" :reshow="true"
:extra="{ column: { width: muscleChartColumnWidth } }" />
</view>
</view>
</view>
</up-popup>
</view>
</template>
<script setup>
import dayjs from 'dayjs';
import LineChart from './LineChart.vue';
import { ref, computed, onMounted } from 'vue';
import timeSelect from './time-select.vue';
... ... @@ -149,303 +169,263 @@ import { getMenuButtonHeight } from '@/utils/safeArea';
import TrainingDataApi from '@/sheep/api/TrainingData/Data.js';
import LeftmotionApi from '@/sheep/api/motion/equipments';
const statusBarHeight = getMenuButtonHeight();
// ==================== 常量 ====================
// 时间范围
const startDate = ref('')
const endDate = ref('')
const WEEKDAY_LABELS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
// 肌肉弹窗
// 弹窗的容量/组数切换索引
const muscleTabIndex = ref(0)
const muscleShow = ref(false);
const currentMuscleName = ref('')
const selectLabel = ref('全部')
const partId = ref('');
const partOptions = ref([]);
const weeklyData = ref(null);
// 加载锁
const weeklyLoading = ref(false);
const trainingLoading = ref(false);
const muscleListLoading = ref(false);
/** 趋势维度文案 */
const TREND_DATA_KEYS = ['volume', 'setCount', 'duration', 'distance', 'count'];
// 时间类型 week / month
const MainCurrentDateType = ref('week');
// 底部提示文案
const tabTip = ref([
const TREND_TIPS = [
'重量X组数X次数,用来评估训练的整体强度(kg)',
'该时间段内训练组数趋势(组)',
'累计计时类动作的时长(min)',
'累计距离类动作的距离(km)',
'累计次数类动作的次数(次)',
]);
];
// ==================== 日期工具函数 ====================
/** 格式化日期为 YYYY-MM-DD */
const formatDateKey = (date) => dayjs(date).format('YYYY-MM-DD');
/** 获取包含指定日期的完整周日期列表(周一~周日),返回 dayjs 实例数组 */
const getWeekFullDates = (baseDateStr) => {
const d = dayjs(baseDateStr);
const dayOfWeek = d.day(); // 0=周日, 1=周一, ...
const monday = d.subtract(dayOfWeek === 0 ? 6 : dayOfWeek - 1, 'day').startOf('day');
return Array.from({ length: 7 }, (_, i) => monday.add(i, 'day'));
};
/** 获取指定日期所在月份的全部日期列表,返回 dayjs 实例数组 */
const getMonthFullDates = (baseDateStr) => {
const d = dayjs(baseDateStr);
const daysInMonth = d.daysInMonth();
return Array.from({ length: daysInMonth }, (_, i) =>
d.date(i + 1).startOf('day')
);
};
/** 解析 API 返回的日期数组 [year, month, day] 为 dayjs 实例 */
const parseApiDate = (apiDateArr) => {
if (!Array.isArray(apiDateArr) || apiDateArr.length !== 3) return null;
const [year, month, day] = apiDateArr;
return dayjs(`${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`);
};
/** 趋势推荐维度按周/月数据加载 */
const loadCategoryData = (date) => {
const loader = MainCurrentDateType.value === 'week'
? loadWeeklyByCategory
: loadMonthlyByCategory;
return loader(date);
};
/** 肌肉详情按周/月数据加载 */
const loadMuscleData = (muscleId, date) => {
const isWeekly = MainCurrentDateType.value === 'week';
return loadMuscleStats(muscleId, date, isWeekly);
};
// ==================== 响应式状态 ====================
// 趋势标签
const NumberTabIndex = ref(0)
const statusBarHeight = getMenuButtonHeight();
// --- 时间范围 ---
const startDate = ref('');
const endDate = ref('');
const MainCurrentDateType = ref('week'); // 'week' | 'month'
// --- 筛选状态 ---
const partId = ref('');
const partOptions = ref([]);
const showLastPeriod = ref(false);
// --- 趋势 Tab 状态 ---
const NumberTabIndex = ref(0);
const trendTabs = ref([
{ id: 0, name: '容量' },
{ id: 1, name: '组数' },
{ id: 2, name: '时长' },
{ id: 3, name: '距离' },
{ id: 4, name: '次数' },
])
]);
// 肌肉部位标签
// --- 肌肉部位 Tab ---
const partTabIndex = ref(0);
// 核心:格式化肌肉部位数据,支持容量/组数切换
const detailPartOptions = computed(() => {
// 没有肌肉数据时返回空数组
if (!subCategorieList.value.length) return []
// 根据当前选中的标签,决定取哪个数据
const isVolume = partTabIndex.value === 0 // 0是容量,1是组数
const dataMap = isVolume ? trainingData.value.totalVolumeMap : trainingData.value.setCountMap
// 把 subCategorieList 里的每一项,加上对应的数据
return subCategorieList.value.map(item => {
return {
id: item.id,
name: item.name,
// 从 dataMap 里取数据,没有的话默认0
value: dataMap[item.id] ?? 0
}
})
})
const totalPartValue = computed(() => {
return detailPartOptions.value.reduce((sum, item) => sum + (Number(item.value) || 0), 0)
})
// 弹窗用的完整日期列表(周/月模式下的所有日期)
const muscleFullDates = ref([])
const muscleProcessedData = ref({
volumeArr: [],
setsArr: []
})
// 核心:计算最后一行剩余格子数,用于总计自适应占格
const itemTotal = computed(() => detailPartOptions.value.length)
const remainSpan = computed(() => {
const mod = itemTotal.value % 4
return mod === 0 ? 4 : 4 - mod
})
// --- 加载锁 ---
const weeklyLoading = ref(false);
const trainingLoading = ref(false);
const muscleListLoading = ref(false);
const showLastPeriod = ref(false);
// --- 数据 ---
const weeklyData = ref(null);
const subCategorieList = ref([]);
const trainingData = ref({ setCountMap: {}, totalVolumeMap: {} });
// 周/月 图表类目 & 数据
const weekCategories = ref(['周一', '周二', '周三', '周四', '周五', '周六', '周日']);
// --- 肌肉弹窗 ---
const muscleShow = ref(false);
const muscleTabIndex = ref(0);
const currentMuscleId = ref(null);
const currentMuscleName = ref('');
const muscleDetailData = ref({ muscleId: 0, dailyStatsList: [] });
const muscleFullDates = ref([]);
const muscleProcessedData = ref({ volumeArr: [], setsArr: [] });
const lastMonthData = ref(Array(31).fill(0));
// ==================== 计算属性 ====================
// ✅ 修复版:强制生成完整的周/月日期,就算没数据也要显示横轴
const chartCategories = computed(() => {
if (!weeklyData.value) return []
/** 肌肉部位数据列表(支持容量/组数切换) */
const detailPartOptions = computed(() => {
if (!subCategorieList.value.length) return [];
const isVolume = partTabIndex.value === 0;
const dataMap = isVolume
? trainingData.value.totalVolumeMap
: trainingData.value.setCountMap;
return subCategorieList.value.map((item) => ({
id: item.id,
name: item.name,
value: dataMap[item.id] ?? 0,
}));
});
if (MainCurrentDateType.value === 'week') {
// 周模式:生成周一到周日的完整日期(月/日格式)
const weekDates = getWeekFullDates(startDate.value).map(d => {
const month = d.getMonth() + 1
const day = d.getDate()
return `${month}/${day}`
})
return weekDates
} else {
// 月模式:生成当月1日到月底的完整日期(纯日期格式)
const monthDates = getMonthFullDates(startDate.value).map(d => {
return `${d.getDate()}`
})
return monthDates
}
})
/** 肌肉部位总计 */
const totalPartValue = computed(() =>
detailPartOptions.value.reduce((sum, item) => sum + (Number(item.value) || 0), 0)
);
// 趋势图表动态柱子宽度:周20,月3
const trendChartColumnWidth = computed(() => {
return MainCurrentDateType.value === 'week' ? 15 : 3
})
/** 总计格子剩余 span(补齐最后一行) */
const remainSpan = computed(() => {
const mod = detailPartOptions.value.length % 4;
return mod === 0 ? 4 : 4 - mod;
});
/** 图表 X 轴日期标签 */
const chartCategories = computed(() => {
if (!weeklyData.value) return [];
const fullDates = MainCurrentDateType.value === 'week'
? getWeekFullDates(startDate.value)
: getMonthFullDates(startDate.value);
// 动态控制柱状图宽度:周15,月5
const muscleChartColumnWidth = computed(() => {
return MainCurrentDateType.value === 'week' ? 15 : 3
})
return MainCurrentDateType.value === 'week'
? fullDates.map((d) => d.format('M/D'))
: fullDates.map((d) => d.format('D'));
});
const trendTabIndex = ref(0);
/** 柱体宽度 */
const trendChartColumnWidth = computed(() =>
MainCurrentDateType.value === 'week' ? 15 : 3
);
// ✅ 修复版:对比表数据,直接从接口取上周/上月总数据
const listData = computed(() => {
if (!weeklyData.value) return []
const curr = weeklyData.value
// 上周/上月的总数据,接口里的字段名是 lastWeekTotalXXX
const last = weeklyData.value
return [
{
label: '容量',
lastPeriod: last.lastWeekTotalVolume ?? 0,
currPeriod: curr.totalVolume ?? 0,
diff: (curr.totalVolume ?? 0) - (last.lastWeekTotalVolume ?? 0)
},
{
label: '组数',
lastPeriod: last.lastWeekTotalSets ?? 0,
currPeriod: curr.totalSets ?? 0,
diff: (curr.totalSets ?? 0) - (last.lastWeekTotalSets ?? 0)
},
{
label: '时长',
lastPeriod: last.lastWeekTotalDuration ?? 0,
currPeriod: curr.totalDuration ?? 0,
diff: (curr.totalDuration ?? 0) - (last.lastWeekTotalDuration ?? 0)
},
{
label: '距离',
lastPeriod: last.lastWeekTotalDistance ?? 0,
currPeriod: curr.totalDistance ?? 0,
diff: (curr.totalDistance ?? 0) - (last.lastWeekTotalDistance ?? 0)
},
{
label: '次数',
lastPeriod: last.lastWeekTotalCount ?? 0,
currPeriod: curr.totalCount ?? 0,
diff: (curr.totalCount ?? 0) - (last.lastWeekTotalCount ?? 0)
}
]
})
const muscleChartColumnWidth = computed(() =>
MainCurrentDateType.value === 'week' ? 15 : 3
);
/** 趋势图 Series 数据 */
const chartSeries = computed(() => {
if (!weeklyData.value) return []
if (!weeklyData.value) return [];
// 1. 获取当前周/月完整日期列表
let fullDateList = MainCurrentDateType.value === 'week'
const isWeekly = MainCurrentDateType.value === 'week';
const fullDateList = isWeekly
? getWeekFullDates(startDate.value)
: getMonthFullDates(startDate.value)
// 转成 YYYY-MM-DD
const fullDateKeys = fullDateList.map(d => {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
})
// weeklyData是周/月返回的数据
const currDataMap = MainCurrentDateType.value === 'week'
: getMonthFullDates(startDate.value);
const fullDateKeys = fullDateList.map((d) => formatDateKey(d));
const currDataMap = isWeekly
? weeklyData.value.weeklyDataMap || {}
: weeklyData.value.monthlyDataMap || {};
const lastDataMap = MainCurrentDateType.value === 'week'
const lastDataMap = isWeekly
? weeklyData.value.lastWeekDataMap || {}
: weeklyData.value.lastMonthDataMap || {};
const keyMap = ['volume', 'setCount', 'duration', 'distance', 'count']
const dataKey = keyMap[NumberTabIndex.value] || 'volume'
// 本周/本月数据
const currData = fullDateKeys.map(dateStr => currDataMap[dateStr]?.[dataKey] ?? 0)
// ✅ 加这行,看控制台打印的 currData 数组
console.log('【月数据数组 currData】', currData);
console.log('【当前选中的 dataKey】', dataKey);
console.log('【接口返回的 monthlyDataMap】', weeklyData.value.monthlyDataMap);
// ==========================================
// 核心:周减7天 / 月减1个月(自动适配大小月)
// ==========================================
const lastData = fullDateKeys.map(dateStr => {
const now = new Date(dateStr)
const target = new Date(now)
if (MainCurrentDateType.value === 'week') {
target.setDate(target.getDate() - 7)
} else {
target.setMonth(target.getMonth() - 1) // ✅ JS自动处理大小月
}
const y = target.getFullYear()
const m = String(target.getMonth() + 1).padStart(2, '0')
const day = String(target.getDate()).padStart(2, '0')
const key = `${y}-${m}-${day}`
const dataKey = TREND_DATA_KEYS[NumberTabIndex.value] || 'volume';
return lastDataMap[key]?.[dataKey] ?? 0
})
// 当前周期数据
const currData = fullDateKeys.map((dateStr) => currDataMap[dateStr]?.[dataKey] ?? 0);
const currName = MainCurrentDateType.value === 'week' ? '本周' : '本月'
const lastName = MainCurrentDateType.value === 'week' ? '上周' : '上月'
// 上一周期数据(按日期偏移匹配)
const lastData = fullDateKeys.map((dateStr) => {
const targetKey = isWeekly
? dayjs(dateStr).subtract(7, 'day').format('YYYY-MM-DD')
: dayjs(dateStr).subtract(1, 'month').format('YYYY-MM-DD');
return lastDataMap[targetKey]?.[dataKey] ?? 0;
});
const currName = isWeekly ? '本周' : '本月';
const lastName = isWeekly ? '上周' : '上月';
return showLastPeriod.value
? [
{ name: lastName, data: lastData, color: '#5b8ff9' },
{ name: currName, data: currData, color: '#ffc53d' }
]
: [{ name: currName, data: currData, color: '#ffc53d' }]
})
// 处理肌肉弹窗的图标坐标数据
// 1. 根据当前日期,生成周模式的完整日期列表(周一到周日)
const getWeekFullDates = (baseDateStr) => {
const date = new Date(baseDateStr)
const day = date.getDay() // 0=周日, 1=周一...
const monday = new Date(date)
monday.setDate(date.getDate() - (day === 0 ? 6 : day - 1))
const dates = []
for (let i = 0; i < 7; i++) {
const d = new Date(monday)
d.setDate(monday.getDate() + i)
dates.push(d)
}
return dates
}
{ name: lastName, data: lastData, color: '#5b8ff9' },
{ name: currName, data: currData, color: '#ffc53d' },
]
: [{ name: currName, data: currData, color: '#ffc53d' }];
});
// 2. 根据当前日期,生成月模式的完整日期列表(当月1日到月底)
const getMonthFullDates = (baseDateStr) => {
const date = new Date(baseDateStr)
const year = date.getFullYear()
const month = date.getMonth()
const daysInMonth = new Date(year, month + 1, 0).getDate()
const dates = []
for (let i = 1; i <= daysInMonth; i++) {
dates.push(new Date(year, month, i))
/** 周期对比数据 */
const listData = computed(() => {
if (!weeklyData.value) return [];
const d = weeklyData.value;
const fields = [
{ label: '容量', last: d.lastWeekTotalVolume, curr: d.totalVolume },
{ label: '组数', last: d.lastWeekTotalSets, curr: d.totalSets },
{ label: '时长', last: d.lastWeekTotalDuration, curr: d.totalDuration },
{ label: '距离', last: d.lastWeekTotalDistance, curr: d.totalDistance },
{ label: '次数', last: d.lastWeekTotalCount, curr: d.totalCount },
];
return fields.map(({ label, last, curr }) => {
const lastVal = last ?? 0;
const currVal = curr ?? 0;
return {
label,
lastPeriod: lastVal,
currPeriod: currVal,
diff: currVal - lastVal,
};
});
});
/** 肌肉弹窗图表 X 轴 */
const muscleChartCategories = computed(() => {
if (MainCurrentDateType.value === 'week') {
return WEEKDAY_LABELS;
}
return dates
}
if (!muscleFullDates.value.length) return [];
return muscleFullDates.value.map((d) => dayjs(d).format('M/D'));
});
// 3. 接口返回的 date 数组 [年,月,日] 转成 Date 对象
const parseApiDate = (apiDateArr) => {
// apiDateArr 格式: [2026, 6, 1]
if (!Array.isArray(apiDateArr) || apiDateArr.length !== 3) return null
const [year, month, day] = apiDateArr
return new Date(year, month - 1, day) // 注意: 月份是0-11
}
const muscleVolumeArr = computed(() => muscleProcessedData.value.volumeArr || []);
const muscleSetsArr = computed(() => muscleProcessedData.value.setsArr || []);
/** 肌肉弹窗图表 Series */
const muscleChartSeries = computed(() => {
const data = muscleTabIndex.value === 0 ? muscleVolumeArr.value : muscleSetsArr.value;
const name = muscleTabIndex.value === 0 ? '容量' : '组数';
const color = muscleTabIndex.value === 0 ? '#ffc53d' : '#5b8ff9';
return [{ name, data: data || [], color }];
});
// 接口请求方法
// 根据动作分类获取指定日期所在周训练数据
// ==================== API 请求方法 ====================
/** 加载周分类趋势数据 */
const loadWeeklyByCategory = async (date) => {
if (weeklyLoading.value) return;
if (!date) return;
if (weeklyLoading.value || !date) return;
weeklyLoading.value = true;
try {
let categoryId = '0'
if (partId.value && partId.value !== '') {
categoryId = partId.value
}
const res = await TrainingDataApi.weeklyByCategory(date, categoryId, showLastPeriod.value ? 'true' : 'false');
console.log('===== 周数据接口完整返回 =====');
console.log(res.data);
console.log('本周weeklyDataMap:', res.data.weeklyDataMap);
console.log('上周lastWeekDataMap:', res.data.lastWeekDataMap);
console.log('上周总数据(lastWeekTotalXXX):', {
volume: res.data.lastWeekTotalVolume,
sets: res.data.lastWeekTotalSets,
duration: res.data.lastWeekTotalDuration,
distance: res.data.lastWeekTotalDistance,
count: res.data.lastWeekTotalCount
});
const categoryId = (partId.value && partId.value !== '') ? partId.value : '0';
const res = await TrainingDataApi.weeklyByCategory(
date,
categoryId,
showLastPeriod.value ? 'true' : 'false',
);
weeklyData.value = res.data;
} catch (err) {
console.error('动作分类周数据加载失败', err);
... ... @@ -454,19 +434,17 @@ const loadWeeklyByCategory = async (date) => {
}
};
// 根据动作分类获取指定日期所在月训练数据(可选择包含上月数据)
/** 加载月分类趋势数据 */
const loadMonthlyByCategory = async (date) => {
if (weeklyLoading.value) return;
if (!date) return;
if (weeklyLoading.value || !date) return;
weeklyLoading.value = true;
try {
let categoryId = '0'
if (partId.value && partId.value !== '') {
categoryId = partId.value
}
console.log('是否返回上个的数据:', showLastPeriod.value);
const res = await TrainingDataApi.monthlyByCategory(date, categoryId, showLastPeriod.value ? 'true' : 'false');
console.log('【动作分类月数据接口返回】', res.data);
const categoryId = (partId.value && partId.value !== '') ? partId.value : '0';
const res = await TrainingDataApi.monthlyByCategory(
date,
categoryId,
showLastPeriod.value ? 'true' : 'false',
);
weeklyData.value = res.data;
} catch (err) {
console.error('动作分类月数据加载失败', err);
... ... @@ -475,56 +453,41 @@ const loadMonthlyByCategory = async (date) => {
}
};
// 加载部位/动作分类
const loadbodyPartOptions = async () => {
const res = await LeftmotionApi.getAllCategories();
partOptions.value = [
{ id: '0', name: '全部' },
...res.data.map(item => {
return {
id: String(item.id),
name: item.name
}
})
]
console.log('部位列表:', partOptions.value);
};
const currentMuscleId = ref(null) // 当前点击的肌肉ID
// 加载肌肉数据
const subCategorieList = ref([])
// 训练数据(来自 loadTrainingData 接口)
const trainingData = ref({
setCountMap: {}, // 组数数据 { id: count }
totalVolumeMap: {} // 容量数据 { id: volume }
})
/** 加载肌肉部位分类列表 */
const loadGetSubCategorieList = async () => {
if (muscleListLoading.value) return;
muscleListLoading.value = true;
try {
const res = await TrainingDataApi.GetSubCategorieList();
subCategorieList.value = res.data;
console.log('肌肉数据:', subCategorieList.value);
subCategorieList.value = res.data || [];
} catch (err) {
console.error('加载失败:', err);
} finally {
muscleListLoading.value = false;
}
};
// 加载肌肉表格数据表格
/** 加载部位选项列表 */
const loadbodyPartOptions = async () => {
const res = await LeftmotionApi.getAllCategories();
partOptions.value = [
{ id: '0', name: '全部' },
...res.data.map((item) => ({ id: String(item.id), name: item.name })),
];
};
/** 加载肌肉部位训练数据(容量/组数分布) */
const loadTrainingData = async (sDate, eDate) => {
if (trainingLoading.value) return;
trainingLoading.value = true;
const s = String(sDate)
const e = String(eDate)
if (!s || !e || s === 'undefined' || e === 'undefined') return
const s = String(sDate);
const e = String(eDate);
if (!s || !e || s === 'undefined' || e === 'undefined') return;
try {
const res = await TrainingDataApi.mainDataAnalysis(s, e);
console.log('训练数据:', res.data);
if (res.data) {
trainingData.value.setCountMap = res.data.setCountMap || {}
trainingData.value.totalVolumeMap = res.data.totalVolumeMap || {}
console.log('trainingData.value', trainingData.value);
trainingData.value.setCountMap = res.data.setCountMap || {};
trainingData.value.totalVolumeMap = res.data.totalVolumeMap || {};
}
} catch (err) {
console.error('加载失败:', err);
... ... @@ -532,395 +495,343 @@ const loadTrainingData = async (sDate, eDate) => {
trainingLoading.value = false;
}
};
// 打开肌肉弹窗需要的数据
const openMuscleDetail = async (muscleId) => {
console.log('点击了肌肉ID:', muscleId)
currentMuscleId.value = muscleId
muscleShow.value = true
// 默认选中容量
muscleTabIndex.value = 0
// 从 subCategorieList 里找到肌肉名称
const muscle = subCategorieList.value.find(item => item.id === muscleId)
currentMuscleName.value = muscle?.name || ''
/** 加载单肌肉统计数据(周/月通用) */
const loadMuscleStats = async (muscleId, date, isWeekly) => {
try {
const res = isWeekly
? await TrainingDataApi.weeklyMuscleStats(muscleId, date)
: await TrainingDataApi.monthlyMuscleStats(muscleId, date);
// 根据当前是周/月模式,加载对应接口
if (MainCurrentDateType.value === 'week') {
await loadWeeklyMuscleStats(muscleId, startDate.value)
} else {
await loadMonthlyMuscleStats(muscleId, startDate.value)
}
}
muscleDetailData.value = res.data || { muscleId: 0, dailyStatsList: [] };
const muscleDetailData = ref({
muscleId: 0,
dailyStatsList: []
})
const fullDates = isWeekly ? getWeekFullDates(date) : getMonthFullDates(date);
muscleFullDates.value = fullDates;
// 加载 单肌肉-周统计
const loadWeeklyMuscleStats = async (muscleId, date) => {
try {
const res = await TrainingDataApi.weeklyMuscleStats(muscleId, date)
console.log('单肌肉周数据:', res.data)
muscleDetailData.value = res.data || { muscleId: 0, dailyStatsList: [] }
// --- 核心:数据对齐处理 ---
// 1. 生成本周完整日期列表
const fullDates = getWeekFullDates(date)
muscleFullDates.value = fullDates
// 2. 把接口数据按日期存成映射表
const dateMap = {}
muscleDetailData.value.dailyStatsList.forEach(item => {
const d = parseApiDate(item.date)
// 将 API 返回的 dailyStatsList 按日期建立索引
const dateMap = {};
muscleDetailData.value.dailyStatsList.forEach((item) => {
const d = parseApiDate(item.date);
if (d) {
// 转成 YYYY-MM-DD 字符串当key
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
dateMap[key] = {
volume: item.volume,
sets: item.sets
}
dateMap[formatDateKey(d)] = { volume: item.volume, sets: item.sets };
}
})
// 3. 按完整日期列表填充数据,没有数据填0
const volumeArr = []
const setsArr = []
fullDates.forEach(d => {
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
const data = dateMap[key] || { volume: 0, sets: 0 }
volumeArr.push(data.volume)
setsArr.push(data.sets)
})
muscleProcessedData.value = { volumeArr, setsArr }
} catch (err) {
console.error('单肌肉周数据失败', err)
}
}
// 加载 单肌肉-月统计
const loadMonthlyMuscleStats = async (muscleId, date) => {
try {
const res = await TrainingDataApi.monthlyMuscleStats(muscleId, date)
console.log('单肌肉月数据:', res.data)
muscleDetailData.value = res.data || { muscleId: 0, dailyStatsList: [] }
// --- 核心:数据对齐处理 ---
// 1. 生成本月完整日期列表
const fullDates = getMonthFullDates(date)
muscleFullDates.value = fullDates
// 2. 把接口数据按日期存成映射表
const dateMap = {}
muscleDetailData.value.dailyStatsList.forEach(item => {
const d = parseApiDate(item.date)
if (d) {
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
dateMap[key] = {
volume: item.volume,
sets: item.sets
}
}
})
// 3. 按完整日期列表填充数据,没有数据填0
const volumeArr = []
const setsArr = []
fullDates.forEach(d => {
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
const data = dateMap[key] || { volume: 0, sets: 0 }
volumeArr.push(data.volume)
setsArr.push(data.sets)
})
muscleProcessedData.value = { volumeArr, setsArr }
} catch (err) {
console.error('单肌肉月数据失败', err)
}
}
const closeMuscleDetail = () => {
muscleShow.value = false
// currentMuscleId.value = null
// currentMuscleName.value = ''
// muscleTabIndex.value = 0
// muscleDetailData.value = { muscleId: 0, dailyStatsList: [] }
// muscleFullDates.value = []
// muscleProcessedData.value = { volumeArr: [], setsArr: [] }
setTimeout(() => {
currentMuscleId.value = null;
currentMuscleName.value = '';
muscleDetailData.value = { muscleId: 0, dailyStatsList: [] };
muscleFullDates.value = [];
muscleProcessedData.value = { volumeArr: [], setsArr: [] };
}, 150);
}
// 处理好的容量数组(已对齐完整日期)
const muscleVolumeArr = computed(() => {
return muscleProcessedData.value.volumeArr || []
})
// 处理好的组数数组(已对齐完整日期)
const muscleSetsArr = computed(() => {
return muscleProcessedData.value.setsArr || []
})
});
// 弹窗图表的类目(周:周一到周日 / 月:具体日期)
const muscleChartCategories = computed(() => {
if (MainCurrentDateType.value === 'week') {
return ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
} else {
// 月模式:如果日期列表为空,返回空数组避免报错
if (!muscleFullDates.value.length) return []
return muscleFullDates.value.map(d => `${d.getMonth() + 1}/${d.getDate()}`)
}
})
// 按完整日期列表填充数据,缺失的补 0
const volumeArr = [];
const setsArr = [];
fullDates.forEach((d) => {
const data = dateMap[formatDateKey(d)] || { volume: 0, sets: 0 };
volumeArr.push(data.volume);
setsArr.push(data.sets);
});
// 弹窗图表数据:根据 muscleTabIndex 只显示容量或组数
const muscleChartSeries = computed(() => {
const data = muscleTabIndex.value === 0
? muscleVolumeArr.value
: muscleSetsArr.value
// 兜底:如果数据为空,返回空数组
if (!data || !data.length) {
return [{
name: muscleTabIndex.value === 0 ? '容量' : '组数',
data: [],
color: muscleTabIndex.value === 0 ? '#ffc53d' : '#5b8ff9'
}]
muscleProcessedData.value = { volumeArr, setsArr };
} catch (err) {
console.error('单肌肉数据加载失败', err);
}
};
return [{
name: muscleTabIndex.value === 0 ? '容量' : '组数',
data: data,
color: muscleTabIndex.value === 0 ? '#ffc53d' : '#5b8ff9'
}]
})
// ==================== 页面事件 ====================
// 事件方法
/** 筛选部位切换 */
const onPartChange = (item) => {
console.log('选中的项:', item);
partId.value = item.id;
if (MainCurrentDateType.value === 'week') {
loadWeeklyByCategory(startDate.value);
} else {
loadMonthlyByCategory(startDate.value);
}
loadCategoryData(startDate.value);
};
// 当前的日期类型周/月
/** 时间类型切换(周/月) */
const handleTypeChange = async (type) => {
MainCurrentDateType.value = type;
showLastPeriod.value = false;
loadTrainingData(startDate.value, endDate.value)
loadTrainingData(startDate.value, endDate.value);
if (startDate.value) {
if (type === 'week') {
await loadWeeklyByCategory(startDate.value)
} else {
await loadMonthlyByCategory(startDate.value)
}
await loadCategoryData(startDate.value);
}
// 如果弹窗打开,自动刷新肌肉数据
// 如果肌肉弹窗打开,同步刷新
if (muscleShow.value && currentMuscleId.value) {
if (type === 'week') {
await loadWeeklyMuscleStats(currentMuscleId.value, startDate.value)
} else {
await loadMonthlyMuscleStats(currentMuscleId.value, startDate.value)
}
await loadMuscleData(currentMuscleId.value, startDate.value);
}
};
// 处理日期改变
/** 日期范围变更 */
const handleDateChange = async (data) => {
console.log(data.startDate, data.endDate);
startDate.value = data.startDate;
endDate.value = data.endDate;
await loadTrainingData(startDate.value, endDate.value)
if (MainCurrentDateType.value === 'week') {
loadWeeklyByCategory(startDate.value);
} else {
loadMonthlyByCategory(startDate.value);
}
// 弹窗打开 → 刷新肌肉详情
await loadTrainingData(startDate.value, endDate.value);
loadCategoryData(startDate.value);
if (muscleShow.value && currentMuscleId.value) {
if (MainCurrentDateType.value === 'week') {
await loadWeeklyMuscleStats(currentMuscleId.value, startDate.value)
} else {
await loadMonthlyMuscleStats(currentMuscleId.value, startDate.value)
}
await loadMuscleData(currentMuscleId.value, startDate.value);
}
};
// 处理是否返回上周/月
/** 打开肌肉详情弹窗 */
const openMuscleDetail = async (muscleId) => {
currentMuscleId.value = muscleId;
muscleShow.value = true;
muscleTabIndex.value = 0;
const muscle = subCategorieList.value.find((item) => item.id === muscleId);
currentMuscleName.value = muscle?.name || '';
await loadMuscleData(muscleId, startDate.value);
};
/** 关闭肌肉详情弹窗 */
const closeMuscleDetail = () => {
muscleShow.value = false;
setTimeout(() => {
currentMuscleId.value = null;
currentMuscleName.value = '';
muscleDetailData.value = { muscleId: 0, dailyStatsList: [] };
muscleFullDates.value = [];
muscleProcessedData.value = { volumeArr: [], setsArr: [] };
}, 150);
};
/** 切换显示上一周期数据 */
const toggleLastWeek = () => {
showLastPeriod.value = !showLastPeriod.value
showLastPeriod.value = !showLastPeriod.value;
if (startDate.value) {
if (MainCurrentDateType.value === 'week') {
loadWeeklyByCategory(startDate.value)
} else {
loadMonthlyByCategory(startDate.value)
}
loadCategoryData(startDate.value);
}
}
};
// ==================== 生命周期 ====================
onMounted(async () => {
await loadbodyPartOptions();
await loadGetSubCategorieList();
if (startDate.value && endDate.value) {
await loadTrainingData(startDate.value, endDate.value)
if (MainCurrentDateType.value === 'week') {
await loadWeeklyByCategory(startDate.value)
} else {
await loadMonthlyByCategory(startDate.value)
}
await loadTrainingData(startDate.value, endDate.value);
await loadCategoryData(startDate.value);
}
});
</script>
<style lang="scss" scoped>
// ==================== CSS 变量 ====================
$color-primary: #202932;
$color-bg: #f8fafc;
$color-card: #ffffff;
$color-border: #f1f5f9;
$color-text: #0f172a;
$color-sub: #475569;
$color-muted: #94a3b8;
$color-green: #10b981;
$color-green-bg: #ecfdf5;
$color-green-border: #a7f3d0;
$color-blue: #5b8ff9;
$color-yellow: #ffc53d;
$color-red: #ff7875;
$radius-card: 24rpx;
$radius-tab: 50rpx;
$radius-item: 12rpx;
// ==================== 公共 Tab 样式 ====================
.tab-group {
display: flex;
gap: 16rpx;
margin-bottom: 12rpx;
&.flex-wrap {
flex-wrap: wrap;
}
}
.tab-item {
border: 1rpx solid #e2e8f0;
border-radius: $radius-tab;
background-color: $color-card;
padding: 10rpx 24rpx;
font-size: 24rpx;
color: $color-sub;
transition: all 0.2s ease;
cursor: pointer;
&.active {
background-color: $color-primary;
color: $color-card;
border-color: $color-primary;
font-weight: 600;
}
&:active {
opacity: 0.8;
}
}
.tip-text {
font-size: 22rpx;
color: $color-muted;
margin-bottom: 20rpx;
display: block;
}
// ==================== 页面主容器 ====================
.training-data-page {
min-height: 100vh;
display: flex;
flex-direction: column;
padding: 30rpx 24rpx 0;
box-sizing: border-box;
overflow: auto;
.content-scroll {
height: calc(100vh - 400rpx);
// #ifdef H5
height: calc(100vh - 185px);
// #endif
width: 100%;
width: 100vw;
background-color: $color-bg;
overflow: hidden;
// 顶部固定时间选择栏
.top-time-picker {
padding: 20rpx 24rpx;
box-sizing: border-box;
overflow: auto;
background-color: $color-card;
border-bottom: 1rpx solid $color-border;
z-index: 10;
}
.section {
padding: 30rpx 20rpx;
box-sizing: border-box;
background-color: #fff;
border-radius: 15rpx;
margin-top: 20rpx;
&:last-child {
padding-bottom: env(safe-area-inset-bottom);
margin-bottom: 110rpx;
}
// 主体可滚动区域
.main-content {
flex: 1;
height: 0;
min-height: 0;
overflow: hidden;
.title {
font-size: 30rpx;
font-weight: bold;
margin-bottom: 20rpx;
}
.scroll-area {
width: 100%;
height: 100%;
.tip {
font-size: 22rpx;
margin-bottom: 20rpx;
.scroll-inner {
padding: 24rpx;
display: flex;
flex-direction: column;
gap: 24rpx;
}
}
}
.img {
width: 100%;
height: 400rpx;
}
// 卡片通用容器
.card-section {
background-color: $color-card;
border-radius: $radius-card;
padding: 28rpx 24rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.02);
border: 1rpx solid $color-border;
.tabs {
.section-header {
display: flex;
gap: 20rpx;
align-items: center;
margin-bottom: 20rpx;
.tab {
border: 1rpx solid #eee;
border-radius: 50rpx;
background-color: #fff;
padding: 10rpx 20rpx;
font-size: 24rpx;
margin-bottom: 20rpx;
&.space-between {
justify-content: space-between;
}
&.active {
background-color: #202932;
color: #fff;
border: none;
}
.title {
font-size: 30rpx;
font-weight: 700;
color: $color-text;
}
.part-select-wrapper {
min-width: 140rpx;
}
}
/* ========== 核心网格布局 已修复 ========== */
.detail-part {
// 肌肉部位网格
.muscle-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 5rpx;
gap: 10rpx;
.part-item {
.grid-item {
width: 100%;
height: 140rpx;
// aspect-ratio: 1 / 1;
/* 强制正方形 */
background-color: #eee;
background-color: $color-bg;
border: 1rpx solid $color-border;
border-radius: $radius-item;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-direction: column;
font-size: 24rpx;
gap: 10rpx;
border-radius: 5rpx;
transition: transform 0.15s, background-color 0.15s;
.num {
font-weight: bold;
color: #000;
&:active {
transform: scale(0.96);
background-color: #f1f5f9;
}
}
/* 总计自适应占格 */
.total-item {
grid-column: span var(--remain-span);
height: 140rpx;
}
}
.item-name {
font-size: 24rpx;
color: $color-sub;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
.item-value {
font-size: 26rpx;
font-weight: 700;
color: #000000;
&.highlight {
color: $color-green;
}
}
&.total-item {
background-color: $color-green-bg;
border-color: $color-green-border;
.item-name {
color: #047857;
font-weight: 600;
}
}
}
}
.statistical {
// 图表容器
.chart-container {
width: 100%;
height: 400rpx;
}
.chose {
// 显示上期数据勾选
.toggle-checkbox {
display: flex;
align-items: center;
margin-top: 20rpx;
font-size: 24rpx;
color: #333333;
.toggle-label {
margin-left: 8rpx;
}
}
.compare {
// 周期对比区域
.compare-section {
margin-top: 30rpx;
.header-row {
.compare-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
.title {
.sub-title {
font-size: 36rpx;
font-weight: bold;
color: #333;
font-weight: 700;
color: #333333;
}
.legend-group {
display: flex;
gap: 100rpx;
gap: 32rpx;
.legend-item {
display: flex;
align-items: center;
font-size: 24rpx;
color: #666;
color: #666666;
.dot {
width: 20rpx;
... ... @@ -928,22 +839,18 @@ onMounted(async () => {
border-radius: 4rpx;
margin-right: 8rpx;
&.blue {
background-color: #5b8ff9;
}
&.yellow {
background-color: #ffc53d;
}
&.blue { background-color: $color-blue; }
&.yellow { background-color: $color-yellow; }
}
}
}
}
.compare-card {
background-color: #ffffff;
background-color: $color-card;
border-radius: 20rpx;
border: 5rpx solid #efefef;
overflow: hidden;
.data-list {
.data-row {
... ... @@ -953,31 +860,25 @@ onMounted(async () => {
border-bottom: 1rpx solid #f0f0f0;
font-size: 26rpx;
&:last-child {
border-bottom: none;
}
&:nth-child(odd) {
background-color: #f9fafc;
}
&:last-child { border-bottom: none; }
&:nth-child(odd) { background-color: #f9fafc; }
.label {
flex: 1.5;
color: #333;
color: #333333;
text-align: center;
}
.value {
flex: 1;
text-align: center;
color: #333;
color: #333333;
&.diff {
color: #666;
}
font-weight: 600;
&.negative {
color: #ff7875;
&.positive { color: $color-green; }
&.negative { color: $color-red; }
}
}
}
... ... @@ -986,16 +887,26 @@ onMounted(async () => {
}
}
.muscleShow-popup {
// 底部安全间距
.bottom-spacer {
height: env(safe-area-inset-bottom);
padding-bottom: 110rpx;
}
}
/* #ifdef MP-WEIXIN */
min-height: 40vh;
/* #endif */
// ==================== 肌肉弹窗样式 ====================
.muscleShow-popup {
padding-bottom: 30rpx;
/* #ifdef H5 */
min-height: 55vh;
/* #endif */
/* #ifdef MP-WEIXIN */
min-height: 40vh;
/* #endif */
/* #ifdef H5 */
min-height: 55vh;
/* #endif */
.popup-body {
.muscle-name {
font-size: 32rpx;
font-weight: bold;
... ... @@ -1006,25 +917,12 @@ onMounted(async () => {
display: flex;
gap: 20rpx;
padding: 20rpx;
.tab {
border: 1rpx solid #eee;
border-radius: 50rpx;
background-color: #fff;
padding: 10rpx 20rpx;
font-size: 24rpx;
&.active {
background-color: #202932;
color: #fff;
border: none;
}
}
}
.tip {
font-size: 22rpx;
padding: 0 20rpx 20rpx;
color: $color-muted;
}
.statistical {
... ...
<template>
<view class="plan-page">
<!-- 顶部横幅 -->
<view class="banner-wrapper">
<swiper class="swiper">
<swiper-item class="swiper-item">
<image class="img"
src="https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260316/37_1773628025534.png"
mode="aspectFill" />
</swiper-item>
<swiper-item class="swiper-item">
<image class="img"
src="https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260316/38_1773628032712.png"
mode="aspectFill" />
</swiper-item>
</swiper>
</view>
<!-- 筛选条 -->
<!-- 后端计划分类的计划列表只返回了难度字段,所有只有这个字段的筛选生效了 -->
<view class="filter-bar">
<view v-for="(item, index) in filterList" :key="index" class="filter-item"
:class="{ active: activeFilter === index }" @click="
activeFilter = index;
toggleDrawer(index);
">
<view v-for="(item, index) in filterList" :key="item" class="filter-item"
:class="{ active: activeFilter === index }" @click="toggleDrawer(index)">
<text class="text">{{
selectedFilters[item] === '不限' ? item : `${selectedFilters[item]}`
}}</text>
... ... @@ -33,20 +13,17 @@
<uni-icons type="search" size="20" color="#333"
style="border: 1px solid #c5c5c5; border-radius: 30rpx; padding: 4rpx 6rpx" @click="navigateToSearch" />
</view>
</view>
<!-- 下拉抽屉组件 -->
<view class="filter-drawer" v-show="drawerShow" @click.stop>
<view class="drawer-content">
<view v-for="(item, idx) in filterDrawerData" :key="idx" class="drawer-item" @click="selectDrawerItem(item)">
<text class="drawer-item-text">{{ item }}</text>
<view class="filter-drawer" v-show="drawerShow" @click.stop>
<view class="drawer-content">
<view v-for="item in filterDrawerData" :key="item" class="drawer-item" @click="selectDrawerItem(item)">
<text class="drawer-item-text">{{ item }}</text>
</view>
</view>
</view>
</view>
<!-- 主体内容:左侧分类 + 右侧计划列表 -->
<view class="content-layout">
<!-- 左侧分类 -->
<scroll-view class="sidebar" scroll-y enable-flex>
<view v-for="(item, index) in categoryList" :key="item.id" class="category-item"
:class="{ active: activeCategory === index }" @click="switchCategory(index)">
... ... @@ -54,7 +31,6 @@
</view>
</scroll-view>
<!-- 右侧计划列表 -->
<scroll-view class="plan-list-wrap" scroll-y enable-flex>
<view v-for="plan in currentPlanList" :key="plan.id" class="plan-card" @tap="navigateToDetail(plan)">
<image class="plan-cover" :src="plan.cover" mode="aspectFill" />
... ... @@ -70,7 +46,6 @@
</view>
</view>
</view>
<!-- 空状态 -->
<view v-if="currentPlanList.length === 0" class="empty-tip">
<text>该分类暂无训练计划</text>
</view>
... ... @@ -81,11 +56,39 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
// ✅ getCurrentPages 是全局 API,无需 import
import QueryPlanApi from '@/sheep/api/plan/queryplan';
import { getMenuButtonHeight, getTopSafeArea } from '@/utils/safeArea';
const menuButtonHeight = ref(getMenuButtonHeight());
const topSafeArea = ref(getTopSafeArea());
const PageHeight = ref(topSafeArea.value);
// #ifdef MP-WEIXIN
PageHeight.value = menuButtonHeight.value + topSafeArea.value;
// #endif
const filterList = ['频率', '难度', '场景', '人群'];
const filterOptionsMap = {
频率: ['不限', '1练/周', '2练/周', '3练/周', '4练/周', '5练/周'],
难度: ['不限', '初阶', '中阶', '高阶'],
场景: ['不限', '健身房', '仅哑铃', '仅哑铃+杠铃'],
人群: ['不限', '青少年', '成年人', '中老年', '运动损伤'],
};
const difficultyText = { 1: '初阶', 2: '中阶', 3: '高阶' };
const filterFieldMap = { 频率: 'frequency', 难度: 'difficultyLevel', 场景: 'scene', 人群: 'population' };
const filterMap = {
频率: {
不限: 0,
'1练/周': 1,
'2练/周': 2,
'3练/周': 3,
'4练/周': 4,
'5练/周': 5,
'6练/周': 6,
'7练/周': 7,
},
难度: { 不限: 0, 初阶: 1, 中阶: 2, 高阶: 3 },
场景: { 不限: 1, 健身房: 2, 仅哑铃: 3, '仅哑铃+杠铃': 4 },
人群: { 不限: 1, 青少年: 2, 成年人: 3, 中老年: 4, 运动损伤: 5 },
};
// ====== 筛选相关(保持原样)======
const filterList = ref(['频率', '难度', '场景', '人群']);
const drawerShow = ref(false);
const activeFilter = ref(null);
const selectedFilters = ref({
... ... @@ -94,15 +97,9 @@ const selectedFilters = ref({
场景: '不限',
人群: '不限',
});
const filterOptionsMap = {
频率: ['不限', '1练/周', '2练/周', '3练/周', '4练/周', '5练/周'],
难度: ['不限', '初阶', '中阶', '高阶'],
场景: ['不限', '健身房', '仅哑铃', '仅哑铃+杠铃'],
人群: ['不限', '青少年', '成年人', '中老年', '运动损伤'],
};
const filterDrawerData = computed(() => {
const currentFilterName = filterList.value[activeFilter.value];
const currentFilterName = filterList[activeFilter.value];
return filterOptionsMap[currentFilterName] || [];
});
... ... @@ -116,72 +113,35 @@ const toggleDrawer = (index) => {
};
const selectDrawerItem = (item) => {
const currentFilterName = filterList.value[activeFilter.value];
const currentFilterName = filterList[activeFilter.value];
selectedFilters.value[currentFilterName] = item;
drawerShow.value = false;
console.log('筛选条件更新:', selectedFilters.value);
};
// ====== 分类与计划 ======
const categoryList = ref([]);
const activeCategory = ref(0);
const planData = ref([]); // 存储所有已加载的计划(带 _categoryId)
const difficultyText = { 1: '初阶', 2: '中阶', 3: '高阶' };
// 完全匹配你后台的数字,复制这一段!
const filterMap = {
频率: {
不限: 0,
'1练/周': 1,
'2练/周': 2,
'3练/周': 3,
'4练/周': 4,
'5练/周': 5,
'6练/周': 6,
'7练/周': 7,
},
难度: { 不限: 0, 初阶: 1, 中阶: 2, 高阶: 3 },
场景: { 不限: 1, 健身房: 2, 仅哑铃: 3, '仅哑铃+杠铃': 4 },
人群: { 不限: 1, 青少年: 2, 成年人: 3, 中老年: 4, 运动损伤: 5 },
};
const planData = ref([]);
const currentPlanList = computed(() => {
const categoryId = categoryList.value[activeCategory.value]?.id;
if (categoryId == null) return [];
let list = planData.value.filter((p) => p._categoryId === categoryId);
// 频率
if (selectedFilters.value.频率 !== '不限') {
const val = filterMap.频率[selectedFilters.value.频率];
list = list.filter((item) => item.frequency === val);
}
// 难度
if (selectedFilters.value.难度 !== '不限') {
const val = filterMap.难度[selectedFilters.value.难度];
list = list.filter((item) => item.difficultyLevel === val);
}
// 场景
if (selectedFilters.value.场景 !== '不限') {
const val = filterMap.场景[selectedFilters.value.场景];
list = list.filter((item) => item.scene === val);
}
// 人群
if (selectedFilters.value.人群 !== '不限') {
const val = filterMap.人群[selectedFilters.value.人群];
list = list.filter((item) => item.population === val);
}
Object.keys(filterMap).forEach((key) => {
if (selectedFilters.value[key] !== '不限') {
const val = filterMap[key][selectedFilters.value[key]];
list = list.filter((item) => item[filterFieldMap[key]] === val);
}
});
return list;
});
// ✅ 切换分类时加载数据
const switchCategory = async (index) => {
activeCategory.value = index;
const categoryId = categoryList.value[index]?.id;
if (categoryId != null) {
// 检查是否已加载过该分类
const isLoaded = planData.value.some((p) => p._categoryId === categoryId);
if (!isLoaded) {
await loadPlansByCategory(categoryId);
... ... @@ -189,51 +149,43 @@ const switchCategory = async (index) => {
}
};
// ====== 数据加载 ======
const loadCategories = async () => {
try {
// 获得计划分类,左侧导航栏显示用
const res = await QueryPlanApi.getCategories();
categoryList.value = res.data || [];
console.log('加载计划列表categoryList.value:', categoryList.value);
if (categoryList.value.length > 0) {
await switchCategory(0); // 加载第一个分类
await switchCategory(0);
}
} catch (err) {
console.error('加载分类失败:', err);
uni.showToast({ title: '加载分类失败', icon: 'none' });
}
};
// ✅ 核心:加载计划,并手动附加 _categoryId
const loadPlansByCategory = async (categoryId) => {
try {
// 根据计划分类ID获得计划列表,右侧计划列表显示用
const res = await QueryPlanApi.getPlanList(categoryId);
console.log('加载计划列表res.data:', res.data);
const plans = (res.data || []).map((item) => ({
id: item.id,
title: item.name,
meta: `${difficultyText[item.difficultyLevel] || '初阶'} · ${item.enrollmentCount}人练过`,
tag: item.enrollmentCount > 500 ? '火爆' : item.enrollmentCount > 0 ? 'New' : '',
cover: item.urlCover || '默认图',
cover: item.urlCover,
_categoryId: categoryId,
frequency: item.frequencyPerWeek, // 新增
difficultyLevel: item.difficultyLevel, // 新增
scene: item.trainingScene, // 新增
population: item.targetPeople, // 新增
frequency: item.frequencyPerWeek,
difficultyLevel: item.difficultyLevel,
scene: item.trainingScene,
population: item.targetPeople,
}));
// 合并数据(避免重复)
planData.value = [...planData.value.filter((p) => p._categoryId !== categoryId), ...plans];
// 打印planData
console.log('加载计划列表planData.value:', planData.value);
} catch (err) {
console.error('加载计划失败:', err);
uni.showToast({ title: '加载计划失败', icon: 'none' });
}
};
// 跳转到搜索页面
const navigateToSearch = () => {
const app = getApp();
app.globalData.allPlansForSearch = planData.value;
... ... @@ -241,88 +193,35 @@ const navigateToSearch = () => {
url: '/pages4/pages/xunji/jihua-search',
});
};
// 跳转到计划详情页
const navigateToDetail = (plan) => {
uni.navigateTo({
url: `/pages4/pages/xunji/xunji-xunlian-jihua?planid=${plan.id}`,
});
};
// ====== 返回按钮 ======
const back = () => {
const pages = getCurrentPages();
if (pages.length > 1) {
uni.navigateBack();
} else {
uni.switchTab({ url: '/pages/index/index' }); // 请替换为你的首页路径
}
};
onMounted(() => {
loadCategories();
});
</script>
<style lang="scss" scoped>
/* ========== 新增:页面头部样式 ========== */
// .page-header {
// display: flex;
// align-items: center;
// padding: 30rpx;
// background: #fff;
// box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08);
// position: sticky;
// top: 0;
// z-index: 999;
// }
// .page-title {
// font-size: 32rpx;
// font-weight: bold;
// color: #333;
// flex: 1;
// text-align: center;
// }
/* ========== 以下是你原有的全部样式(完全保留) ========== */
.plan-page {
width: 100%;
height: 100vh;
background-color: #f5f5f5;
box-sizing: border-box;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* 顶部横幅 */
.banner-wrapper {
height: 250rpx;
width: 100%;
margin: 20rpx 0;
.swiper {
width: 100%;
height: 100%;
.swiper-item {
width: 100%;
height: 100%;
padding: 20rpx;
box-sizing: border-box;
.img {
width: 100%;
height: 220rpx;
border-radius: 20rpx;
}
}
}
height: calc(100vh - 50px);
// #ifdef MP-WEIXIN
height: calc(100vh - 138px);
// #endif
}
/* 筛选条 */
.filter-bar {
margin: 0 20rpx 20rpx;
position: relative;
margin: 20rpx 20rpx;
// #ifdef MP-WEIXIN
margin-top: 40rpx;
// #endif
padding: 12rpx 18rpx;
border-radius: 40rpx;
display: flex;
... ... @@ -369,14 +268,9 @@ onMounted(() => {
//下拉抽屉
.filter-drawer {
position: absolute;
top: 560rpx;
/* #ifdef H5 */
top: 247px;
/* #endif */
left: 20rpx;
right: 20rpx;
top: 100%;
left: -18rpx;
right: -18rpx;
z-index: 999;
border-radius: 0 0 40rpx 40rpx;
background-color: #fff;
... ... @@ -406,11 +300,10 @@ onMounted(() => {
/* 主体布局 */
.content-layout {
flex: 1;
height: 100%;
display: flex;
box-sizing: border-box;
overflow: hidden;
min-height: 0;
}
/* 左侧分类 */
... ... @@ -418,10 +311,10 @@ onMounted(() => {
width: 180rpx;
background-color: #ffffff;
border-radius: 16rpx;
height: 72%;
/* #ifdef H5 */
height: 88%;
// #ifdef MP-WEIXIN
height: 86%;
/* #endif */
// #endif
box-sizing: border-box;
overflow: hidden;
... ... @@ -455,10 +348,10 @@ onMounted(() => {
flex: 1;
padding: 0 20rpx;
box-sizing: border-box;
height: 72%;
/* #ifdef H5 */
height: 88%;
// #ifdef MP-WEIXIN
height: 86%;
/* #endif */
// #endif
overflow: hidden;
}
... ...
<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 === 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>
<view class="tab-item" :class="{ active: currentTab === 4 }" @click="handleTabClick(4)">模板</view>
<view class="tab-item" :class="{ active: currentTab === 5 }" @click="handleTabClick(5)">数据</view>
<view class="menu-btn" @click="openDrawer">
<uni-icons type="bars" size="20"></uni-icons>
<view class="home-page">
<view class="tab-bar" :style="{ paddingTop: PageHeight + 'px' }">
<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>
<view class="tab-item" :class="{ active: currentTab == 4 }" @click="handleTabClick(4)">模板</view>
<view class="tab-item" :class="{ active: currentTab == 5 }" @click="handleTabClick(5)">数据</view>
<view class="menu-btn" @click="drawerShow = true">
<up-icon name="list" size="20" />
</view>
</view>
<view class="content">
<!-- <xunjiXunji v-if="currentTab === 0" /> -->
<xunjiXunlianjihua v-if="currentTab === 1" />
<xunjiRili v-if="currentTab === 2" />
<xunjiDongzuo v-if="currentTab === 3" />
... ... @@ -21,116 +19,69 @@
<xunjiShuju v-if="currentTab === 5" />
</view>
<Tabbar />
<uni-drawer ref="drawer" mode="right" :mask="true" :width="280">
<view class="drawer-content" :style="{ paddingTop: menuButtonHeight + topSafeArea + 'px' }">
<up-popup :show="drawerShow" mode="right" :closeable="true" @close="drawerShow = false">
<view class="drawer-content" :style="{ paddingTop: PageHeight + 'px' }">
<view class="user-header">
<image class="avatar" :src="userInfo.avatar ||
'https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260526/默认头像_1779779926983.png'
" mode="aspectFill"></image>
<image
class="avatar"
:src="userInfo.avatar || defaultAvatar "
mode="aspectFill"
/>
<view class="user-info">
<text class="nickname">{{ userInfo.nickname || '未登录' }}</text>
</view>
</view>
<image class="ad-banner"
src="https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260316/37_1773628025534.png"
mode="aspectFill"></image>
<view class="menu-list">
<view class="menu-item" @click="navigateTo('/pages4/pages/xunji/xunji-wode-zhuye')">
<uni-icons type="person" size="18"></uni-icons>
<text class="menu-text">我的主页</text>
<uni-icons type="right" size="14"></uni-icons>
</view>
<view class="menu-item" @click="navigateTo('/pages4/pages/xunji/xunji-wode-moban')">
<uni-icons type="wallet" size="18"></uni-icons>
<text class="menu-text">我的模板</text>
<uni-icons type="right" size="14"></uni-icons>
</view>
<view class="menu-item" @click="navigateTo('/pages4/pages/xunji/xunji-wode-jihua')">
<uni-icons type="list" size="18"></uni-icons>
<text class="menu-text">我的计划</text>
<uni-icons type="right" size="14"></uni-icons>
</view>
<view class="menu-item" @click="goWidgetLib">
<uni-icons type="more" size="18"></uni-icons>
<text class="menu-text">小组件库</text>
<uni-icons type="right" size="14"></uni-icons>
</view>
<view class="menu-item" @click="goFeedback">
<uni-icons type="chat" size="18"></uni-icons>
<text class="menu-text">训记反馈建议</text>
<uni-icons type="right" size="14"></uni-icons>
</view>
<view class="menu-item" @click="goUserGroup">
<uni-icons type="chatboxes" size="18"></uni-icons>
<text class="menu-text">训记内测用户群</text>
<uni-icons type="right" size="14"></uni-icons>
</view>
<view class="menu-item" @click="goTutorial">
<uni-icons type="help" size="18"></uni-icons>
<text class="menu-text">训记使用教程</text>
<uni-icons type="right" size="14"></uni-icons>
<text class="nickname">{{ userInfo.nickname || '-' }}</text>
</view>
</view>
<up-cell-group :border="false">
<up-cell title="我的主页" icon="account" :isLink="true"
@click="navigateTo('/pages4/pages/xunji/xunji-wode-zhuye')" />
<up-cell title="我的模板" icon="file-text" :isLink="true"
@click="navigateTo('/pages4/pages/xunji/xunji-wode-moban')" />
<up-cell title="我的计划" icon="order" :isLink="true"
@click="navigateTo('/pages4/pages/xunji/xunji-wode-jihua')" />
<up-cell title="训记使用教程" icon="question-circle" :isLink="true" @click="goTutorial" />
</up-cell-group>
</view>
</uni-drawer>
</up-popup>
<TrainingFloating />
<Tabbar />
</view>
</template>
<script setup>
import { ref, shallowRef, computed, onMounted } from 'vue';
import { onLoad, onHide, onShow } from '@dcloudio/uni-app';
import { ref, computed } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app';
import useUserStore from '@/sheep/store/user';
import { getMenuButtonHeight, getTopSafeArea } from '@/utils/safeArea';
// 组件导入
import { defaultAvatar } from '@/sheep/config/index';
import xunjiRili from '@/pages/xunji/components/xunji-rili.vue';
import xunjiDongzuo from '@/pages/xunji/components/xunji-dongzuo.vue';
import xunjiMoban from '@/pages/xunji/components/xunji-moban.vue';
import xunjiShuju from '@/pages/xunji/components/xunji-shuju.vue';
import xunjiXunji from '@/pages/xunji/components/xunji-xunji.vue';
import xunjiXunlianjihua from '@/pages/xunji/components/xunji-xunlianjihua.vue';
import TrainingFloating from '@/pages/TrainingFloating.vue';
import TrainingFloating from '@/pages/TrainingFloating.vue'
import { useTrainingStore } from '@/sheep/store/trainingStore';
const trainingStore = useTrainingStore();
// 状态管理
const userStore = useUserStore();
const drawerShow = ref(false);
const currentTab = ref(3);
// --- 补全缺失的响应式变量定义 ---
const currentTab = ref(1);
const currentComponent = shallowRef(xunjiXunji); // 用于 H5 端动态组件切换(如保留原功能逻辑)
const drawer = ref(null); // uni-drawer 的组件实例引用
const menuButtonHeight = ref(getMenuButtonHeight());
const topSafeArea = ref(getTopSafeArea());
const PageHeight = ref(topSafeArea.value);
// #ifdef MP-WEIXIN
PageHeight.value = menuButtonHeight.value + topSafeArea.value;
// #endif
// 安全区域相关变量(从工具函数异步或同步获取)
const menuButtonHeight = ref(getMenuButtonHeight ? getMenuButtonHeight() : 0);
const topSafeArea = ref(getTopSafeArea ? getTopSafeArea() : 0);
// 用户信息计算属性(防止 store 异步更新时报错)
const userInfo = computed(() => userStore.userInfo || {});
// --- 原有页面生命周期维护 ---
onHide(() => {
if (drawer.value) {
drawer.value.close();
}
});
onLoad((options) => {
if (!userStore.isLogin) {
uni.redirectTo({
url: '/pages7/pages/index/login',
});
uni.redirectTo({ url: '/pages7/pages/index/login' });
return;
}
// 确保解析路由参数的正确性
userStore.getInfo();
if (options && options.currentTab) {
currentTab.value = Number(options.currentTab);
}
... ... @@ -142,85 +93,40 @@ onShow(() => {
const tabNum = Number(targetTab);
currentTab.value = tabNum;
handleTabClick(tabNum);
// 使用后清空缓存,避免下次进来自动跳转
uni.removeStorageSync('xunjiTargetTab');
}
});
})
// --- 原有业务方法补充与修复 ---
// 打开抽屉
const openDrawer = () => {
if (drawer.value) {
drawer.value.open();
}
};
// 修复模板直接调用 uni.navigateTo 的多端兼容问题
const navigateTo = (url) => {
uni.navigateTo({ url });
};
// 处理标签点击
const handleTabClick = (index) => {
currentTab.value = index;
// #ifdef H5
switch (index) {
case 0:
currentComponent.value = xunjiXunji;
break;
case 1:
currentComponent.value = xunjiXunlianjihua;
break;
case 2:
currentComponent.value = xunjiRili;
break;
case 3:
currentComponent.value = xunjiDongzuo;
break;
case 4:
currentComponent.value = xunjiMoban;
break;
case 5:
currentComponent.value = xunjiShuju;
break;
default:
currentComponent.value = xunjiXunlianjihua;
}
// #endif
};
// 空实现占位(防止模板点击报错,保持你原有的未给出的方法定义)
const goWidgetLib = () => {
uni.navigateTo({ url: '/pages4/pages/xunji/widget' });
};
const goFeedback = () => { };
const goUserGroup = () => { };
const goTutorial = () => { };
defineExpose({
switchTabIndex: handleTabClick
})
defineExpose({ switchTabIndex: handleTabClick });
</script>
<style scoped lang="scss">
.home-page {
width: 100%;
height: 100vh;
// #ifdef H5
padding-bottom: 90rpx;
// #ifdef H5
height: calc(100vh - 50px);
// #endif
background-color: #f5f5f5;
box-sizing: border-box;
overflow: hidden;
display: flex;
flex-direction: column;
// #ifdef MP-WEIXIN
height: calc(100vh - 50px);
// #endif
background-color: #fff;
.content {
flex: 1;
box-sizing: border-box;
overflow: hidden;
padding-top: 70rpx;
padding-top: 65rpx;
// #ifdef MP-WEIXIN
padding-top: 200rpx;
// #endif
... ... @@ -231,37 +137,32 @@ defineExpose({
display: flex;
align-items: flex-end;
justify-content: space-between;
padding: 0px 20px;
padding-top: 20rpx;
padding: 0 20px;
background-color: #fff;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 999;
box-sizing: border-box;
}
/* 标签项:默认样式 */
.tab-item {
font-size: 16px;
color: #666;
padding: 5px 0;
padding-top: 30rpx;
}
/* 激活态标签:下划线+深色文字 */
.tab-item.active {
color: #333;
border-bottom: 2px solid #000;
font-weight: 500;
}
.tab-item {
font-size: 16px;
color: #666;
padding: 5px 0;
padding-top: 30rpx;
&.active {
color: #333;
border-bottom: 2px solid #000;
font-weight: 500;
}
}
/* 菜单按钮:样式 */
.menu-btn {
font-size: 20px;
color: #666;
.menu-btn {
font-size: 20px;
color: #666;
}
}
.card-subtitle {
... ... @@ -294,82 +195,37 @@ defineExpose({
color: #666;
}
/* 抽屉样式 */
.drawer-content {
background-color: #ffffff;
width: 500rpx;
background-color: #f7f7f8;
height: 100%;
padding: 20rpx;
padding: 0;
box-sizing: border-box;
}
/* 用户头像和信息区域 */
.user-header {
display: flex;
align-items: center;
padding: 20rpx 0;
border-bottom: 1px solid #f0f0f0;
}
.user-header .avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
margin-right: 20rpx;
margin-top: 0;
justify-content: flex-start;
}
.user-info {
display: flex;
flex-direction: column;
}
.nickname {
font-size: 28rpx;
font-weight: 500;
color: #333333;
margin-bottom: 8rpx;
}
.user-id {
font-size: 24rpx;
color: #999999;
}
/* 广告横幅 */
.ad-banner {
width: 100%;
height: 200rpx;
margin: 20rpx 0;
border-radius: 12rpx;
}
/* 菜单列表 */
.menu-list {
margin-top: 20rpx;
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 30rpx 0;
border-bottom: 1px solid #f0f0f0;
}
.menu-item:last-child {
border-bottom: none;
}
.menu-text {
font-size: 28rpx;
color: #333333;
margin-left: 20rpx;
flex: 1;
}
/* 覆盖uni-icons默认颜色 */
:deep(.uni-icons) {
color: #666666;
.user-header {
display: flex;
align-items: center;
padding: 40rpx 30rpx 30rpx;
background: #fff;
margin-bottom: 20rpx;
.avatar {
width: 90rpx;
height: 90rpx;
border-radius: 50%;
background: #f0f0f0;
flex-shrink: 0;
}
.user-info {
margin-left: 20rpx;
.nickname {
font-size: 28rpx;
font-weight: 500;
color: #333;
}
}
}
}
</style>
... ...
<!--
基础版训练计划搜索页(仅关键词搜索)
页面位于:训记横向导航栏的‘计划’模块,右侧搜索图标点击后进入的页面就是,核心功能是提供一个简单的搜索界面,允许用户输入关键词搜索训练计划,还未完成
-->
<template>
<view class="search-page">
<!-- 顶部搜索栏 -->
<view class="search-header">
<view class="search-input-wrapper">
<uni-icons type="search" size="18" color="#999" class="search-icon"></uni-icons>
<input ref="searchInput" v-model="searchKeyword" class="search-input" type="text" placeholder="搜索训练计划"
placeholder-style="color:#999" confirm-type="search" @confirm="handleSearch" focus />
</view>
<view class="cancel-btn" @click="handleCancel">
<text class="cancel-text">取消</text>
</view>
<up-search v-model="searchKeyword" placeholder="搜索训练计划" placeholderColor="#999" shape="round"
:showAction="true" actionText="取消" :clearabled="true" @search="handleSearch" @custom="handleSearch"
@cancel="handleCancel" />
</view>
<!-- 搜索结果区域 -->
<view class="search-results">
<!-- 未搜索时提示 -->
<view v-if="!hasSearched" class="empty-hint">
<text>请输入关键词搜索训练计划</text>
</view>
<!-- 已搜索但无结果 -->
<view v-else-if="searchResults.length === 0" class="empty-hint">
<text>未找到相关训练计划</text>
</view>
<!-- 搜索结果列表 -->
<scroll-view class="search-results" scroll-y enable-flex>
<up-empty v-if="!hasSearched" text="请输入关键词搜索训练计划" mode="search" />
<up-empty v-else-if="searchResults.length === 0" text="未找到相关训练计划" mode="data" />
<view v-else class="results-list">
<view v-for="plan in searchResults" :key="plan.id" class="result-item" @click="goToDetail(plan)">
<image v-if="plan.cover" :src="plan.cover" class="result-cover" mode="aspectFill" />
... ... @@ -38,77 +18,68 @@
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import QueryPlanApi from '@/sheep/api/plan/queryplan'
import { ref, computed, onMounted } from 'vue';
import QueryPlanApi from '@/sheep/api/plan/queryplan';
import { getNavbarHeight } from '@/utils/safeArea';
// 响应式数据
const searchKeyword = ref('');
const hasSearched = ref(false);
const searchResults = ref([]);
const allPlans = ref([]);
const searchInput = ref(null);
// 难度/频率/场景/人群 数字转文字映射(和列表页保持一致)
const navBarHeight = ref(0);
const difficultyText = { 1: '初阶', 2: '中阶', 3: '高阶' };
const frequencyText = { 0: '不限', 1: '1练/周', 2: '2练/周', 3: '3练/周', 4: '4练/周', 5: '5练/周', 6: '6练/周', 7: '7练/周' };
// 页面加载时获取全局数据
let isMpWeixin = false;
// #ifdef MP-WEIXIN
isMpWeixin = true;
// #endif
onMounted(() => {
// 自动聚焦输入框(uni-app 需要延迟)
setTimeout(() => {
if (searchInput.value && typeof searchInput.value.focus === 'function') {
searchInput.value.focus();
}
}, 300);
if (isMpWeixin) {
navBarHeight.value = getNavbarHeight();
}
});
// 返回
const goBack = () => {
uni.navigateBack();
};
// 执行接口搜索
const handleSearch = async () => {
const keyword = searchKeyword.value.trim();
hasSearched.value = true;
if (!keyword) {
searchResults.value = [];
return;
}
// 调用接口:根据名称模糊查询计划
try {
const res = await QueryPlanApi.searchPlansByName(keyword);
// 接口返回成功,处理数据
if (res.code === 0) {
// 映射接口字段到页面字段
searchResults.value = res.data.map(item => ({
id: item.id,
title: item.name, // 接口字段name → 页面字段title
cover: item.urlCover, // 接口字段urlCover → 页面字段cover
title: item.name,
cover: item.urlCover,
meta: `${difficultyText[item.difficultyLevel] || '初阶'} · ${frequencyText[item.frequencyPerWeek] || '1练/周'}`
}));
} else {
// 接口返回错误,提示用户
uni.showToast({ title: res.msg || '搜索失败', icon: 'none' });
searchResults.value = [];
}
} catch (err) {
// 网络/接口异常处理
uni.showToast({ title: '网络异常,请重试', icon: 'none' });
searchResults.value = [];
console.error('搜索接口调用失败:', err);
}
};
// 取消并返回
const handleCancel = () => {
uni.navigateBack();
};
// 点击跳转详情(根据你项目结构调整)
const goToDetail = (plan) => {
if (!plan?.id) {
uni.showToast({ title: '计划信息异常', icon: 'none' });
... ... @@ -122,126 +93,74 @@ const goToDetail = (plan) => {
<style lang="scss" scoped>
.search-page {
// 整个页面往下偏移,避免被顶部导航栏遮挡
padding-top: 160rpx;
height: 100vh;
background-color: #f5f5f5;
display: flex;
flex-direction: column;
}
/* 顶部搜索栏 */
.search-header {
display: flex;
align-items: center;
padding: 20rpx 20rpx 20rpx 30rpx;
background-color: #ffffff;
// box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08);
z-index: 999;
// 固定顶部搜索栏
position: fixed;
top: 160rpx;
left: 0;
right: 0;
}
.search-header {
padding: 14rpx 20rpx;
background-color: #ffffff;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
z-index: 999;
.search-input-wrapper {
flex: 1;
background-color: #f5f5f5;
border-radius: 12rpx;
padding: 0 20rpx;
height: 64rpx;
display: flex;
align-items: center;
gap: 10rpx;
}
position: sticky;
top: 0;
.search-icon {
flex-shrink: 0;
}
.search-input {
flex: 1;
height: 100%;
font-size: 28rpx;
color: #333;
background: transparent;
border: none;
}
.cancel-btn {
margin-left: 24rpx;
padding: 10rpx 0;
height: 64rpx;
display: flex;
align-items: center;
}
.cancel-text {
font-size: 28rpx;
color: #333;
/* 主题绿色,与你的 UI 一致 */
}
/* 搜索结果区域 */
.search-results {
margin-top: 100rpx; // 改成 100rpx,避免被搜索栏遮挡
flex: 1;
padding: 30rpx 20rpx;
overflow-y: auto;
}
.empty-hint {
text-align: center;
color: #999;
font-size: 28rpx;
margin-top: 200rpx;
}
.results-list {
display: flex;
flex-direction: column;
}
.result-item {
display: flex;
background: #ffffff;
border-radius: 16rpx;
margin-bottom: 20rpx;
overflow: hidden;
padding: 20rpx;
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.05);
}
.result-cover {
width: 160rpx;
height: 160rpx;
border-radius: 12rpx;
margin-right: 20rpx;
flex-shrink: 0;
}
.result-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.result-title {
font-size: 28rpx;
color: #333;
font-weight: bold;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
.result-meta {
font-size: 24rpx;
color: #999;
margin-top: 10rpx;
.search-results {
flex: 1;
padding: 30rpx 20rpx;
box-sizing: border-box;
.results-list {
display: flex;
flex-direction: column;
.result-item {
display: flex;
background: #ffffff;
border-radius: 16rpx;
margin-bottom: 20rpx;
overflow: hidden;
padding: 20rpx;
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.05);
.result-cover {
width: 160rpx;
height: 160rpx;
border-radius: 12rpx;
margin-right: 20rpx;
flex-shrink: 0;
}
.result-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
.result-title {
font-size: 28rpx;
color: #333;
font-weight: bold;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.result-meta {
font-size: 24rpx;
color: #999;
margin-top: 10rpx;
}
}
}
}
}
}
</style>
... ...
<template>
<view class="profile-page">
<view class="avatar-section">
<!-- #ifdef H5 -->
<button class="avatar-edit-btn" @click="handleH5ChooseAvatar">
<image :src="formData.avatar ||
'https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260526/默认头像_1779779926983.png'
" mode="aspectFill" class="avatar-image"></image>
</button>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<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>
</button>
<!-- #endif -->
</view>
<view class="info-list">
<view class="list-item" @click="gotoEditNickname">
<text class="item-title">昵称</text>
<view class="item-value">{{ formData.nickname || '未设置' }}</view>
<view class="item-value">{{ formData.nickname || '-' }}</view>
<uni-icons type="right" color="#999" size="20" class="arrow-icon"></uni-icons>
</view>
... ... @@ -204,9 +213,6 @@ const handleChooseAvatar = async (e) => {
try {
const res = await FileApi.uploadFile(avatarUrl);
console.log('res----------', res);
formData.value.avatar = res.data;
} catch (err) {
... ... @@ -215,6 +221,43 @@ const handleChooseAvatar = async (e) => {
}
};
// #ifdef H5
/** H5 端选择头像上传 */
const handleH5ChooseAvatar = () => {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: async (chooseRes) => {
const tempFilePath = chooseRes.tempFilePaths[0];
if (!tempFilePath) return;
// 文件大小校验
uni.getFileInfo({
filePath: tempFilePath,
success: async (fileInfo) => {
if (fileInfo.size / 1024 / 1024 > 5) {
uni.showToast({ title: '图片大小不能超过5MB', icon: 'none' });
return;
}
try {
const res = await FileApi.uploadFile(tempFilePath);
formData.value.avatar = res.data;
uni.showToast({ title: '上传成功', icon: 'success' });
} catch (err) {
console.error('[H5] 头像上传失败:', err);
uni.showToast({ title: '上传失败', icon: 'none' });
}
},
});
},
});
};
// #endif
/**
* 唤起并初始化昵称弹窗缓存
*/
... ... @@ -434,15 +477,17 @@ const saveProfile = async () => {
await UserApi.updateUser(payload);
// 异步刷新全局 Pinia 的 userInfo 用户属性,使全局头像和昵称视图无痛同步
if (userStore.getUserInfo) {
await userStore.getUserInfo();
}
// 同步表单数据,避免 navigateBack 时 onBackPress 误判为"未保存"
formData.value = JSON.parse(JSON.stringify(userStore.userInfo));
uni.showToast({ title: '保存成功', icon: 'success' });
setTimeout(() => {
uni.navigateBack({ delta: 1 });
uni.navigateBack();
}, 1200);
} catch (err) {
console.error('[API Error] 保存个人信息更新请求失败:', err);
... ...
... ... @@ -4,11 +4,7 @@ const { version } = packageInfo;
// 开发环境配置
export let baseUrl;
if (process.env.NODE_ENV === 'development') {
baseUrl = import.meta.env.SHOPRO_DEV_BASE_URL;
} else {
baseUrl = import.meta.env.SHOPRO_BASE_URL;
}
baseUrl = import.meta.env.SHOPRO_DEV_BASE_URL;
if (typeof baseUrl === 'undefined') {
console.error('请检查.env配置文件是否存在');
} else {
... ... @@ -20,7 +16,7 @@ export const staticUrl = import.meta.env.SHOPRO_STATIC_URL;
export const tenantId = import.meta.env.SHOPRO_TENANT_ID;
export const websocketPath = import.meta.env.SHOPRO_WEBSOCKET_PATH;
export const h5Url = import.meta.env.SHOPRO_H5_URL;
export const defaultAvatar = import.meta.env.DEFAULT_AVATAR;
export default {
baseUrl,
apiPath,
... ... @@ -28,4 +24,5 @@ export default {
tenantId,
websocketPath,
h5Url,
defaultAvatar,
};
... ...