Authored by Bad

feat: 优化训练日历与动作详情交互

... ... @@ -58,7 +58,8 @@
{
"path": "pages/xunji/xunji-wode-moban",
"style": {
"navigationBarTitleText": "我的模版"
"navigationBarTitleText": "我的模版",
"navigationStyle": "default"
}
},
{
... ... @@ -82,7 +83,7 @@
{
"path": "pages/xunji/xunji-dongzuo-xinzeng",
"style": {
"navigationBarTitleText": "",
"navigationBarTitleText": "新增自定义动作",
"navigationStyle": "default"
}
},
... ... @@ -180,13 +181,14 @@
{
"path": "pages/user/wode-changjian-wenti",
"style": {
"navigationBarTitleText": "我的常见问题"
"navigationBarTitleText": "常见问题",
"navigationStyle": "default"
}
},
{
"path": "pages/user/wode-guanyu-hongxing",
"style": {
"navigationBarTitleText": "我的关于鸿星"
"navigationBarTitleText": "关于我们"
}
},
{
... ...
... ... @@ -8,7 +8,7 @@
<view @click="show = false">
<up-icon name="close" color="#fff" size="20"></up-icon>
</view>
<text class="titleName">动作备注</text>
<text class="titleName">{{ title }}</text>
<view class="btn" @click="saveNoteContent">保存</view>
</view>
<view class="area-box">
... ... @@ -30,6 +30,10 @@ const tempNoteContent = ref(''); // 临时编辑的备注内容
const emit = defineEmits(['saveSuccess']);
const props = defineProps({
title: {
type: String,
default: '动作备注'
},
oldNote: {
type: String,
default: ''
... ...
... ... @@ -185,7 +185,7 @@
====================================== -->
<view class="icons">
<template v-if="!isEditing">
<view class="icon" @click.stop="emitToggleActive(index)" :class="{ active: record.isActive }">
<view class="icon" v-if="isTick" @click.stop="emitToggleActive(index)" :class="{ active: record.isActive }">
<up-icon name="checkmark" :color="record.isActive ? '#000' : '#919191'" size="20" bold></up-icon>
</view>
</template>
... ... @@ -210,6 +210,10 @@ const props = defineProps({
userWeight: {
type: Number,
default: 70
},
isTick: {
type: Boolean,
default: true
}
})
... ...
... ... @@ -51,7 +51,7 @@
<action-set-input v-for="(item, index) in recordList" :key="index"
:exercise-type="actionDetail.exerciseType" :record="item" :index="index" :is-editing="isEditing"
@openPicker="openPicker" @openQuickTimer="openQuickTimer" @toggleActive="toggleActive" @addRow="addRow"
@deleteRow="deleteRow" :user-weight="userWeight" @change-weight="userWeight = $event" />
@deleteRow="deleteRow" :user-weight="userWeight" @change-weight="userWeight = $event" :isTick="isTick"/>
</template>
<template v-else-if="type === 2">
... ... @@ -97,7 +97,7 @@
</view>
<super-set-input v-for="(sub, idx) in actionDetail.exercises" :key="sub.id"
:sub-exercise-type="sub.exerciseType" :set-index="setIndex" :sub-index="idx" :user-weight="userWeight"
:data="getSubActionData(setIndex, sub.id)" @open-time-picker="openSuperPicker(setIndex, sub.id)" />
:data="getSubActionData(setIndex, sub.id)" @open-time-picker="openSuperPicker(setIndex, sub.id)" :isTick="isTick"/>
</view>
</view>
</template>
... ... @@ -171,7 +171,8 @@ const props = defineProps({
unitIndex: Number,
actionDetail: { type: Object, default: () => ({}) },
isDailyTemplates: { type: Boolean, default: false },
isUnitActive: { type: Boolean, default: false }
isUnitActive: { type: Boolean, default: false },
isTick: { type: Boolean, default: true },
})
// ===================== Emits =====================
... ... @@ -400,19 +401,22 @@ function selectDifficulty(label, color) {
function toggleActive(index) {
const wasActive = recordList.value[index].isActive
// 在状态改变前获取当前组的休息秒数,用于取消时传递给父组件
const seconds = parseQuickTime(recordList.value[index].quickTimeDisplay)
recordList.value[index].isActive = !recordList.value[index].isActive
const hasAnyActive = recordList.value.some(item => item.isActive)
emit('updateUnitActive', hasAnyActive)
if (recordList.value[index].isActive) {
const seconds = parseQuickTime(recordList.value[index].quickTimeDisplay)
emit('startRestTimer', seconds, props.unitIndex)
} else if (wasActive) {
emit('cancelRestTimer', props.unitIndex)
emit('cancelRestTimer', seconds, props.unitIndex)
}
}
function toggleSuperSetActive(setIndex) {
const wasActive = superGroupSetData(setIndex).isActive
// 在状态改变前获取当前组的休息秒数,用于取消时传递给父组件
const restSeconds = parseQuickTime(superGroupSetData(setIndex).quickTimeDisplay)
props.actionDetail.exercises.forEach(sub => {
const list = superRecordMap.value[sub.id]
if (list?.[setIndex]) list[setIndex].isActive = !list[setIndex].isActive
... ... @@ -422,10 +426,9 @@ function toggleSuperSetActive(setIndex) {
)
emit('updateUnitActive', hasAnyActive)
if (!wasActive) {
const restSeconds = parseQuickTime(superGroupSetData(setIndex).quickTimeDisplay)
emit('startRestTimer', restSeconds, props.unitIndex)
} else {
emit('cancelRestTimer', props.unitIndex)
emit('cancelRestTimer', restSeconds, props.unitIndex)
}
}
... ...
... ... @@ -119,9 +119,6 @@
<view class="memo-box" v-if="type === 1">
<view class="memo-title">
<view class="section-title">步骤</view>
<view class="pencil-icon">
<up-icon name="edit-pen" color="#fedc1f" size="20"></up-icon>
</view>
</view>
<view class="steps-list">
<rich-text class="step-text" :nodes="actionDetail.stepDescription"></rich-text>
... ... @@ -158,9 +155,9 @@
<view class="section-title">训练部位</view>
<view class="muscle-card">
<image
src="https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260612/muscle_back_1781252170914.png"
src="https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260724/训练部位_1784878616826.jpg"
mode="widthFix" class="muscle-map" />
<view class="legend">
<!-- <view class="legend">
<view class="legend-item">
<view class="legend-dot primary"></view>
<text>主要部位</text>
... ... @@ -168,8 +165,8 @@
<view class="legend-item">
<view class="legend-dot secondary"></view>
<text>次要部位</text>
</view>
</view>
</view> -->
<!-- </view> -->
</view>
<view class="muscle-tags">
<view class="muscle-group" v-if="actionDetail.primaryMuscleNames?.length">
... ... @@ -768,12 +765,14 @@ defineExpose({ open });
background: #464646;
box-sizing: border-box;
border-radius: 16rpx;
padding: 24rpx;
.step-text {
font-size: 30rpx;
line-height: 1.6;
color: #bbb;
list-style: none;
white-space: pre-wrap;
word-break: break-all;
}
}
... ...
<template>
<!-- 添加自助训练底部弹窗 -->
<view v-if="visible" class="popup-overlay" @click="close">
<view class="popup-bottom" @click.stop>
<view class="popup-options">
<view class="popup-option" @click="handleAddFromPlan" v-if="planListCount > 0">
<view class=" icon-wrap">
<!-- <image src="/static/icons/plan.png" mode="aspectFit"></image> -->
<up-icon name="file-text-fill" color="#040000" size="24"></up-icon>
<up-popup
:show="visible"
mode="bottom"
round="24rpx"
bg-color="#1a1a1a"
:close-on-click-overlay="true"
:safe-area-inset-bottom="true"
@close="close"
>
<view class="add-train-popup">
<!-- 拖拽指示条 -->
<view class="popup-indicator" />
<!-- 菜单选项列表 -->
<view class="popup-menu">
<!-- 从训练计划中添加 -->
<view
v-if="planListCount > 0"
class="menu-item"
@click="handleAddFromPlan"
>
<view class="menu-icon">
<up-icon name="file-text-fill" color="#040000" size="24" />
</view>
<view class="text-wrap" @click="">
<text class="option-title">从训练计划中添加</text>
<text class="option-desc">从我的训练计划中添加</text>
<view class="menu-text">
<text class="menu-title">从训练计划中添加</text>
<text class="menu-desc">从我的训练计划中添加</text>
</view>
</view>
<view class="popup-option" @click="handleAddFromTemplate">
<view class="icon-wrap">
<up-icon name="grid-fill" color="#040000" size="24"></up-icon>
<!-- 使用训练模板 -->
<view class="menu-item" @click="handleAddFromTemplate">
<view class="menu-icon">
<up-icon name="grid-fill" color="#040000" size="24" />
</view>
<view class="text-wrap">
<text class="option-title">使用训练模板</text>
<text class="option-desc">官方训练模板和自定义模板</text>
<view class="menu-text">
<text class="menu-title">使用训练模板</text>
<text class="menu-desc">官方训练模板和自定义模板</text>
</view>
</view>
<view class="popup-option" @click="handleFreeTraining">
<view class="icon-wrap">
<up-icon name="clock-fill" color="#040000" size="24"></up-icon>
<!-- 自由训练 -->
<view class="menu-item" @click="handleFreeTraining">
<view class="menu-icon">
<up-icon name="clock-fill" color="#040000" size="24" />
</view>
<view class="text-wrap">
<text class="option-title">自由训练</text>
<text class="option-desc">新建空白训练</text>
<view class="menu-text">
<text class="menu-title">自由训练</text>
<text class="menu-desc">新建空白训练</text>
</view>
</view>
</view>
<!-- 关闭按钮 -->
<view class="close-btn" @click="close">
<text>x</text>
<up-icon name="close" color="#000" size="22" />
</view>
</view>
<!-- 我的计划添加弹窗,显示最新添加的计划的模板列表 传递-->
<WodeJihuaTianjiaTancuang v-model:visible="showPlanPopup" @get-plan-list-length="getPlanListLength" :isAdd="true"
:plan-id="lastPlanId" :is-my-plan="true" />
</view>
</up-popup>
<!-- 我的计划添加弹窗(放在外部避免弹窗嵌套) -->
<WodeJihuaTianjiaTancuang
v-model:visible="showPlanPopup"
@get-plan-list-length="getPlanListLength"
:is-add="true"
:plan-id="lastPlanId"
:is-my-plan="true"
/>
</template>
<script setup>
... ... @@ -50,50 +75,57 @@ import { ref, onMounted, computed } from 'vue'
import WodeJihuaTianjiaTancuang from '@/pages/xunji/components/wode-jihua-tianjia-tancuang.vue'
import QueryPlanApi from '@/sheep/api/plan/queryplan'
// 控制训练计划弹窗显示
const showPlanPopup = ref(false)
const planListCount = ref(0)
// ==================== Props & Emits ====================
const props = defineProps({
visible: {
type: Boolean,
default: false
}
default: false,
},
})
const emit = defineEmits(['update:visible'])
// 关闭弹窗
// ==================== 响应式状态 ====================
const showPlanPopup = ref(false)
const planListCount = ref(0)
const planList = ref([])
// ==================== 计算属性 ====================
/** 获取计划列表最后一条的 id,无数据返回 null */
const lastPlanId = computed(() => {
if (!Array.isArray(planList.value) || planList.value.length === 0) return null
return planList.value.at(-1)?.id ?? null
})
// ==================== 方法 ====================
/** 关闭弹窗 */
const close = () => {
emit('update:visible', false)
}
// 三个逻辑全部放在子组件内部
const handleAddFromPlan = () => {
// close() // 关闭当前弹窗
showPlanPopup.value = true
}
/** 获取子组件传递的计划列表长度 */
const getPlanListLength = (len) => {
planListCount.value = len
}
/** 从训练计划中添加:打开计划选择弹窗 */
const handleAddFromPlan = () => {
showPlanPopup.value = true
}
/** 使用训练模板:跳转模板选择页 */
const handleAddFromTemplate = () => {
uni.navigateTo({ url: '/pages4/pages/xunji/xunji-rili-tianjia-moban' })
close()
}
/** 自由训练:跳转动作练习页 */
const handleFreeTraining = () => {
uni.navigateTo({ url: '/pages4/pages/xunji/xunji-dongzuo-lianxi?isTraining=true' })
close()
}
// 获取我的计划列表的最后一个planid,传递到WodeJihuaTianjiaTancuang中
// 计划列表
const planList = ref([])
// 获取我的全部训练计划
/** 获取我的全部训练计划 */
const fetchMyPlanList = async () => {
try {
const res = await QueryPlanApi.getMyPlan()
... ... @@ -108,73 +140,44 @@ const fetchMyPlanList = async () => {
}
}
// 计算属性:拿到列表最后一条计划id,无数据返回null
const lastPlanId = computed(() => {
if (!Array.isArray(planList.value) || planList.value.length === 0) return null
const lastPlan = planList.value.at(-1)
return lastPlan?.id ?? null
})
// ==================== 生命周期 ====================
onMounted(() => {
fetchMyPlanList()
})
</script>
<style lang="scss" scoped>
/* 半透明黑色遮罩层 —— 内容靠底部!! */
.popup-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.8);
z-index: 9999;
display: flex;
flex-direction: column;
/* 核心:靠下 */
justify-content: flex-end;
align-items: center;
/* 距离底部一点距离,不贴着最底下 */
padding-bottom: 150rpx;
}
/* 弹窗主体容器 */
.popup-bottom {
width: 85%;
.add-train-popup {
display: flex;
flex-direction: column;
align-items: center;
gap: 70rpx;
margin-bottom: 100rpx;
}
padding: 0 30rpx 40rpx;
// 顶部拖拽指示条
.popup-indicator {
width: 72rpx;
height: 8rpx;
border-radius: 4rpx;
background: rgba(255, 255, 255, 0.3);
margin: 16rpx 0 32rpx;
}
/* 选项列表 */
.popup-options {
// 菜单选项列表
.popup-menu {
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 20rpx;
}
gap: 16rpx;
/* 单个选项项 */
.popup-option {
width: 100%;
// background: rgba(255, 255, 255, 0.1);
border-radius: 16rpx;
padding: 24rpx 30rpx;
.menu-item {
display: flex;
align-items: center;
gap: 24rpx;
}
padding: 24rpx 30rpx;
border-radius: 16rpx;
}
/* 黄色图标容器 */
.icon-wrap {
.menu-icon {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
... ... @@ -183,33 +186,29 @@ onMounted(() => {
align-items: center;
justify-content: center;
flex-shrink: 0;
image {
width: 40rpx;
height: 40rpx;
}
}
/* 文字区域 */
.text-wrap {
.menu-text {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.option-title {
.menu-title {
font-size: 32rpx;
color: #fff;
font-weight: 500;
}
}
.option-desc {
.menu-desc {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.7);
}
}
}
}
/* 底部关闭按钮 */
.close-btn {
// 底部关闭按钮
.close-btn {
margin-top: 60rpx;
width: 88rpx;
height: 88rpx;
border-radius: 50%;
... ... @@ -217,11 +216,6 @@ onMounted(() => {
display: flex;
align-items: center;
justify-content: center;
text {
font-size: 40rpx;
color: #000;
line-height: 1;
}
}
</style>
... ...
... ... @@ -4,12 +4,12 @@
<!-- 顶部日期栏 -->
<view class="header-bar">
<view class="close-btn" @click="closeCellPopup">
<uni-icons type="down" size="24" color="#333"></uni-icons>
<u-icon name="arrow-down" size="15" color="#333"></u-icon>
</view>
<view class="date-wrapper">
<text class="date-text">{{ displayDate }}</text>
</view>
<button class="date-note-btn" @click.stop="handleDateNote">日期备注</button>
<button class="date-note-btn" @click="handleDateNote">日期备注</button>
<button class="go-train-btn" @click.stop="startTraining">
<text class="go-train-text">去训练</text>
<text class="go-train-tag">GO</text>
... ... @@ -21,10 +21,8 @@
<view class="noteContent" v-if="noteList.length > 0">
<text class="noteTitle">日程备注:</text>
<view class="noteTags">
<view class="noteTag" v-for="(item, idx) in noteList" :key="item.id"
:style="{ backgroundColor: item.backgroundColor }" @click="handleEditNote(item)">
{{ item.content }}
</view>
<u-tag class="noteTag" v-for="(item, idx) in noteList" :key="item.id" :text="item.content"
:bg-color="item.backgroundColor" color="#fff" shape="circle" @click="handleEditNote(item)" />
</view>
</view>
... ... @@ -54,13 +52,10 @@
{{ currentPlan?.totalWeight }}kg</text>
</view>
<view class="plan-header-btns">
<button class="plan-btn more-btn" @click.stop="handlePlanMore">更多</button>
<button class="plan-btn copy-btn" @click.stop="openCopyCalendarPopup(currentPlan?.templateId)">
复制到
</button>
<button class="plan-btn go-train-btn-small" @click.stop="handleTrainAgain">
<button class="plan-btn more-btn" @click="handlePlanMore">更多</button>
<button class="plan-btn copy-btn" @click="openCopyCalendarPopup(currentPlan?.templateId)">复制到</button>
<button class="plan-btn go-train-btn-small" @click="handleTrainAgain">
<text class="go-train-text-sm">今日再练</text>
<!-- <text class="go-train-tag-sm">GO</text> -->
</button>
</view>
</view>
... ... @@ -93,25 +88,8 @@
<view class="set-item" v-for="(set, setIdx) in unit.exercises[0]?.sets || []" :key="setIdx">
<!-- 新增:把左边所有内容包起来 -->
<view class="set-left" :style="{ color: set.isCompleted === 1 ? '#333' : '#a2a2a2' }">
<!-- <text class="indexData">{{ setIdx + 1 }}</text> -->
<text class="set-content" v-if="unit.exercises[0].exerciseType === 0">
{{ set.weight }}kg x {{ set.reps }}次
</text>
<text class="set-content" v-if="unit.exercises[0].exerciseType === 1">
{{ formatSecondsToHms(set.duration) }} x {{ set.distance }}km
</text>
<text class="set-content" v-if="unit.exercises[0].exerciseType === 2">
{{ set.reps }}次
</text>
<text class="set-content" v-if="unit.exercises[0].exerciseType === 3">
{{ formatSecondsToHms(set.duration) }}
</text>
<text class="set-content" v-if="[4, 5].includes(unit.exercises[0].exerciseType)">
{{ set.weight }}kg x {{ set.duration }}s x {{ set.restTime }}
</text>
<text class="set-content" v-if="unit.exercises[0].exerciseType === 6">
{{ set.reps }}组 x {{ set.duration }}秒 x {{ set.restTime || 0 }}秒/组休息
</text>
<text class="set-content">{{ getSetDisplayText(set, unit.exercises[0].exerciseType)
}}</text>
</view>
<view class="set-right">
<text class="rest-time" v-if="set.restTime && unit.exercises[0].exerciseType != 6">
... ... @@ -127,7 +105,6 @@
<!-- 右侧:修改按钮 + 完成对勾 -->
<view class="action-right" @click="openXiuGai(unit, unitIdx)">
<button class="modify-btn">修改</button>
<!-- <view class="check-icon">✓</view> -->
</view>
</view>
<!-- 情况2:超级组 unitType=2 -->
... ... @@ -168,28 +145,8 @@
<view class="set-content"
:style="{ color: ex.sets[idx].isCompleted === 1 ? '#333' : '#a2a2a2' }">
<!-- 适配不同动作类型的显示文本 -->
<text v-if="ex.exerciseType === 0" class="detai-data">
{{ ex.sets[idx].weight }}kg x {{ ex.sets[idx].reps }}次
</text>
<text v-if="ex.exerciseType === 1" class="detai-data">
{{ formatSecondsToHms(ex.sets[idx].duration) }} x {{
ex.sets[idx].distance
}}km
</text>
<text v-if="ex.exerciseType === 2" class="detai-data">
{{ ex.sets[idx].reps }}次
</text>
<text v-if="ex.exerciseType === 3" class="detai-data">
{{ formatSecondsToHms(ex.sets[idx].duration) }}
</text>
<text v-if="[4, 5].includes(ex.exerciseType)" class="detai-data">
{{ ex.sets[idx].weight }}kg x {{ ex.sets[idx].reps }}次
</text>
<text v-if="ex.exerciseType === 6" class="detai-data">
{{ ex.sets[idx].reps }}次 x {{ ex.sets[idx].duration }}秒 x {{ ex.sets[idx].restTime
}}秒/组休息
</text>
<text class="detai-data">{{ getSuperSetDisplayText(ex.sets[idx], ex.exerciseType)
}}</text>
<view class="rest-time" v-if="ex.sets[idx]?.restTime && ex.exerciseType !== 6"
:style="{ color: ex.sets[idx].isCompleted === 1 ? '#999 !important' : '#a2a2a2 !important' }">
{{ ex.sets[idx].restTime }}s
... ... @@ -212,21 +169,13 @@
<!-- 空状态区域 -->
<view v-else class="empty-section">
<image class="empty-img"
src="https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260316/order-empty_1773628059920.png"
src="https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260724/空状态自助_1784875704380.png"
mode="aspectFit">
</image>
<text class="empty-tip">今天没有安排</text>
<button class="add-train-btn" @click="openAddTrainPopup">+ 添加自助训练</button>
<!-- 底部课程类型卡片 -->
<!-- <view class="course-section">
<view class="course-item" v-for="(item, index) in courseList" :key="index"
@click="handleCourseClick(item.type)">
<image class="course-icon" :src="item.icon" mode="aspectFill"></image>
<text class="course-title">{{ item.title }}</text>
<text class="course-desc">{{ item.desc }}</text>
</view>
</view> -->
</view>
<AddTrainPopup v-model:visible="showAddTrainPopup" @successAddTrain="handleAddTrain" />
... ... @@ -298,59 +247,44 @@ import CalendarColorPicker from '@/pages/xunji/components/rili-components/Calend
import { useTrainingStore } from '@/sheep/store/trainingStore'
import MeiRiMoBanXiuGai from '../rili-components/meiri-moban-xiugai.vue'
// ===== Props & Emits =====
const props = defineProps({
date: { type: String, default: '' },
// noteList: {
// type: Array,
// default: () => []
// }
})
const show = ref(false);
const emit = defineEmits(['close', 'refreshCalendar']);
const noteList = ref([]);
// ===== 响应式状态 =====
const show = ref(false);
const showAddTrainPopup = ref(false);
const selectedDate = ref('');
const displayDate = ref('');
const resdailyData = ref([]);
// const noteList = ref([])
const currentPlanIndex = ref(0);
const noteList = ref([]);
const noteHistoryList = ref([]);
// 弹窗相关
const isMoveMode = ref(false);
const isCopyMode = ref(false);
const showRiqibeizhu = ref(false)
const moreShow = ref(false)
const showRiqibeizhu = ref(false);
const moreShow = ref(false);
const showColorPopup = ref(false);
const calendarPopupRef = ref(null);
const currentEditId = ref(null); // 新增这一行
const noteHistoryList = ref([]);
const currentEditId = ref(null);
const selectedPlanId = ref(null);
const showColorPopup = ref(false)
const selectedPlanId = ref(null)
// Store / 子组件引用
const trainingStore = useTrainingStore();
const meirimobanRef = ref(null);
const trainingStore = useTrainingStore()
// 修改单元缓存
const editUnitInfo = ref({
unit: null,
unitIndex: null,
dailyTemplate: null,
});
const meirimobanRef = ref(null)
// 没有训练模板时推荐课程
const courseList = ref([
{
type: 'group',
title: '团课',
desc: '大家一起练',
icon: 'https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260316/group-course.png'
},
{
type: 'private',
title: '私教',
desc: '1对1专人指导',
icon: 'https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260316/private-course.png'
},
{
type: 'smallClass',
title: '小班课',
desc: '28天极速瘦身',
icon: 'https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260316/small-class.png'
}
]);
// 打开弹窗
const openPopup = async () => {
... ... @@ -370,14 +304,7 @@ defineExpose({
closeCellPopup
});
// 修改
// 定义缓存变量,传给弹窗
const editUnitInfo = ref({
unit: null,
unitIndex: null,
dailyTemplate: null // currentPlan(整个每日模板对象,含id、templateId)
})
// 打开弹窗修改单个unit
// ===== 弹窗:打开修改单个 unit =====
const openXiuGai = (unit, idx) => {
editUnitInfo.value = {
unit: JSON.parse(JSON.stringify(unit)), // 深拷贝防止原数据被实时篡改
... ... @@ -410,12 +337,14 @@ const handleAddTrain = async () => {
// 更新主页面
emit('refreshCalendar');
}
// ===== 计算属性 =====
// 当前正在显示的训记
const currentPlan = computed(() => {
if (!resdailyData.value.length) return null;
return resdailyData.value[currentPlanIndex.value] || null;
});
// ===== API:加载每日模板数据 =====
const loaddailytemplate = async () => {
if (!selectedDate.value) return;
console.log('【子组件】开始加载数据...');
... ... @@ -458,7 +387,7 @@ const switchPlan = (index) => {
console.log('【切换标签后 - currentPlan】', currentPlan.value);
};
// 更多弹窗控制
// ===== 更多弹窗:操作项 =====
const handlePlanMore = () => {
if (!currentPlan.value) { // 加这个
return
... ... @@ -472,6 +401,13 @@ const closeMorePopup = () => {
// 一键打勾(完成训练)
const handleCompleteCheck = async () => {
// 日期校验:只能对今天及以前的日期一键打勾
const today = dayjs().format('YYYY-MM-DD');
if (selectedDate.value > today) {
uni.showToast({ title: '一键打勾只能用于今天及以前的日期', icon: 'none' });
return;
}
if (!currentPlan.value?.id) {
uni.showToast({ title: '未找到训练ID', icon: 'none' })
return
... ... @@ -494,13 +430,17 @@ const handleCompleteCheck = async () => {
}
}
// 保存到我的模板
// const saveToMyTemplate = (dailyTemplate) => {
// console.log('dailyTemplate', dailyTemplate);
// }
// ===== 保存训练为个人模板 =====
const openSaveTemplateDialog = async (item) => {
// 关闭所有可能遮挡的弹窗
moreShow.value = false;
showColorPopup.value = false;
showAddTrainPopup.value = false;
showRiqibeizhu.value = false;
show.value = false
// 等待 DOM 更新后再弹出模板名称输入框,确保弹窗完全关闭
await nextTick();
// uni弹窗输入框:只填模板名称
uni.showModal({
title: '确认保存为你的训练模板?',
... ... @@ -520,7 +460,6 @@ const openSaveTemplateDialog = async (item) => {
// 发起创建接口
console.log('reqData', reqData);
await submitCreateTemplate(reqData)
moreShow.value = false;
}
}
})
... ... @@ -580,25 +519,42 @@ const closeAddTrainPopup = () => {
showAddTrainPopup.value = false;
};
// 弹窗选项点击事件
// const handleAddFromPlan = () => {
// uni.showToast({ title: '从训练计划添加', icon: 'none' });
// closeAddTrainPopup();
// };
// const handleAddFromTemplate = () => {
// uni.navigateTo({ url: '/pages4/pages/xunji/xunji-rili-tianjia-moban' });
// closeAddTrainPopup();
// };
// const handleFreeTraining = () => {
// uni.navigateTo({ url: '/pages4/pages/xunji/xunji-dongzuo-lianxi' });
// closeAddTrainPopup();
// };
// ===== 工具函数 =====
const formatSecondsToHms = (seconds) => {
if (!seconds || isNaN(seconds)) return '00:00:00';
return dayjs.duration(parseInt(seconds, 10), 'seconds').format('HH:mm:ss');
}
// ===== 工具函数:根据动作类型生成组次显示文本 =====
const getSetDisplayText = (set, exerciseType) => {
if (!set) return '';
switch (exerciseType) {
case 0: return `${set.weight || 0}kg x ${set.reps || 0}次`;
case 1: return `${formatSecondsToHms(set.duration)} x ${set.distance || 0}km`;
case 2: return `${set.reps || 0}次`;
case 3: return formatSecondsToHms(set.duration);
case 4:
case 5: return `${set.weight || 0}kg x ${set.duration || 0}s x ${set.restTime || 0}`;
case 6: return `${set.reps || 0}组 x ${set.duration || 0}秒 x ${set.restTime || 0}秒/组休息`;
default: return '';
}
};
// 超级组中的动作显示(与普通动作在 type 4/5 上的展示略有差异)
const getSuperSetDisplayText = (set, exerciseType) => {
if (!set) return '';
switch (exerciseType) {
case 0: return `${set.weight || 0}kg x ${set.reps || 0}次`;
case 1: return `${formatSecondsToHms(set.duration)} x ${set.distance || 0}km`;
case 2: return `${set.reps || 0}次`;
case 3: return formatSecondsToHms(set.duration);
case 4:
case 5: return `${set.weight || 0}kg x ${set.reps || 0}次`;
case 6: return `${set.reps || 0}次 x ${set.duration || 0}秒 x ${set.restTime || 0}秒/组休息`;
default: return '';
}
};
// 返回按钮逻辑
const goBack = () => {
uni.navigateBack({
... ... @@ -609,19 +565,15 @@ const goBack = () => {
});
};
// 日期格式化(兼容iOS
// 日期格式化(使用 dayjs 处理,兼容性更好
const formatDateWithWeek = (dateStr) => {
if (!dateStr) return '';
const d = dayjs(dateStr);
const weekMap = ['日', '一', '二', '三', '四', '五', '六'];
const iosSafeDate = new Date(dateStr.replace(/-/g, '/'));
const year = iosSafeDate.getFullYear();
const month = String(iosSafeDate.getMonth() + 1).padStart(2, '0');
const day = String(iosSafeDate.getDate()).padStart(2, '0');
const week = weekMap[iosSafeDate.getDay()];
return `${year}/${month}/${day} 周${week}`;
return `${d.format('YYYY/MM/DD')} 周${weekMap[d.day()]}`;
};
// 打开日期备注组件
// ===== 日期备注相关 =====
const handleDateNote = () => {
if (noteList.value.length >= 5) {
uni.showToast({
... ... @@ -648,7 +600,7 @@ const handleCloseNotePopup = () => {
currentEditId.value = null;
};
// 打开「添加到日历」弹窗
// ===== 复制到 / 移动到 =====
const openCalendarPopup = (templateId) => {
console.log('点击了添加到日历按钮', templateId); // 新增日志
console.log('子组件实例', calendarPopupRef.value); // 新增日志
... ... @@ -695,6 +647,14 @@ const handleDeleteTemplate = async () => {
uni.showToast({ title: '未找到模板ID', icon: 'none' });
return;
}
// 关闭所有可能遮挡的弹窗
moreShow.value = false;
showColorPopup.value = false;
showAddTrainPopup.value = false;
showRiqibeizhu.value = false;
show.value = false
// 等待 DOM 更新后再弹出模板名称输入框,确保弹窗完全关闭
await nextTick();
// 弹出确认框
uni.showModal({
title: '确认删除',
... ... @@ -820,7 +780,7 @@ const calendarColorPickerSuccess = () => {
emit('refreshCalendar')
}
// 去训练
// ===== 去训练 / 每日模板编辑 =====
const startTraining = () => {
if (trainingStore.isTraining) {
... ... @@ -861,6 +821,7 @@ const templateEdit = (plan) => {
console.log('开始进入编辑模板页面,模板ID:', plan.templateId, '每日模板ID:', plan.id);
};
// ===== 监听 & 生命周期 =====
// 监听父组件传进来的 date 变化
watch(
() => props.date,
... ... @@ -888,6 +849,19 @@ onMounted(() => {
</script>
<style lang="scss" scoped>
// ===== 公共变量 =====
$color-primary: #333;
$color-accent: #ffc107;
$color-bg: #f2f3f5;
$color-white: #fff;
$color-danger: #ff4d4f;
$color-muted: #a2a2a2;
$color-card-bg: #f9f9f9;
$radius-sm: 8rpx;
$radius-md: 12rpx;
$radius-lg: 16rpx;
$radius-round: 40rpx;
/* 全局容器 */
.container {
display: flex;
... ... @@ -941,9 +915,14 @@ onMounted(() => {
.date-text {
font-size: 28rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.date-note-btn {
display: inline-flex;
align-items: center;
font-size: 28rpx;
color: #333;
background: #e7e7e9;
... ... @@ -1066,11 +1045,11 @@ onMounted(() => {
}
.go-train-btn-small {
// background: #ffc107;
color: #000;
display: flex;
align-items: center;
padding: 12rpx 20rpx;
background: inherit;
}
.go-train-text-sm {
... ... @@ -1286,34 +1265,34 @@ onMounted(() => {
flex-direction: column;
align-items: center;
justify-content: center;
// width: 500rpx;
height: 500rpx;
margin-top: 100rpx;
padding: 20rpx 20rpx 60rpx;
padding: 80rpx 40rpx 120rpx;
box-sizing: border-box;
}
.empty-img {
width: 280rpx;
height: 280rpx;
margin-bottom: 30rpx;
width: 360rpx;
height: 360rpx;
margin-bottom: 40rpx;
transform: scale(3);
}
.empty-tip {
font-size: 32rpx;
color: #666;
margin-bottom: 60rpx;
font-weight: 500;
margin-bottom: 80rpx;
letter-spacing: 2rpx;
}
.add-train-btn {
font-size: 30rpx;
color: #000;
background: #ffc107;
border-radius: 40rpx;
padding: 20rpx 80rpx;
background: $color-accent;
border-radius: $radius-round;
padding: 24rpx 80rpx;
border: none;
margin-bottom: 60rpx;
font-weight: 500;
box-shadow: 0 4rpx 12rpx rgba(255, 193, 7, 0.3);
}
/* 底部课程类型区域 */
... ... @@ -1485,6 +1464,7 @@ button::after {
border: none;
}
/* 训记切换标签 */
.plan-tabs {
white-space: nowrap;
... ... @@ -1512,93 +1492,87 @@ button::after {
color: #fff;
}
/* ========== 超级组样式(已修复 无重复 无嵌套错误)========== */
/* ========== 超级组样式 ========== */
.superset-wrapper {
margin-bottom: 20rpx;
background: #fff;
border-radius: 12rpx;
background: $color-white;
border-radius: $radius-md;
overflow: hidden;
}
.superset-wrapper .super-head {
.super-head {
display: flex;
align-items: center;
padding: 24rpx 20rpx;
position: relative;
gap: 16rpx;
}
}
.superset-wrapper .super-image {
.super-image {
width: 75rpx;
height: 75rpx;
flex-shrink: 0;
}
}
.superset-wrapper .super-title-wrap {
.super-title-wrap {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
}
}
.superset-wrapper .super-title {
.super-title {
font-size: 32rpx;
color: #111;
font-weight: 500;
}
}
.superset-wrapper .super-totalWeight {
.super-totalWeight {
font-size: 24rpx;
color: #666;
}
}
.superset-wrapper .super-actionName {
.super-actionName {
padding: 0 20rpx 20rpx 70rpx;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.superset-wrapper .name-item {
.action-name {
font-size: 28rpx;
color: $color-primary;
}
}
.name-item {
display: flex;
align-items: center;
gap: 12rpx;
}
}
.superset-wrapper .letter {
.letter {
font-size: 28rpx;
font-weight: bold;
color: #333;
}
.superset-wrapper .super-actionName .action-name {
font-size: 28rpx;
color: #333;
}
color: $color-primary;
}
.superset-wrapper .super-set-list {
.super-set-list {
padding: 0 20rpx 20rpx 20rpx;
display: flex;
flex-direction: column;
gap: 12rpx;
}
}
.superset-wrapper .super-set-item {
.super-set-item {
display: flex;
// flex-direction: column; // 新增:垂直排列
align-items: stretch; // 让每个动作占满宽度
gap: 12rpx; // 垂直间距(可根据需要微调)
align-items: stretch;
gap: 12rpx;
padding: 16rpx;
background: #f9f9f9;
border-radius: 12rpx;
}
// 为了让序号独立占一行
.superset-wrapper .super-set-item {
margin-bottom: 8rpx;
}
}
.superset-wrapper .set-number {
.set-number {
font-size: 26rpx;
background: #e4e4e4;
color: #282828;
... ... @@ -1608,27 +1582,27 @@ button::after {
flex-shrink: 0; // /* 不被父容器挤压变形,保持固定大小 */
border-radius: 50%;
// line-height: 60rpx;
}
}
.superset-wrapper .set-column {
.set-column {
display: flex;
flex-direction: column;
}
}
.superset-wrapper .action-in-set {
.action-in-set {
// display: flex;
// flex-direction: column;
width: 100%;
display: flex;
align-items: center;
margin-bottom: 8rpx;
}
}
.superset-wrapper .action-in-set:last-child {
.action-in-set:last-child {
margin-bottom: 0;
}
}
.superset-wrapper .action-letter {
.action-letter {
display: inline-block;
font-size: 26rpx;
color: #666;
... ... @@ -1636,28 +1610,27 @@ button::after {
white-space: nowrap;
margin-right: 12rpx;
flex-shrink: 0;
}
}
.superset-wrapper .set-content {
.set-content {
font-size: 26rpx;
color: inherit;
flex: 1;
}
}
.superset-wrapper .rest-time {
.rest-time {
font-size: 24rpx;
// color: inherit;
margin-left: 10rpx;
flex-shrink: 0;
}
}
.superset-wrapper .check {
.check {
font-size: 26rpx;
color: #333;
margin-left: 275rpx !important;
/* 这里控制和左边内容的距离,数值你可以自己调 */
flex-shrink: 0;
/* 防止被压缩 */
}
}
.superset-title {
... ... @@ -1673,16 +1646,14 @@ button::after {
.noteContent {
display: flex;
align-items: flex-start;
/* 改成顶部对齐,而不是居中 */
gap: 12rpx;
padding: 20rpx;
background: #fff;
background: $color-white;
}
.noteTitle {
font-size: 26rpx;
color: #666;
/* 关键:固定宽度,保证标签容器的左边距稳定 */
flex-shrink: 0;
width: 150rpx;
}
... ... @@ -1694,11 +1665,10 @@ button::after {
gap: 12rpx;
}
.noteTag {
// u-tag 微调
:deep(.noteTag) {
width: auto !important;
font-size: 24rpx;
color: #fff;
padding: 6rpx 14rpx;
border-radius: 10rpx;
line-height: 1.2;
}
</style>
\ No newline at end of file
... ...
... ... @@ -12,7 +12,7 @@
<scroll-view class="pop-content" scroll-y>
<dongzuo v-if="isRenderDongzuo" :id="currUnit.unitId" :type="currUnit.unitType" :actionDetail="convertAction"
:unitIndex="currUnitIndex" ref="dongzuoSingleRef" @deleteAction.prevent="handleDeleteAction"
@replace-action.prevent="handleReplaceAction" :is-daily-templates="true" />
@replace-action.prevent="handleReplaceAction" :is-daily-templates="true" :is-tick="false"/>
</scroll-view>
</view>
... ...
<template>
<!-- 自定义底部弹窗 -->
<view v-if="showPopup" class="custom-mask" @click="handleMaskClick">
<view class="custom-popup" @click.stop>
<!-- 底部弹窗:训练日历 -->
<up-popup
:show="showPopup"
mode="bottom"
round="24rpx"
:safeAreaInsetBottom="true"
@close="close"
>
<view class="calendar-popup-content">
<!-- 标题 + 月份导航 -->
<view class="popup-header">
<text class="title">训练日历</text>
<view class="month-nav">
<u-icon name="arrow-left" size="24" @click="prevMonth"></u-icon>
<text class="month-text">{{ currentYearMonth }}</text>
<u-icon name="arrow-right" size="24" @click="nextMonth"></u-icon>
<text class="header-title">训练日历</text>
<view class="month-navigator">
<u-icon name="arrow-left" size="24" @click="prevMonth" />
<text class="month-label">{{ currentYearMonth }}</text>
<u-icon name="arrow-right" size="24" @click="nextMonth" />
</view>
</view>
<!-- 自定义日历主体 -->
<view class="calendar-container">
<!-- 日历主体 -->
<view class="calendar-body">
<!-- 星期行 -->
<view class="week-row">
<text v-for="week in weekDays" :key="week" class="week-day">{{ week }}</text>
<view class="week-header">
<text
v-for="(day, idx) in WEEK_DAYS"
:key="idx"
class="week-item"
:class="{ weekend: idx === 0 || idx === 6 }"
>
{{ day }}
</text>
</view>
<!-- 日期格子 -->
<view class="days-grid">
<view v-for="(day, idx) in calendarDays" :key="idx" class="day-cell" :class="{
'other-month': !day.isCurrentMonth,
'selected': isDateSelected(day.fullDate),
'disabled': day.fullDate < minDateStr
}" @click="toggleDate(day)">
<text class="day-number">{{ day.day }}</text>
<!-- 日期网格 -->
<view class="date-grid">
<view
v-for="(item, idx) in calendarDays"
:key="idx"
class="date-cell"
:class="getDateCellClass(item)"
@click="toggleDate(item)"
>
<text class="date-text">{{ item.day }}</text>
</view>
</view>
</view>
<!-- 底部确认按钮 -->
<!-- 底部操作 -->
<view class="popup-footer">
<u-button type="primary" size="large" :disabled="selectedDates.length === 0" @click="confirmAdd">
<view class="selected-hint" v-if="selectedDates.length">
已选 {{ selectedDates.length }} 天
</view>
<u-button
type="primary"
size="large"
:disabled="!selectedDates.length"
@click="confirmAdd"
>
添加到以上日程
</u-button>
</view>
</view>
</view>
</up-popup>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import { ref, computed } from 'vue';
import dayjs from 'dayjs';
import QueryPlanApi from '@/sheep/api/plan/queryplan';
import dailytemplateApi from '@/sheep/api/Template/Dailytemplate';
// ==================== 常量 ====================
const DATE_FORMAT = 'YYYY-MM-DD';
const WEEK_DAYS = ['日', '一', '二', '三', '四', '五', '六'];
const TOTAL_GRID_CELLS = 42; // 6 行 × 7 列
// ==================== Props & Emits ====================
const props = defineProps({
planId: { type: [String, Number], required: false },
templateId: { type: [String, Number], required: true, default: 0 },
isMove: {
type: Boolean,
default: false
},
isMove: { type: Boolean, default: false },
isCopy: { type: Boolean, default: false },
dailyTemplateId: { type: Number, default: 0 }
dailyTemplateId: { type: Number, default: 0 },
});
const emit = defineEmits(['success', 'clearCopyFlag']);
// 弹窗显示控制
// ==================== 工具函数 ====================
/** 格式化 dayjs 实例为 YYYY-MM-DD 字符串 */
const fmtDate = (d) => d.format(DATE_FORMAT);
/** 获取当天的 YYYY-MM-DD */
const getTodayStr = () => fmtDate(dayjs());
// ==================== 响应式状态 ====================
const showPopup = ref(false);
// 当前显示的日期(年、月)
const currentYear = ref(new Date().getFullYear());
const currentMonth = ref(new Date().getMonth() + 1); // 1-12
// 当前视图的年月(用 dayjs 驱动)
const viewDate = ref(dayjs());
// 已选择的日期列表(字符串数组,格式 YYYY-MM-DD)
// 已选日期集合
const selectedDates = ref([]);
// 最小可选日期(今天)
const today = new Date();
const minDateStr = `${today.getFullYear()}-${(today.getMonth() + 1).toString().padStart(2, '0')}-${today.getDate().toString().padStart(2, '0')}`;
const minDateStr = getTodayStr();
// 星期标题
const weekDays = ['日', '一', '二', '三', '四', '五', '六'];
// ==================== 计算属性 ====================
/** 当前显示的年-月文本 */
const currentYearMonth = computed(() => viewDate.value.format('YYYY年MM月'));
// 当前年月显示文本
const currentYearMonth = computed(() => `${currentYear.value}年${currentMonth.value.toString().padStart(2, '0')}月`);
// 生成当前月份的日历数据(包含上个月和下个月的占位)
/** 生成日历网格数据 */
const calendarDays = computed(() => {
const year = currentYear.value;
const month = currentMonth.value;
const firstDayOfMonth = new Date(year, month - 1, 1);
const startWeekday = firstDayOfMonth.getDay(); // 0=周日
const daysInMonth = new Date(year, month, 0).getDate();
// 获取上个月的最后几天
const prevMonthDate = new Date(year, month - 1, 0);
const daysInPrevMonth = prevMonthDate.getDate();
const vd = viewDate.value;
const year = vd.year();
const month = vd.month(); // 0-11
// 当月第一天 & 当月天数
const firstDay = dayjs(new Date(year, month, 1));
const startWeekday = firstDay.day(); // 0=周日
const daysInMonth = firstDay.daysInMonth();
// 上个月补位
const prevMonthDays = [];
const prevLastDay = firstDay.subtract(1, 'day');
const prevDaysInMonth = prevLastDay.date();
for (let i = startWeekday - 1; i >= 0; i--) {
const dayNum = daysInPrevMonth - i;
const fullDate = `${prevMonthDate.getFullYear()}-${(prevMonthDate.getMonth() + 1).toString().padStart(2, '0')}-${dayNum.toString().padStart(2, '0')}`;
prevMonthDays.push({
day: dayNum,
fullDate: fullDate,
isCurrentMonth: false,
});
const d = dayjs(new Date(year, month - 1, prevDaysInMonth - i));
prevMonthDays.push({ day: d.date(), fullDate: fmtDate(d), isCurrentMonth: false });
}
// 当月天数
const currentMonthDays = [];
// 当月
const curMonthDays = [];
for (let i = 1; i <= daysInMonth; i++) {
const fullDate = `${year}-${month.toString().padStart(2, '0')}-${i.toString().padStart(2, '0')}`;
currentMonthDays.push({
day: i,
fullDate: fullDate,
isCurrentMonth: true,
});
const d = dayjs(new Date(year, month, i));
curMonthDays.push({ day: i, fullDate: fmtDate(d), isCurrentMonth: true });
}
// 下个月补齐(总共42格,6行)
const totalCells = 42;
const remaining = totalCells - (prevMonthDays.length + currentMonthDays.length);
// 下个月补位
const nextMonthDays = [];
const remaining = TOTAL_GRID_CELLS - prevMonthDays.length - curMonthDays.length;
for (let i = 1; i <= remaining; i++) {
const nextDate = new Date(year, month, i);
const fullDate = `${nextDate.getFullYear()}-${(nextDate.getMonth() + 1).toString().padStart(2, '0')}-${nextDate.getDate().toString().padStart(2, '0')}`;
nextMonthDays.push({
day: i,
fullDate: fullDate,
isCurrentMonth: false,
});
const d = dayjs(new Date(year, month + 1, i));
nextMonthDays.push({ day: i, fullDate: fmtDate(d), isCurrentMonth: false });
}
return [...prevMonthDays, ...currentMonthDays, ...nextMonthDays];
return [...prevMonthDays, ...curMonthDays, ...nextMonthDays];
});
// 检查某天是否被选中
const isDateSelected = (fullDate) => {
return selectedDates.value.includes(fullDate);
};
// ==================== 方法 ====================
/** 判断日期是否被选中 */
const isDateSelected = (fullDate) => selectedDates.value.includes(fullDate);
// 切换日期选中状态(仅允许选择今天及之后)
const toggleDate = (day) => {
if (day.fullDate < minDateStr) {
/** 判断是否为今天 */
const isToday = (fullDate) => fullDate === getTodayStr();
/** 获取日期格子的样式类 */
const getDateCellClass = (item) => ({
'other-month': !item.isCurrentMonth,
selected: isDateSelected(item.fullDate),
disabled: item.fullDate < minDateStr,
today: isToday(item.fullDate),
});
/** 切换日期选中状态 */
const toggleDate = (item) => {
if (item.fullDate < minDateStr) {
uni.showToast({ title: '不能选择过去的日期', icon: 'none' });
return;
}
// 移动模式下,只能选择一个日期
// 移动模式:单选
if (props.isMove) {
selectedDates.value = [day.fullDate]; // 直接覆盖,永远只有1个
selectedDates.value = [item.fullDate];
return;
}
const index = selectedDates.value.indexOf(day.fullDate);
if (index === -1) {
selectedDates.value.push(day.fullDate);
// 普通模式:多选切换
const idx = selectedDates.value.indexOf(item.fullDate);
if (idx === -1) {
selectedDates.value.push(item.fullDate);
} else {
selectedDates.value.splice(index, 1);
selectedDates.value.splice(idx, 1);
}
};
// 月份切换
/** 上一月 */
const prevMonth = () => {
if (currentMonth.value === 1) {
currentYear.value--;
currentMonth.value = 12;
} else {
currentMonth.value--;
}
viewDate.value = viewDate.value.subtract(1, 'month');
};
/** 下一月 */
const nextMonth = () => {
if (currentMonth.value === 12) {
currentYear.value++;
currentMonth.value = 1;
} else {
currentMonth.value++;
}
viewDate.value = viewDate.value.add(1, 'month');
};
/** 重置到当前月份 */
const resetToToday = () => {
viewDate.value = dayjs();
};
// 关闭弹窗
const handleMaskClick = () => close();
/** 关闭弹窗 */
const close = () => {
showPopup.value = false;
selectedDates.value = [];
// 重置到当前月份(可选)
currentYear.value = new Date().getFullYear();
currentMonth.value = new Date().getMonth() + 1;
emit('clearCopyFlag')
resetToToday();
emit('clearCopyFlag');
};
// 打开弹窗(暴露给父组件)
/** 打开弹窗 */
const open = () => {
console.log('子组件open方法被调用了');
showPopup.value = true;
selectedDates.value = [];
currentYear.value = new Date().getFullYear();
currentMonth.value = new Date().getMonth() + 1;
resetToToday();
};
// 确认添加
/** 确认添加 */
const confirmAdd = async () => {
if (selectedDates.value.length === 0) {
if (!selectedDates.value.length) {
uni.showToast({ title: '请选择训练日期', icon: 'none' });
return;
}
try {
// if (props.isMove) {
// const params = {
// id: props.templateId,
// trainingDate: selectedDates.value[0] || ''
// };
// // 添加到日历弹窗的参数
// console.log('移动到:params', params);
// await QueryPlanApi.updateDailyTemplateDate(params);
// uni.showToast({ title: '移动成功', icon: 'success' });
// } else {
// const params = {
// id: props.templateId,
// planId: props.planId,
// trainDateList: selectedDates.value
// };
// // 添加到日历弹窗的参数
// console.log('添加到params', params);
// await QueryPlanApi.addPlanToCalendar(params);
// uni.showToast({ title: '添加到日历成功', icon: 'success' });
// }
// ========== 新增复制分支 优先判断 ==========
console.log('rops.dailyTemplateId=', props.dailyTemplateId);
if (props.isCopy) {
// 调用你新增的复制接口
await dailytemplateApi.copyTempleteDailyTemplate(props.dailyTemplateId, selectedDates.value)
await dailytemplateApi.copyTempleteDailyTemplate(
props.dailyTemplateId,
selectedDates.value,
);
uni.showToast({ title: '复制成功', icon: 'success' });
console.log('调用复制接口成功');
} else if (props.isMove) {
// 移动到
const params = {
await QueryPlanApi.updateDailyTemplateDate({
id: props.templateId,
trainingDate: selectedDates.value[0] || ''
};
await QueryPlanApi.updateDailyTemplateDate(params);
trainingDate: selectedDates.value[0] || '',
});
uni.showToast({ title: '移动成功', icon: 'success' });
console.log('移动接口成功');
} else {
//添加到日历
const params = {
await QueryPlanApi.addPlanToCalendar({
id: props.templateId,
planId: props.planId,
trainDateList: selectedDates.value
};
await QueryPlanApi.addPlanToCalendar(params);
trainDateList: selectedDates.value,
});
uni.showToast({ title: '添加到日历成功', icon: 'success' });
console.log('添加到接口成功');
}
close();
emit('success');
} catch (err) {
... ... @@ -258,120 +251,162 @@ defineExpose({ open, close });
</script>
<style scoped lang="scss">
.custom-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 9999;
display: flex;
align-items: flex-end;
}
.custom-popup {
background: #fff;
border-radius: 24rpx 24rpx 0 0;
/* ==================== 弹窗内容容器 ==================== */
.calendar-popup-content {
padding: 30rpx;
box-sizing: border-box;
width: 100%;
max-height: 80vh;
overflow-y: auto;
min-height: 50vh;
background: #fff;
}
/* ==================== 头部 ==================== */
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30rpx;
margin-bottom: 28rpx;
.title {
font-size: 32rpx;
font-weight: bold;
color: #333;
.header-title {
font-size: 34rpx;
font-weight: 700;
color: #1a1a1a;
}
.month-nav {
.month-navigator {
display: flex;
align-items: center;
gap: 20rpx;
gap: 24rpx;
.month-text {
font-size: 28rpx;
.month-label {
font-size: 30rpx;
font-weight: 500;
color: #333;
min-width: 140rpx;
text-align: center;
}
}
}
.calendar-container {
margin-bottom: 30rpx;
/* ==================== 日历主体 ==================== */
.calendar-body {
flex: 1;
}
.week-row {
.week-header {
display: flex;
justify-content: space-around;
margin-bottom: 20rpx;
margin-bottom: 16rpx;
padding: 0 4rpx;
.week-day {
width: 14.28%;
.week-item {
flex: 1;
text-align: center;
font-size: 28rpx;
font-size: 26rpx;
color: #999;
padding: 8rpx 0;
&.weekend {
color: #e6a23c;
}
}
}
.days-grid {
.date-grid {
display: flex;
flex-wrap: wrap;
}
.day-cell {
width: 14.28%;
aspect-ratio: 1 / 1;
.date-cell {
width: calc(100% / 7);
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8rpx;
margin-bottom: 8rpx;
border-radius: 50%;
position: relative;
transition: background-color 0.2s, transform 0.15s;
.day-number {
.date-text {
font-size: 28rpx;
color: #333;
position: relative;
z-index: 1;
}
&.other-month .day-number {
color: #ccc;
/* 非当月 */
&.other-month {
opacity: 0;
pointer-events: none;
}
/* 今天 */
&.today {
.date-text {
color: #409eff;
font-weight: 600;
&::after {
content: '';
position: absolute;
bottom: -4rpx;
left: 50%;
transform: translateX(-50%);
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background: #409eff;
}
}
}
/* 已选中 */
&.selected {
background-color: #ffd700;
background: linear-gradient(135deg, #ffd700, #ffb800);
.date-text {
color: #1a1a1a;
font-weight: 700;
}
.day-number {
color: #000;
font-weight: bold;
&.today .date-text::after {
background: #1a1a1a;
}
}
/* 不可选(过去日期) */
&.disabled {
opacity: 0.4;
opacity: 0.3;
pointer-events: none;
}
/* 可点击态 */
&:not(.disabled):not(.other-month):active {
transform: scale(0.9);
background-color: #f0f0f0;
}
}
/* ==================== 底部 ==================== */
.popup-footer {
margin-top: 30rpx;
padding-top: 30rpx;
margin-top: 24rpx;
padding-top: 20rpx;
border-top: 1rpx solid #f0f0f0;
.selected-hint {
text-align: center;
font-size: 24rpx;
color: #999;
margin-bottom: 16rpx;
}
.u-button {
background-color: #ffd700 !important;
color: #000;
font-weight: bold;
border-radius: 40rpx;
color: #1a1a1a;
font-weight: 700;
font-size: 30rpx;
border-radius: 44rpx;
letter-spacing: 2rpx;
&.u-button--disabled {
background-color: #eee !important;
color: #999 !important;
background-color: #e8e8e8 !important;
color: #bbb !important;
}
}
}
... ...
... ... @@ -345,15 +345,23 @@ const chartSeries = computed(() => {
const dataKey = TREND_DATA_KEYS[NumberTabIndex.value] || 'volume';
/** 单位转换:秒→分钟,米→公里,保留一位小数 */
const convertVal = (raw) => {
const num = Number(raw) || 0;
if (dataKey === 'duration') return parseFloat((num / 60).toFixed(1));
if (dataKey === 'distance') return parseFloat((num / 1000).toFixed(1));
return num;
};
// 当前周期数据
const currData = fullDateKeys.map((dateStr) => currDataMap[dateStr]?.[dataKey] ?? 0);
const currData = fullDateKeys.map((dateStr) => convertVal(currDataMap[dateStr]?.[dataKey] ?? 0));
// 上一周期数据(按日期偏移匹配)
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;
return convertVal(lastDataMap[targetKey]?.[dataKey] ?? 0);
});
const currName = isWeekly ? '本周' : '本月';
... ... @@ -372,11 +380,15 @@ const listData = computed(() => {
if (!weeklyData.value) return [];
const d = weeklyData.value;
/** 单位转换:秒→分钟,米→公里,保留一位小数 */
const toMin = (v) => parseFloat(((v ?? 0) / 60).toFixed(1));
const toKm = (v) => parseFloat(((v ?? 0) / 1000).toFixed(1));
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: toMin(d.lastWeekTotalDuration), curr: toMin(d.totalDuration) },
{ label: '距离', last: toKm(d.lastWeekTotalDistance), curr: toKm(d.totalDistance) },
{ label: '次数', last: d.lastWeekTotalCount, curr: d.totalCount },
];
... ... @@ -387,7 +399,7 @@ const listData = computed(() => {
label,
lastPeriod: lastVal,
currPeriod: currVal,
diff: currVal - lastVal,
diff: parseFloat((currVal - lastVal).toFixed(1)),
};
});
});
... ...
... ... @@ -34,7 +34,7 @@ const emit = defineEmits(['success', 'close'])
// 组件状态
const show = ref(false)
const folderList = ref([])
const defaultCover = 'https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260316/order-empty_1773628059920.png' // 默认封面,可替换为你的本地图片
const defaultCover = 'https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260724/文件夹_1784867551849.png' // 默认封面,可替换为你的本地图片
const props = defineProps({
... ...
... ... @@ -233,6 +233,11 @@ const handleDeleteTemplate = async () => {
return;
}
// 先保存模板ID,再关闭菜单弹窗,避免弹窗叠加
const templateId = currentTemplate.value.id;
closeTemplateMenu();
setTimeout(() => {
uni.showModal({
title: '确认删除',
content: '确定要删除这个模板吗?删除后无法恢复',
... ... @@ -241,13 +246,11 @@ const handleDeleteTemplate = async () => {
if (res.confirm) {
try {
uni.showLoading({ title: '删除中...' });
await TemplatesApi.deleteCustTemplate(currentTemplate.value.id);
await TemplatesApi.deleteCustTemplate(templateId);
uni.hideLoading();
uni.showToast({ title: '删除成功', icon: 'success' });
closeTemplateMenu();
// 通知父组件刷新列表
emit('refresh');
} catch (err) {
uni.hideLoading();
console.error('删除失败:', err);
... ... @@ -256,6 +259,7 @@ const handleDeleteTemplate = async () => {
}
}
});
}, 200);
};
... ...
<template>
<view class="container">
<!-- 加载中占位 -->
<view class="loading-wrap" v-if="!isDataReady">
<text class="loading-text">加载中...</text>
</view>
<!-- 我的分类 -->
<view class="section">
<view class="section" v-if="isDataReady">
<view class="section-header">
<text class="section-title">我的分类</text>
<text class="section-subtitle">点击隐藏分类</text>
... ... @@ -20,7 +25,7 @@
</view>
<!-- 已隐藏分类 -->
<view class="section">
<view class="section" v-if="isDataReady">
<view class="section-header">
<text class="section-title">已隐藏分类</text>
<text class="section-subtitle">点击恢复分类</text>
... ... @@ -43,7 +48,6 @@
<!-- 底部按钮 -->
<view class="bottom-btns">
<view class="edit-btn" @click="openEditPopup">编辑名称</view>
<!-- 优化:点击打开自定义新增弹窗 -->
<view class="add-btn" @click="openAddPopup">+ 新增动作分类</view>
</view>
... ... @@ -77,7 +81,7 @@
</view>
</up-popup>
<!-- ===================== 新增:自定义新增分类弹窗 ===================== -->
<!-- 新增分类弹窗 -->
<up-popup :show="showAddPopup" mode="bottom" :mask-click="true" :safe-area-inset-bottom="true">
<view class="add-popup-wrap">
<!-- 头部 -->
... ... @@ -118,86 +122,92 @@
<script setup>
import { ref, computed } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app';
import { onShow } from '@dcloudio/uni-app';
import ExercisesApi from '@/sheep/api/motion/exercises';
import { useActionStore } from '@/sheep/store/action';
// 引入 Store
const actionStore = useActionStore();
// 从 Store 获取状态和方法
const showCategories = computed(() => actionStore.showCategories);
const hiddenCategories = computed(() => actionStore.hiddenCategories);
const { hideCategory, showCategory, getloadCategories, reloadCategories } = actionStore;
const { hideCategory, showCategory, getloadCategories } = actionStore;
// 页面数据就绪标记(避免 allCategories 为空时渲染空白)
const isDataReady = ref(false);
// 计算属性:获取隐藏分类的对象数组
// 隐藏分类的对象数组(含名称)
const hiddenCategoryObjects = computed(() => {
if (!isDataReady.value) return [];
return actionStore.allCategories.filter(category =>
hiddenCategories.value.includes(category.id)
);
});
// 自定义分类列表(弹窗用)
// Toast 提示常量
const TOAST = {
ADD_SUCCESS: '新增成功',
ADD_FAIL: '新增失败',
EDIT_SUCCESS: '修改成功',
EDIT_FAIL: '修改失败',
LOAD_FAIL: '加载失败',
};
// 自定义分类列表(编辑弹窗用)
const customCategories = ref([]);
// 当前要编辑的分类
const currentEditCategory = ref(null);
// 控制自定义分类编辑弹窗
const showEditPopup = ref(false);
// 编辑弹窗输入值
const editInputValue = ref('');
// ===================== 新增:自定义新增弹窗变量 =====================
const showAddPopup = ref(false); // 控制新增弹窗显示
const addCategoryName = ref(''); // 新增分类名称
// 新增分类弹窗变量
const showAddPopup = ref(false);
const addCategoryName = ref('');
// 打开新增弹窗
const openAddPopup = () => {
addCategoryName.value = ''; // 清空输入框
addCategoryName.value = '';
showAddPopup.value = true;
};
// 关闭新增弹窗
const closeAddPopup = () => {
showAddPopup.value = false;
};
// ===================== 优化:执行新增分类(接口请求) =====================
const doAddCategory = async () => {
const name = addCategoryName.value.trim();
if (!name) return;
try {
await ExercisesApi.createCustomCategory(name);
uni.showToast({ title: '新增成功', icon: 'success' });
uni.showToast({ title: TOAST.ADD_SUCCESS, icon: 'success' });
closeAddPopup();
getloadCategories();
reloadCategories();
} catch (err) {
uni.showToast({ title: '新增失败', icon: 'none' });
uni.showToast({ title: TOAST.ADD_FAIL, icon: 'none' });
console.error(err);
}
};
// 打开自定义分类编辑弹窗
const openEditPopup = async () => {
try {
// 获取自定义分类列表
const fetchCustomCategories = async () => {
const res = await ExercisesApi.getCustomCategories();
customCategories.value = res.data || [];
};
// 打开编辑弹窗
const openEditPopup = async () => {
try {
await fetchCustomCategories();
showEditPopup.value = true;
} catch (err) {
console.error('加载自定义分类失败', err);
uni.showToast({ title: '加载失败', icon: 'none' });
uni.showToast({ title: TOAST.LOAD_FAIL, icon: 'none' });
}
};
// 关闭弹窗
const closeEditPopup = () => {
showEditPopup.value = false;
};
// 打开编辑弹窗
const openEditModal = (item) => {
currentEditCategory.value = item;
editInputValue.value = item.name;
uni.showModal({
title: '动作分类',
editable: true,
... ... @@ -207,27 +217,59 @@
if (res.confirm && res.content.trim()) {
try {
await ExercisesApi.updateCustomCategory(item.id, res.content.trim());
uni.showToast({ title: '修改成功', icon: 'success' });
openEditPopup();
getloadCategories();
uni.showToast({ title: TOAST.EDIT_SUCCESS, icon: 'success' });
await fetchCustomCategories();
reloadCategories();
} catch (err) {
console.error('修改失败', err);
uni.showToast({ title: '修改失败', icon: 'none' });
uni.showToast({ title: TOAST.EDIT_FAIL, icon: 'none' });
}
}
},
});
};
onShow(() => {
getloadCategories();
onShow(async () => {
await getloadCategories();
isDataReady.value = true;
});
</script>
<style scoped lang="scss">
// ========== 设计变量 ==========
$radius-sm: 10rpx;
$radius-md: 12rpx;
$radius-lg: 20rpx;
$radius-round: 36rpx;
$gap: 20rpx;
$color-primary: #ffd700;
$color-text: #333;
$color-text-secondary: #666;
$color-text-light: #999;
$color-border: #eee;
$color-bg: #f7f7f7;
$color-bg-light: #f9f9f9;
$transition-base: 0.2s ease;
.container {
min-height: 100vh;
background-color: #fff;
// 为底部固定栏留出空间
padding-bottom: 120rpx;
box-sizing: border-box;
}
// 加载中占位
.loading-wrap {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
}
.loading-text {
font-size: 28rpx;
color: $color-text-light;
}
.section {
... ... @@ -243,20 +285,22 @@
.section-title {
font-size: 28rpx;
color: #333;
color: $color-text;
font-weight: bold;
}
.section-subtitle {
font-size: 24rpx;
color: #999;
color: $color-text-light;
margin-left: 16rpx;
}
.category-grid {
// 分类网格(展示 + 隐藏共用)
.category-grid,
.hidden-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20rpx;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: $gap;
}
.category-item {
... ... @@ -264,34 +308,36 @@
justify-content: center;
align-items: center;
padding: 20rpx 16rpx;
padding-right: 28rpx;
background-color: #fff;
border: 1rpx solid #eee;
border-radius: 10rpx;
border: 1rpx solid $color-border;
border-radius: $radius-sm;
position: relative;
transition: all 0.2s ease;
padding-right: 28rpx;
}
transition: all $transition-base;
.category-item:active {
&:active {
transform: scale(0.96);
background-color: #fafafa;
}
}
.category-text {
font-size: 24rpx;
color: #333;
color: $color-text;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.delete-icon {
position: absolute;
right: 4rpx;
top: 4rpx;
transform: none;
z-index: 99;
z-index: 1;
padding: 4rpx;
opacity: 0.7;
transition: all 0.2s ease;
transition: all $transition-base;
}
.category-item:active .delete-icon {
... ... @@ -299,26 +345,21 @@
color: #ff4d4f;
}
.hidden-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20rpx;
}
.hidden-item {
display: flex;
justify-content: center;
align-items: center;
padding: 20rpx 16rpx;
background: #f9f9f9;
border: 1rpx solid #eee;
border-radius: 10rpx;
transition: all 0.2s ease;
}
background: $color-bg-light;
border: 1rpx solid $color-border;
border-radius: $radius-sm;
transition: all $transition-base;
.hidden-item:active {
&:active {
transform: scale(0.96);
background-color: #f2f2f2;
background-color: #e8e8e8;
border-color: #ddd;
}
}
.hidden-empty {
... ... @@ -329,10 +370,13 @@
.hidden-text {
font-size: 24rpx;
color: #999;
color: $color-text-light;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 底部按钮 */
// 底部按钮栏
.bottom-btns {
display: flex;
justify-content: space-between;
... ... @@ -344,9 +388,8 @@
background-color: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
z-index: 10;
padding: 20rpx 30rpx;
padding: 20rpx 20rpx calc(20rpx + env(safe-area-inset-bottom));
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.05);
padding: 20rpx;
box-sizing: border-box;
}
... ... @@ -355,11 +398,11 @@
height: 72rpx;
line-height: 72rpx;
font-size: 26rpx;
color: #666;
color: $color-text-secondary;
background-color: #f5f5f5;
border-radius: 36rpx;
border-radius: $radius-round;
text-align: center;
transition: all 0.2s;
transition: all $transition-base;
&:active {
background-color: #e0e0e0;
... ... @@ -371,16 +414,16 @@
flex: 1;
height: 72rpx;
line-height: 72rpx;
background: linear-gradient(135deg, #ffd700 0%, #ffcc00 100%) !important;
color: #333 !important;
background: linear-gradient(135deg, $color-primary 0%, #ffcc00 100%);
color: $color-text;
font-size: 28rpx;
font-weight: 500;
border-radius: 36rpx;
margin-left: 20rpx;
border-radius: $radius-round;
margin-left: $gap;
border: none;
box-shadow: 0 4rpx 12rpx rgba(255, 215, 0, 0.3);
text-align: center;
transition: all 0.2s;
transition: all $transition-base;
&:active {
transform: scale(0.98);
... ... @@ -388,10 +431,10 @@
}
}
/* 编辑弹窗 */
// 编辑分类弹窗
.popup-wrap {
background: #f7f7f7;
border-radius: 20rpx 20rpx 0 0;
background: $color-bg;
border-radius: $radius-lg $radius-lg 0 0;
padding: 0 30rpx;
max-height: 70vh;
display: flex;
... ... @@ -408,12 +451,12 @@
.popup-title {
font-size: 36rpx;
font-weight: 500;
color: #333;
color: $color-text;
}
.popup-tip {
font-size: 28rpx;
color: #666;
color: $color-text-secondary;
margin-bottom: 30rpx;
}
... ... @@ -427,22 +470,33 @@
justify-content: space-between;
align-items: center;
background: #fff;
border-radius: 12rpx;
border-radius: $radius-md;
padding: 30rpx;
margin-bottom: 20rpx;
margin-bottom: $gap;
transition: all $transition-base;
&:active {
background-color: #f5f5f5;
transform: scale(0.98);
}
}
.item-name {
font-size: 32rpx;
color: #333;
color: $color-text;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 16rpx;
}
/* ===================== 新增:自定义新增弹窗样式 ===================== */
// 新增分类弹窗
.add-popup-wrap {
background: #fff;
border-radius: 20rpx 20rpx 0 0;
border-radius: $radius-lg $radius-lg 0 0;
padding: 30rpx;
padding-bottom: calc(var(--safe-area-inset-bottom) + 30rpx);
padding-bottom: calc(env(safe-area-inset-bottom) + 30rpx);
}
.add-popup-header {
... ... @@ -455,26 +509,27 @@
.add-popup-title {
font-size: 38rpx;
font-weight: bold;
color: #333;
color: $color-text;
}
.add-input-box {
background: #f7f7f7;
border-radius: 12rpx;
background: $color-bg;
border-radius: $radius-md;
padding: 24rpx 20rpx;
margin-bottom: 20rpx;
margin-bottom: $gap;
}
.add-input {
font-size: 32rpx;
color: #333;
color: $color-text;
height: 48rpx;
line-height: 48rpx;
width: 100%;
}
.add-tip {
font-size: 24rpx;
color: #999;
color: $color-text-light;
margin-bottom: 40rpx;
}
... ... @@ -487,14 +542,14 @@
height: 88rpx;
line-height: 88rpx;
font-size: 32rpx;
border-radius: 12rpx;
background-color: #ffd700;
border-radius: $radius-md;
background-color: $color-primary;
color: #000;
border: none;
&[disabled] {
background-color: #e0e0e0;
color: #999;
color: $color-text-light;
}
}
</style>
... ...
<template>
<view class="page-wrap">
<!-- 顶部搜索 -->
<view class="add-btn-wrapper" @click.stop="addExercise">
<uni-icons type="plus" size="35" color="#333"></uni-icons>
<view class="floating-menu" v-show="replaceAddshow" @click.stop>
<view class="menu-item" @click="addnewmotion">
<uni-icons type="plus" size="24" color="#333"></uni-icons>
<text class="menu-text">新增动作</text>
</view>
</view>
</view>
<view class="search-bar">
<!-- <view class="search-input-wrapper">
<uni-icons type="search" size="18" color="#999"></uni-icons>
<input class="search-input" type="text" placeholder="搜索动作名称" @input="onSearch" />
</view> -->
</view>
<view class="page-wrap" :style="{ paddingTop: PageHeight + 'px' }">
<!-- 左右主体布局 -->
<view class="layout-container">
... ... @@ -29,18 +13,44 @@
<!-- 右侧动作列表 -->
<scroll-view scroll-y class="right-content">
<!-- 初始引导:未选择分类 -->
<view class="content-tip init-guide" v-if="!activeNav">
<text class="tip-text">请选择左侧分类查看动作</text>
<text class="tip-subtext">选择分类后将展示该分类下的训练动作</text>
</view>
<!-- 加载中 -->
<view class="content-tip loading-state" v-else-if="loading">
<text class="tip-text">加载中...</text>
</view>
<!-- 加载失败 -->
<view class="content-tip error-state" v-else-if="loadError">
<text class="tip-text">加载失败,请重试</text>
<view class="retry-btn" @click="retryLoad">点击重试</view>
</view>
<!-- 正常内容 -->
<template v-else>
<!-- 部位筛选标签 -->
<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)">
<scroll-view scroll-x show-scrollbar="false" class="part-filter" v-if="motionPart.length > 0">
<view class="part-filter-inner">
<view class="filter-item" :class="{ active: activeMotionPart === '' }" @click="handlePartClick('')">全部
</view>
<view class="filter-item" v-for="item in motionPart" :key="item.id"
:class="{ active: activeMotionPart === item.id }" @click="handlePartClick(item.id)">
{{ item.name }}
</view>
</view>
</scroll-view>
<view class="exercise-grid">
<view class="content" v-if="exercises.length > 0">
<view class="equipment-list">
<!-- 部位筛选为空提示 -->
<view class="content-tip part-empty-tip" v-else>
<text class="tip-text">该分类下暂无可筛选部位</text>
</view>
<!-- 动作列表 -->
<view class="equipment-list" v-if="exercises.length > 0">
<view class="equipment-item" v-for="item in exercises" :key="item.equipmentId">
<view class="equipment-name">{{ item.equipmentName }}</view>
<view class="action-list">
... ... @@ -52,23 +62,27 @@
</view>
</view>
</view>
<!-- 无动作数据 -->
<view class="content-tip empty-state" v-else>
<text class="tip-text">该分类下暂无动作</text>
<text class="tip-subtext">可尝试切换分类或部位查看其他动作</text>
</view>
<view class="empty-state" v-else>
<text class="empty-text">暂无动作数据</text>
</view>
</view>
</template>
</scroll-view>
</view>
<!-- 底部横向已选标签栏 -->
<!-- 底部已选标签栏 -->
<scroll-view class="selected-bar" scroll-x show-scrollbar="false" v-if="selectedActions.length > 0">
<view class="selected-item" v-for="(item, index) in selectedActions" :key="index">
<view class="selected-bar-inner">
<view class="selected-item" v-for="item in selectedActions" :key="item.id">
<text class="name">{{ item.name }}</text>
<uni-icons type="close" size="16" color="#fff" @click="removeAction(item)"></uni-icons>
</view>
</view>
</scroll-view>
<!-- 页面底部按钮 -->
<!-- 底部操作按钮 -->
<view class="bottom-btn-bar">
<view class="btn cancel-btn" @click="backPage">取消</view>
<view class="btn confirm-btn" :class="{ disabled: selectedActions.length < 2 }" @click="combineActions">
... ... @@ -76,29 +90,17 @@
</view>
</view>
<!-- 底部弹出命名弹窗 -->
<!-- 命名弹窗 -->
<up-popup v-model:show="showNamePopup" mode="bottom" round>
<view class="popup-content" @click.stop>
<view class="popup-header">
<!-- <text class="close-btn" @click="closePopup">x</text> -->
<up-icon name="close" color="#333" size="25" @click="closePopup"></up-icon>
<text class="popup-title">命名超级组</text>
<text class="save-btn" @click="saveCombination">保存</text>
</view>
<view class="input-wrapper">
<textarea v-model="combinationName" class="name-input" placeholder="请输入超级组名称" maxlength="20" focus />
<!-- <text class="hint">例如:胸肩超级组、背部组合训练</text> -->
</view>
<!-- <view class="preview-section">
<text class="preview-title">已选动作 ({{ selectedActions.length }})</text>
<scroll-view scroll-x class="preview-scroll">
<view class="preview-tag" v-for="item in selectedActions" :key="item.id">
{{ item.name }}
</view>
</scroll-view>
</view> -->
</view>
</up-popup>
</view>
... ... @@ -106,48 +108,61 @@
<script setup>
import { ref, computed } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app';
import { onLoad } from '@dcloudio/uni-app'
import { useActionStore } from '@/sheep/store/action'
import ExercisesApi from '@/sheep/api/motion/exercises'
import SupersetsApi from '@/sheep/api/motion/supersets'
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 actionStore = useActionStore()
// 搜索
const searchValue = ref('')
// ==================== 状态变量 ====================
// 左侧分类、部位筛选
// 左侧分类
const navItems = ref([])
const activeNav = ref(null)
// 部位筛选
const motionPart = ref([])
const activeMotionPart = ref('')
// 原始全量动作、当前渲染动作
// 动作数据
const exercises = ref([])
const allExercises = ref([])
const loading = ref(false)
const loadError = ref(false)
// 选中动作
const selectedActions = ref([])
const selectedIds = computed(() => new Set(selectedActions.value.map(item => item.id)))
// 命名弹窗控制
// 命名弹窗
const showNamePopup = ref(false)
const combinationName = ref('')
const replaceAddshow = ref(false);
// ==================== 生命周期 ====================
// 页面初始化(onLoad 页面生命周期)
onLoad(async () => {
selectedActions.value = []
combinationName.value = ''
await loadCategories()
})
// 加载全部分类
// ==================== 数据加载 ====================
/** 加载全部分类 */
const loadCategories = async () => {
try {
await actionStore.getloadCategories()
navItems.value = actionStore.showCategories
// 过滤掉虚拟分类(如「超级组」id='super'),其 id 非数字,无法用于加载动作
navItems.value = actionStore.showCategories.filter(item => {
const id = item.id
return typeof id === 'number' || (typeof id === 'string' && /^\d+$/.test(id))
})
if (navItems.value.length > 0) {
switchNav(navItems.value[0].id)
}
... ... @@ -156,77 +171,81 @@ const loadCategories = async () => {
}
}
const addExercise = () => {
replaceAddshow.value = !replaceAddshow.value;
};
const addnewmotion = () => {
uni.navigateTo({ url: '/pages4/pages/xunji/xunji-dongzuo-xinzeng' });
};
// 切换左侧大分类
/** 切换左侧大分类 */
const switchNav = async (id) => {
if (activeNav.value === id) return
activeNav.value = id
activeMotionPart.value = ''
searchValue.value = ''
loadError.value = false
await loadExercises(id)
}
// 根据分类ID加载动作+部位
/** 根据分类ID加载动作和部位 */
const loadExercises = async (categoriesId) => {
loading.value = true
loadError.value = false
try {
const partRes = await ExercisesApi.getMotionPart(categoriesId)
const [partRes, exerciseRes] = await Promise.all([
ExercisesApi.getMotionPart(categoriesId),
ExercisesApi.getexercises({ categoriesId })
])
motionPart.value = partRes.data || []
const res = await ExercisesApi.getexercises({ categoriesId })
exercises.value = res.data || []
allExercises.value = [...exercises.value]
exercises.value = exerciseRes.data || []
} catch (err) {
console.error('加载动作失败', err)
loadError.value = true
motionPart.value = []
exercises.value = []
} finally {
loading.value = false
}
}
// 子部位筛选
/** 按子部位筛选动作 */
const handlePartClick = async (id) => {
activeMotionPart.value = id
loading.value = true
loadError.value = false
try {
const res = await ExercisesApi.getexercises({
categoriesId: activeNav.value,
subCategoriesId: id
})
exercises.value = res.data || []
} catch (err) {
console.error('筛选动作失败', err)
uni.showToast({ title: '加载失败', icon: 'none' })
loadError.value = true
} finally {
loading.value = false
}
}
// 搜索过滤
const onSearch = () => {
const keyword = searchValue.value.trim().toLowerCase()
if (!keyword) {
exercises.value = [...allExercises.value]
return
/** 重试加载 */
const retryLoad = () => {
if (activeNav.value) {
loadExercises(activeNav.value)
}
exercises.value = allExercises.value.filter(item =>
item.exercises?.some(e => e.name.toLowerCase().includes(keyword))
)
}
// 点击选中/取消单个动作
// ==================== 操作交互 ====================
/** 选中/取消单个动作 */
const toggleAction = (action) => {
console.log('点击的动作', action, '选中前数组', selectedActions.value)
const exist = selectedIds.value.has(action.id)
if (exist) {
selectedActions.value = selectedActions.value.filter(i => i.id !== action.id)
} else {
selectedActions.value.push({ ...action })
selectedActions.value = [...selectedActions.value, { ...action }]
}
console.log('选中后', selectedActions.value)
}
// 底部标签移除动作
/** 移除已选动作 */
const removeAction = (action) => {
selectedActions.value = selectedActions.value.filter(i => i.id !== action.id)
}
// 打开命名弹窗
/** 打开命名弹窗 */
const combineActions = () => {
if (selectedActions.value.length < 2) {
uni.showToast({ title: '至少选择2个动作', icon: 'none' })
... ... @@ -236,12 +255,12 @@ const combineActions = () => {
showNamePopup.value = true
}
// 关闭弹窗
/** 关闭弹窗 */
const closePopup = () => {
showNamePopup.value = false
}
// 【原有创建超级组接口不动】保存超级组
/** 保存超级组 */
const saveCombination = async () => {
const name = combinationName.value.trim()
if (!name) {
... ... @@ -258,7 +277,6 @@ const saveCombination = async () => {
uni.showToast({ title: '创建成功', icon: 'success' })
selectedActions.value = []
closePopup()
// 创建成功返回上一页
setTimeout(() => uni.navigateBack(), 800)
} else {
uni.showToast({ title: res.msg || '创建失败', icon: 'none' })
... ... @@ -268,99 +286,83 @@ const saveCombination = async () => {
}
}
// 返回上一页
/** 返回上一页 */
const backPage = () => {
uni.navigateBack()
}
</script>
<style lang="scss" scoped>
page {
background-color: #f5f5f5;
}
// ==================== SCSS 变量 ====================
$color-primary: #f8d714;
$color-bg: #f5f5f5;
$color-bg-nav: #fafafa;
$color-text-primary: #333;
$color-text-secondary: #666;
$color-text-muted: #999;
$color-white: #fff;
$color-dark-bar: #2c2c2e;
$color-dark-tag: #444;
$color-border: #eee;
$color-disabled-bg: #eee;
$color-disabled-text: #bbb;
$z-fixed: 100;
$radius-sm: 12rpx;
$radius-md: 16rpx;
$radius-lg: 24rpx;
$radius-round: 40rpx;
$spacing-xs: 8rpx;
$spacing-sm: 16rpx;
$spacing-md: 20rpx;
$spacing-lg: 30rpx;
$font-xs: 24rpx;
$font-sm: 26rpx;
$font-md: 28rpx;
$font-lg: 30rpx;
$font-xl: 32rpx;
.page-wrap {
width: 100%;
min-height: 100vh;
height: 100vh;
// #ifdef MP-WEIXIN
height: 88vh;
// #endif
display: flex;
flex-direction: column;
overflow: hidden;
}
.add-btn-wrapper {
position: fixed;
left: 28rpx;
top: calc(var(--status-bar-height) + 12rpx);
z-index: 9999;
width: 50rpx;
height: 50rpx;
}
.search-bar {
padding-top: var(--status-bar-height);
height: 88rpx;
background: #fff;
}
.floating-menu {
position: absolute;
top: 90rpx;
left: 20rpx;
background: #fff;
border-radius: 12rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.15);
padding: 20rpx;
z-index: 9999;
width: 260rpx;
}
.menu-item {
display: flex;
align-items: center;
gap: 16rpx;
}
.menu-text {
box-sizing: border-box;
display: flex;
font-size: 28rpx;
// margin-left: 200rpx;
}
// .search-bar {
// padding: 20rpx 30rpx;
// background: #fff;
// .search-input {
// width: 100%;
// height: 70rpx;
// border-radius: 35rpx;
// background: #f7f7f7;
// padding: 0 24rpx;
// font-size: 28rpx;
// border: 1rpx solid #eee;
// }
// }
// ==================== 主体布局 ====================
.layout-container {
flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
padding-bottom: 130rpx;
padding-bottom: calc(100rpx + 30rpx); // 底部按钮栏 + 间距
}
.left-nav {
width: 150rpx;
background: #fafafa;
border-right: 1rpx solid #eee;
// 左侧导航
.left-nav {
width: 180rpx;
background: $color-bg-nav;
border-right: 1rpx solid $color-border;
.nav-item {
padding: 30rpx 20rpx;
font-size: 26rpx;
color: #666;
padding: $spacing-lg $spacing-sm;
font-size: $font-sm;
color: $color-text-secondary;
position: relative;
white-space: nowrap;
text-align: center;
&.active {
background: #fff;
background: $color-white;
color: #000;
font-weight: 500;
... ... @@ -372,230 +374,263 @@ page {
transform: translateY(-50%);
width: 6rpx;
height: 36rpx;
background: #f8d714;
}
background: $color-primary;
}
}
}
}
.right-content {
// 右侧内容
.right-content {
flex: 1;
padding: 20rpx;
padding: $spacing-md;
padding-top: $spacing-lg;
.tip {
// 通用内容提示块
.content-tip {
display: flex;
flex-wrap: wrap;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80rpx $spacing-lg;
text-align: center;
.tip-text {
font-size: $font-md;
color: $color-text-muted;
}
.tip-subtext {
margin-top: $spacing-sm;
font-size: $font-xs;
color: #bbb;
}
}
// 初始引导
.init-guide {
.tip-text {
font-size: $font-lg;
color: $color-text-secondary;
font-weight: 500;
}
}
// 错误重试按钮
.retry-btn {
margin-top: $spacing-md;
padding: 12rpx 40rpx;
font-size: $font-sm;
color: #000;
background: $color-primary;
border-radius: $radius-round;
}
// 部位筛选标签(横向滚动)
.part-filter {
margin-bottom: $spacing-md;
white-space: nowrap;
.part-filter-inner {
display: inline-flex;
flex-wrap: nowrap;
gap: 15rpx;
margin-bottom: 20rpx;
}
.item {
padding: 10rpx 20rpx;
background: #fff;
border-radius: 16rpx;
font-size: 24rpx;
.filter-item {
flex-shrink: 0;
padding: 10rpx 24rpx;
background: $color-white;
border-radius: $radius-sm;
font-size: $font-xs;
color: $color-text-secondary;
transition: all 0.2s;
&.active {
background: #f8d714;
background: $color-primary;
color: #000;
font-weight: 500;
}
}
}
// 部位筛选为空
.part-empty-tip {
padding: 30rpx $spacing-lg 40rpx;
}
.equipment-item {
margin-bottom: 30rpx;
margin-bottom: $spacing-lg;
.equipment-name {
font-size: 28rpx;
font-size: $font-md;
font-weight: 500;
margin-bottom: 16rpx;
color: $color-text-secondary;
margin-bottom: $spacing-sm;
padding-left: $spacing-xs;
border-left: 6rpx solid $color-primary;
}
.action-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
gap: $spacing-sm;
.action-item {
background: #fff;
border-radius: 16rpx;
padding: 16rpx;
background: $color-white;
border-radius: $radius-md;
padding: $spacing-sm;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
gap: $spacing-xs;
border: 2rpx solid transparent;
transition: border-color 0.2s, transform 0.15s;
&.selected {
border: 2rpx solid #f8d714;
border-color: $color-primary;
}
&:active {
transform: scale(0.97);
}
.action-img {
width: 100%;
height: 200rpx;
border-radius: 12rpx;
border-radius: $radius-sm;
}
.action-name {
font-size: 24rpx;
color: #333;
font-size: $font-xs;
color: $color-text-primary;
}
}
}
}
// 无动作空状态
.empty-state {
padding-top: 100rpx;
text-align: center;
color: #999;
font-size: 28rpx;
}
}
}
// ==================== 底部已选标签栏 ====================
.selected-bar {
position: fixed;
left: 0;
right: 0;
bottom: calc(100rpx + env(safe-area-inset-bottom));
background: #2c2c2e;
padding: 20rpx 30rpx;
display: flex;
gap: 20rpx;
background: $color-dark-bar;
padding: 20rpx 20rpx 40rpx;
white-space: nowrap;
z-index: $z-fixed;
.selected-bar-inner {
display: inline-flex;
flex-wrap: nowrap;
gap: $spacing-md;
}
.selected-item {
background: #444;
color: #fff;
padding: 10rpx 20rpx;
flex-shrink: 0;
background: $color-dark-tag;
color: $color-white;
padding: 10rpx $spacing-md;
border-radius: 30rpx;
display: flex;
align-items: center;
gap: 10rpx;
.name {
font-size: 26rpx;
font-size: $font-sm;
}
}
}
// ==================== 底部操作按钮 ====================
.bottom-btn-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
gap: 20rpx;
padding: 20rpx 30rpx calc(20rpx + env(safe-area-inset-bottom));
background: #fff;
gap: $spacing-md;
padding: $spacing-md $spacing-lg calc(20rpx + env(safe-area-inset-bottom));
background: $color-white;
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.05);
z-index: $z-fixed;
.btn {
flex: 1;
height: 80rpx;
border-radius: 40rpx;
border-radius: $radius-round;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
font-size: $font-lg;
font-weight: bold;
}
.cancel-btn {
background: #f5f5f5;
color: #333;
background: $color-bg;
color: $color-text-primary;
}
.confirm-btn {
background: #f8d714;
background: $color-primary;
color: #000;
&.disabled {
background: #eee;
color: #bbb;
background: $color-disabled-bg;
color: $color-disabled-text;
}
}
}
// ==================== 命名弹窗 ====================
.popup-content {
width: 100%;
background: #fff;
background: $color-white;
box-sizing: border-box;
border-radius: 24rpx 24rpx 0 0;
padding: 30rpx;
height: 70vh;
border-radius: $radius-lg $radius-lg 0 0;
padding: $spacing-lg;
max-height: 70vh;
.popup-header {
display: flex;
justify-content: space-between;
// align-items: center;
padding-bottom: 20rpx;
margin-bottom: 30rpx;
.close-btn {
font-size: 32rpx;
color: #333;
background: none;
}
align-items: center;
padding-bottom: $spacing-md;
margin-bottom: $spacing-lg;
.save-btn {
font-size: 28rpx;
background: #f8d714;
font-size: $font-md;
background: $color-primary;
padding: 12rpx 32rpx;
border-radius: 32rpx;
color: #000;
}
.popup-title {
font-size: 32rpx;
font-size: $font-xl;
font-weight: bold;
}
}
.input-wrapper {
margin-bottom: 30rpx;
display: flex;
// justify-content: center;
margin-bottom: $spacing-lg;
.name-input {
width: 100%;
height: 300rpx;
// border: 1rpx solid #ffffff;
background-color: #ececec;
border-radius: 16rpx;
border-radius: $radius-md;
padding: 24rpx;
font-size: 28rpx;
font-size: $font-md;
text-align: left;
vertical-align: top;
}
.hint {
font-size: 24rpx;
color: #999;
margin-top: 12rpx;
}
}
.preview-section {
.preview-title {
font-size: 28rpx;
margin-bottom: 16rpx;
color: #666;
}
.preview-scroll {
display: flex;
gap: 16rpx;
overflow-x: auto;
.preview-tag {
background: #f0f9ff;
color: #f8d714;
padding: 8rpx 20rpx;
border-radius: 50rpx;
font-size: 24rpx;
white-space: nowrap;
}
}
}
}
</style>
... ...
<template>
<view class="template-edit-page" @click="dismissGuide">
<view class="template-edit-page" @click="dismissGuide" :style="{ paddingTop: PageHeight + 'px' }">
<!-- 顶部导航 -->
<view class="header">
<view class="close-btn" @click="goBack">
... ... @@ -16,26 +16,19 @@
</text>
</view>
<view
class="save-btn"
:class="{ disabled: unitCount === 0, active: unitCount > 0 }"
@click="handleSave"
>
<view class="save-btn" :class="{ disabled: unitCount === 0, active: unitCount > 0 }" @click="handleSave">
保存模板
</view>
</view>
<!-- 内容区 -->
<view class="content-area">
<view class="content-wrapper">
<scroll-view scroll-y class="content-area">
<template v-if="unitCount">
<view v-for="(unit, index) in units" :key="unit.unitId">
<dongzuo
:id="unit.unitId"
:type="unit.unitType"
:action-detail="trainingStore.convertUnitToActionDetail(unit)"
:ref="(el) => setActionRef(el, index)"
:unit-index="index"
/>
<dongzuo :id="unit.unitId" :type="unit.unitType"
:action-detail="trainingStore.convertUnitToActionDetail(unit)" :ref="(el) => setActionRef(el, index)"
:unit-index="index" :isTick="false" />
</view>
</template>
... ... @@ -45,6 +38,7 @@
点击最下方 + 开始添加动作
</view>
</view>
</scroll-view>
</view>
<!-- 引导气泡 -->
... ... @@ -81,7 +75,7 @@
</view>
<!-- 文本编辑弹窗 -->
<view v-if="popupType" class="text-popup">
<up-popup :show="!!popupType" mode="bottom" round="24rpx" @close="closeTextPopup" bg-color="#333">
<view class="popup-header">
<view class="popup-close-btn" @click="closeTextPopup">
<up-icon name="close" color="#fff" size="20" bold />
... ... @@ -90,28 +84,15 @@
<view class="popup-save-btn" @click="saveTextPopup">保存</view>
</view>
<view class="popup-content">
<textarea
class="intro-textarea"
v-model="popupValue"
:placeholder="popupType === 'intro' ? '在此处输入模板介绍' : '请输入模板标题'"
/>
</view>
<textarea class="intro-textarea" v-model="popupValue"
:placeholder="popupType === 'intro' ? '在此处输入模板介绍' : '请输入模板标题'" />
</view>
</up-popup>
<!-- 场景选择器 -->
<up-picker
:show="showScenePicker"
:columns="sceneColumns"
:default-index="[0]"
title="训练场景"
title-color="#ffffff"
cancel-color="#cccccc"
confirm-color="#409eff"
active-color="#ffd100"
@confirm="handleSceneConfirm"
@cancel="showScenePicker = false"
close-on-click-overlay
/>
<up-picker :show="showScenePicker" :columns="sceneColumns" :default-index="[0]" title="训练场景" title-color="#ffffff"
cancel-color="#cccccc" confirm-color="#409eff" active-color="#ffd100" @confirm="handleSceneConfirm"
@cancel="showScenePicker = false" close-on-click-overlay />
<!-- 动作排序 / 新增 -->
<action-sort ref="actionSortRef" @open-add-actions="addExShow = true" />
... ... @@ -131,11 +112,17 @@ import ActionSort from '@/pages/xunji/components/dongzuo-lianxi/dongzuo-paixu.vu
import { useTrainingStore } from '@/sheep/store/trainingStore';
import FileApi from '@/sheep/api/infra/file';
import TemplatesApi from '@/sheep/api/Template/Templates';
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
dayjs.extend(duration);
// ---- 常量 ----
const NAVIGATE_DELAY = 1500;
const NAVIGATE_DELAY = 1000;
const SCENE_LIST = [
{ id: 0, title: '不限' },
... ... @@ -367,7 +354,6 @@ $radius-circle: 50%;
.template-edit-page {
position: relative;
width: 100%;
height: 100vh;
background-color: $color-bg-primary;
display: flex;
flex-direction: column;
... ... @@ -438,14 +424,20 @@ $radius-circle: 50%;
}
// ---- 内容区 ----
.content-area {
flex: 1;
overflow-y: auto;
.content-wrapper {
height: 83vh;
padding-bottom: 20rpx;
box-sizing: border-box;
overflow: hidden;
}
.content-area {
height: 100%;
}
.empty-area {
flex: 1;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
... ... @@ -461,8 +453,8 @@ $radius-circle: 50%;
// ---- 引导气泡 ----
.guide-bubble {
position: absolute;
bottom: 130rpx;
position: fixed;
bottom: 200rpx;
left: 50%;
transform: translateX(-50%);
background-color: #000;
... ... @@ -485,6 +477,10 @@ $radius-circle: 50%;
// ---- 底部栏 ----
.bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
height: 110rpx;
align-items: center;
... ... @@ -493,7 +489,6 @@ $radius-circle: 50%;
padding-bottom: env(safe-area-inset-bottom);
background-color: $color-bg-primary;
border-top: 1rpx solid $color-border;
flex-shrink: 0;
.bar-item {
display: flex;
... ... @@ -528,19 +523,6 @@ $radius-circle: 50%;
}
// ---- 文本弹窗 ----
.text-popup {
position: fixed;
left: 0;
right: 0;
bottom: 0;
height: 85vh;
background: $color-bg-secondary;
border-radius: $radius-lg $radius-lg 0 0;
z-index: 1000;
display: flex;
flex-direction: column;
}
.popup-header {
display: flex;
justify-content: space-between;
... ...
... ... @@ -78,14 +78,7 @@
<view
class="rest-countdown"
v-if="restCountdownSeconds > 0"
@click="
clearRestCountdown();
uncheckCurrentUnit();
if (restTimerWasRunning) {
trainingStore.toggleTimer();
restTimerWasRunning = false;
}
"
@click="clearRestCountdown()"
>
<view class="countdown-circle">
<!-- H5 端:使用 SVG 进度环 -->
... ... @@ -288,7 +281,6 @@
const restCountdownSeconds = ref(0);
const restCountdownTotal = ref(0);
let restCountdownInterval = null;
let restTimerWasRunning = false;
const restFormattedTime = computed(() => {
const min = Math.floor(restCountdownSeconds.value / 60);
... ... @@ -304,8 +296,6 @@
// H5 SVG 圆环周长(用于 stroke-dasharray)
const CIRCUMFERENCE = 2 * Math.PI * 44;
const currentRestUnitIndex = ref(null);
const clearRestCountdown = () => {
if (restCountdownInterval) {
clearInterval(restCountdownInterval);
... ... @@ -315,39 +305,33 @@
restCountdownTotal.value = 0;
};
const startRestCountdown = (seconds, unitIndex) => {
const startRestCountdown = (seconds) => {
if (!seconds || seconds <= 0) return;
clearRestCountdown();
// 叠加倒计时:如果已经在倒计时中则累加,否则开始新的倒计时
if (restCountdownInterval) {
restCountdownSeconds.value += seconds;
restCountdownTotal.value += seconds;
} else {
restCountdownTotal.value = seconds;
restCountdownSeconds.value = seconds;
currentRestUnitIndex.value = unitIndex;
restTimerWasRunning = !trainingStore.isPause;
if (restTimerWasRunning) trainingStore.toggleTimer();
restCountdownInterval = setInterval(() => {
restCountdownSeconds.value--;
if (restCountdownSeconds.value <= 0) {
clearRestCountdown();
uncheckCurrentUnit();
if (restTimerWasRunning) trainingStore.toggleTimer();
// 倒计时结束:不取消勾选状态,不恢复主计时器(主计时器始终在运行)
}
}, 1000);
};
function uncheckCurrentUnit() {
if (currentRestUnitIndex.value != null) {
const ref = dongzuoRefs.value[currentRestUnitIndex.value];
if (ref?.resetActiveSets) ref.resetActiveSets();
currentRestUnitIndex.value = null;
}
}
// 不暂停主计时器,训练计时器始终继续
};
function onCancelRestTimer(unitIndex) {
function onCancelRestTimer(seconds) {
if (!restCountdownInterval) return;
// 取消勾选时减去对应秒数
restCountdownSeconds.value -= (seconds || 0);
restCountdownTotal.value -= (seconds || 0);
if (restCountdownSeconds.value <= 0) {
clearRestCountdown();
currentRestUnitIndex.value = unitIndex;
uncheckCurrentUnit();
if (restTimerWasRunning) {
trainingStore.toggleTimer();
restTimerWasRunning = false;
}
}
... ... @@ -545,8 +529,25 @@
}
}
if (units.length === 0) {
uni.showToast({ title: '未获取到训练数据', icon: 'none' });
// 检查是否有打勾(已完成)的动作,只有打勾的动作才会被提交
const hasAnyChecked = units.some(u =>
u.exercises.some(e => e.sets && e.sets.length > 0)
);
if (!hasAnyChecked) {
uni.showModal({
title: '提示',
content: '未完成任何动作,是否取消训练?',
confirmText: '取消训练',
cancelText: '继续训练',
confirmColor: '#FF3B30',
success: (res) => {
if (res.confirm) {
uni.navigateBack();
trainingStore.clearTrainingStore();
}
},
});
return;
}
... ...
<template>
<view class="add-custom-exercise-page">
<!-- 肌肉部位图 -->
<view class="muscle-area" @click="openMusclePicker">
<image src="https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260424/muscle_back_1777018976173.png"
class="muscle-image-front" mode="aspectFill" />
</view>
<!-- 肌肉部位选择弹窗 - 美化版 -->
<view v-if="showMusclePicker" class="popup-overlay" @click="closeAllPopups">
<view class="popup-content" @click.stop>
<view class="popup-header">
<view class="close-btn" @click="closeAllPopups">x</view>
<view class="title">选择训练部位</view>
<view class="confirm-btn" @click="confirmMuscleSelection">完成</view>
</view>
<view class="muscle-list-beautified">
<view v-for="group in muscleGroups" :key="group.id" class="muscle-item-beautified">
<text class="muscle-name">{{ group.name }}</text>
<!-- 主要部位按钮 -->
<view class="selector-btn primary" :class="{ active: primaryMuscles.includes(group.id) }"
@click.stop="togglePrimary(group.id)">
<view class="selector-dot primary"></view>
<text class="selector-label">主要</text>
</view>
<!-- 次要部位按钮 -->
<view class="selector-btn secondary" :class="{ active: secondaryMuscles.includes(group.id) }"
@click.stop="toggleSecondary(group.id)">
<view class="selector-dot secondary"></view>
<text class="selector-label">次要</text>
</view>
</view>
</view>
<view class="page">
<!-- ==================== 肌肉部位图 ==================== -->
<view class="muscle-card" @click="showMusclePicker = true">
<image class="muscle-img"
src="https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260724/训练部位_1784878616826.jpg"
mode="aspectFill" />
<view class="muscle-tip">
<up-icon name="edit-pen" color="#fff" size="14" />
<text>点击选择训练部位</text>
</view>
</view>
<!-- 表单区域 -->
<view class="form-section">
<!-- 动作名称 -->
<view class="form-item">
<!-- <view class="label">动作名称</view>
<view class="input-group">
<input v-model="exerciseName" class="input-field" placeholder="点击此处填写动作名称"
placeholder-class="input-placeholder" />
</view> -->
<view class="action-name">
<view class="label">动作名称</view>
<view class="input-group">
<input v-model="exerciseName" class="input-field" placeholder="点击此处填写动作名称"
placeholder-class="input-placeholder" />
</view>
<!-- ==================== 表单卡片 ==================== -->
<view class="form-card">
<!-- 动作名称 + 封面 -->
<view class="form-row name-row">
<view class="name-input-area">
<text class="form-label">动作名称</text>
<input v-model="exerciseName" class="name-input" placeholder="点击此处填写动作名称" placeholder-class="ph" />
</view>
<view class="upload-btn" @click="openCoverSelector">
<image :src="coverImagePath" class="preview-media" mode="aspectFill" />
<text class="btn-text">更换封面</text>
<view class="cover-upload" @click="openCoverSelector">
<image :src="coverImagePath" class="cover-preview" mode="aspectFill" />
<text class="cover-label">更换封面</text>
</view>
</view>
<!-- 新增:训练部位 -->
<view class="form-item" @click="openMusclePicker">
<view class="label">训练部位</view>
<view class="value-wrapper">
<text class="value">{{ selectedMusclesDisplay }}</text>
<up-icon name="arrow-right" color="#333" size="16"></up-icon>
<!-- 训练部位 -->
<view class="form-row select-row" @click="showMusclePicker = true">
<text class="form-label">训练部位</text>
<view class="select-value">
<text class="sel-text">{{ selectedMusclesDisplay }}</text>
<up-icon name="arrow-right" color="#999" size="14" />
</view>
</view>
<!-- 动作类型 -->
<view class="form-item" @click="openActionTypePicker">
<view class="label">动作类型</view>
<view class="value-wrapper">
<text class="value">{{ actionType || '请选择' }}</text>
<!-- <image src="https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260316/order-empty_1773628059920.png"
class="arrow-icon" mode="aspectFill" /> -->
<up-icon v-if="!isEdit" name="arrow-right" color="#333" size="16"></up-icon>
<view class="form-row select-row" @click="openActionTypePicker">
<text class="form-label">动作类型</text>
<view class="select-value">
<text class="sel-text">{{ actionType || '请选择' }}</text>
<up-icon v-if="!isEdit" name="arrow-right" color="#999" size="14" />
</view>
</view>
<!-- 目录管理 -->
<view class="form-item" @click="openDirectoryPicker">
<view class="label">目录管理</view>
<view class="value-wrapper">
<text class="value">{{ directoryName || '请选择' }}</text>
<up-icon name="arrow-right" color="#333" size="16"></up-icon>
<view class="form-row select-row" @click="showDirectoryPicker = true">
<text class="form-label">目录管理</text>
<view class="select-value">
<text class="sel-text">{{ directoryName || '请选择' }}</text>
<up-icon name="arrow-right" color="#999" size="14" />
</view>
</view>
<!-- 动作视频 -->
<view class="form-item">
<view class="label">动作视频</view>
<view class="value-wrapper">
<text class="value">(非必填)</text>
</view>
<view class="upload-btn" @click="openVideoSelector">
<!-- <image src="https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260316/order-empty_1773628059920.png"
class="camera-icon" mode="aspectFill" />videoFilePath.value -->
<view class="form-row media-row">
<text class="form-label">动作视频 <text class="optional">(非必填)</text></text>
<view class="media-upload" @click="openVideoSelector">
<template v-if="videoFilePath">
<video v-if="isVideoFile" :src="videoFilePath" class="preview-media" :controls="false"
:show-fullscreen-btn="false" :enable-progress-gesture="false"></video>
<!-- 图片类型 -->
<image v-else :src="videoFilePath" class="preview-media" mode="aspectFill" />
<video v-if="isVideoFile" :src="videoFilePath" class="media-preview" :controls="false"
:show-fullscreen-btn="false" :enable-progress-gesture="false" />
<image v-else :src="videoFilePath" class="media-preview" mode="aspectFill" />
<view class="media-replace-btn">重新选择</view>
</template>
<template v-else>
<up-icon name="camera" color="#333" size="16"></up-icon>
<text class="btn-text">图片/视频</text>
<up-icon name="camera" color="#999" size="28" />
<text class="media-placeholder">图片 / 视频</text>
</template>
</view>
</view>
<!-- 视频详解 -->
<view class="form-item" @click="chooseTutorialVideo">
<view class="label">视频详解</view>
<view class="value-wrapper">
<text class="value">{{ videoDetailName || '上传详解视频(非必填)' }}</text>
<view class="upload-btn">
<view class="form-row media-row">
<text class="form-label">视频详解 <text class="optional">(非必填)</text></text>
<view class="media-upload" @click="chooseTutorialVideo">
<template v-if="urlTutorial">
<video :src="urlTutorial" class="preview-media" :controls="false" :show-fullscreen-btn="false"
:enable-progress-gesture="false"></video>
<video :src="urlTutorial" class="media-preview" :controls="false" :show-fullscreen-btn="false"
:enable-progress-gesture="false" />
<view class="media-replace-btn">重新选择</view>
</template>
<template v-else>
<up-icon name="camera" color="#333" size="16" />
<text class="btn-text">添加视频</text>
<up-icon name="play-right" color="#999" size="28" />
<text class="media-placeholder">添加详解视频</text>
</template>
</view>
</view>
</view>
<view class="form-item" v-if="isEdit" @click="deleteAction">
<view class="center-label">删除动作</view>
<!-- 删除动作(编辑模式) -->
<view v-if="isEdit" class="form-row delete-row" @click="deleteAction">
<up-icon name="trash" color="#e74c3c" size="18" />
<text class="delete-text">删除动作</text>
</view>
</view>
<!-- 保存按钮 -->
<view class="save-button">
<button class="btn-save" :disabled="!exerciseName.trim()" @click="saveExercise">保存</button>
<!-- 保存按钮(卡片外、贴近底部) -->
<view class="save-bar">
<view class="btn-save" :class="{ disabled: !exerciseName.trim() }" @click="saveExercise">
保存
</view>
</view>
<!-- 动作类型选择弹窗 -->
<view v-if="showActionTypePicker" class="popup-overlay" @click="closeAllPopups">
<view class="popup-content" @click.stop>
<view class="popup-header">
<view class="close-btn" @click="closeAllPopups">×</view>
<view class="title">选择动作类型</view>
<view class="confirm-btn" @click="confirmActionType">确定</view>
<!-- ==================== 弹窗:肌肉部位选择 ==================== -->
<up-popup :show="showMusclePicker" mode="bottom" round="16rpx" @close="showMusclePicker = false"
:safe-area-inset-bottom="true">
<view class="popup-body">
<view class="popup-head">
<text class="popup-title">选择训练部位</text>
<view class="popup-close" @click="showMusclePicker = false">
<up-icon name="close" color="#666" size="18" />
</view>
<picker-view :value="[actionTypeIndex]" @change="onActionTypeChange" class="picker-view">
<picker-view-column>
<view class="picker-item" v-for="item in actionTypeOptions" :key="item">{{
item.name
}}</view>
</picker-view-column>
</picker-view>
</view>
<view class="muscle-list">
<view v-for="group in muscleGroups" :key="group.id" class="muscle-item">
<text class="muscle-name">{{ group.name }}</text>
<view class="muscle-actions">
<view class="tag primary" :class="{ on: primaryMuscles.includes(group.id) }"
@click.stop="togglePrimary(group.id)">
<text>主要</text>
</view>
<view class="tag secondary" :class="{ on: secondaryMuscles.includes(group.id) }"
@click.stop="toggleSecondary(group.id)">
<text>次要</text>
</view>
<!-- 目录管理弹窗 -->
<view v-if="showDirectoryPicker" class="popup-overlay" @click="closeAllPopups">
<view class="popup-content" @click.stop>
<view class="popup-header">
<view class="close-btn" @click="closeAllPopups">×</view>
<view class="title">目录管理</view>
<view class="confirm-btn" @click="confirmDirectory">确定</view>
</view>
<view class="double-picker">
<picker-view :value="[bodyPartIndex]" @change="onBodyPartChange" class="picker-view half">
<picker-view-column>
<view class="picker-item" v-for="item in bodyPartOptions" :key="item">{{
item.name
}}</view>
</picker-view-column>
</picker-view>
<picker-view :value="[equipmentIndex]" @change="onEquipmentChange" class="picker-view half">
<picker-view-column>
<view class="picker-item" v-for="item in equipmentOptions" :key="item">{{
item.name
}}</view>
</picker-view-column>
</picker-view>
</view>
</view>
<view class="popup-foot">
<view class="popup-done" @click="confirmMuscleSelection">完成</view>
</view>
</view>
</up-popup>
<!-- ==================== 弹窗:动作类型选择 ==================== -->
<up-picker
:show="showActionTypePicker"
:columns="actionTypeColumns"
:default-index="[actionTypeIndex]"
title="选择动作类型"
@confirm="onActionTypeConfirm"
@cancel="showActionTypePicker = false"
close-on-click-overlay
/>
<!-- ==================== 弹窗:目录管理 ==================== -->
<up-picker
:show="showDirectoryPicker"
:columns="directoryColumns"
:default-index="[bodyPartIndex, equipmentIndex]"
title="目录管理"
@confirm="onDirectoryConfirm"
@cancel="showDirectoryPicker = false"
close-on-click-overlay
/>
</view>
</template>
<script setup>
import { onMounted, ref } from 'vue';
import { ref, computed } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import ExercisesApi from '@/sheep/api/motion/exercises';
import LeftmotionApi from '@/sheep/api/motion/equipments';
import EquipmentListApi from '@/sheep/api/motion/motionequipments';
import FileApi from '@/sheep/api/infra/file';
// 表单数据
// ==================== 基础状态 ====================
const isEdit = ref(false);
const editActionId = ref(null);
const actionDetail = ref({});
// ==================== 表单数据 ====================
const exerciseName = ref('');
const actionType = ref();
const directoryName = ref();
const actionType = ref('');
const directoryName = ref('');
const selectedMusclesDisplay = ref('未选择');
const editActionId = ref(null)
// 选中索引
// ==================== picker 索引 ====================
const actionTypeIndex = ref(0);
const bodyPartIndex = ref(0);
const equipmentIndex = ref(0);
// 这是后端固定写死的,不要改
// ==================== 动作类型选项(后端固定) ====================
const actionTypeOptions = ref([
{ id: 0, name: '无氧运动' },
{ id: 1, name: '有氧运动' },
... ... @@ -210,343 +187,290 @@ const actionTypeOptions = ref([
{ id: 4, name: '自重加重' },
{ id: 5, name: '自重减重' },
{ id: 6, name: '间歇训练' },
])
// // 加载动作分类(类型)
// const loadexercisesGroups = async () => {
// try {
// const res = await ExercisesApi.getAllMotionCategories();
// actionTypeOptions.value = res.data;
// console.log('加载动作分类', actionTypeOptions.value)
// } catch (err) {
// console.error('加载部位失败', err);
// }
// };
]);
const resetForm = () => {
exerciseName.value = ''
actionType.value = ''
actionTypeIndex.value = 0
directoryName.value = ''
bodyPartIndex.value = 0
equipmentIndex.value = 0
selectedMusclesDisplay.value = '未选择'
primaryMuscles.value = []
secondaryMuscles.value = []
coverImagePath.value = 'https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260316/order-empty_1773628059920.png'
coverUploaded.value = false
videoFilePath.value = ''
videoUploaded.value = false
isVideoFile.value = false
urlTutorial.value = ''
videoDetailName.value = ''
// 同时关闭所有弹窗,防止上次弹窗残留
closeAllPopups()
}
// up-picker 需要的 columns 格式:[['无氧运动', '有氧运动', ...]]
const actionTypeColumns = computed(() => [actionTypeOptions.value.map((item) => item.name)]);
const isEdit = ref(false)
const actionDetail = ref({});
// ==================== 肌肉部位 ====================
const muscleGroups = ref([]);
const primaryMuscles = ref([]);
const secondaryMuscles = ref([]);
const loadMuscleGroups = async () => {
try {
const res = await ExercisesApi.getMotionPartPage();
muscleGroups.value = res.data;
} catch (err) {
console.error('加载肌肉部位失败', err);
}
};
// ==================== 目录选项 ====================
const bodyPartOptions = ref([]);
const loadbodyPartOptions = async () => {
const res = await LeftmotionApi.getAllCategories();
bodyPartOptions.value = res.data;
};
const equipmentOptions = ref([]);
const loadequipmentOptions = async () => {
const res = await EquipmentListApi.Getallequipments();
equipmentOptions.value = res.data;
};
// up-picker 双列格式:[['大腿', '大臂', ...], ['器械', '杠铃', ...]]
const directoryColumns = computed(() => [
bodyPartOptions.value.map((item) => item.name),
equipmentOptions.value.map((item) => item.name),
]);
// ==================== 弹窗状态 ====================
const showMusclePicker = ref(false);
const showActionTypePicker = ref(false);
const showDirectoryPicker = ref(false);
// ==================== 上传状态 ====================
const DEFAULT_COVER = 'https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260724/动作_1784878189633.jpg';
const coverImagePath = ref(DEFAULT_COVER);
const coverUploaded = ref(false);
const videoFilePath = ref('');
const videoUploaded = ref(false);
const isVideoFile = ref(false);
const urlTutorial = ref('');
const videoDetailName = ref('');
// ==================== 表单重置 ====================
const resetForm = () => {
exerciseName.value = '';
actionType.value = '';
actionTypeIndex.value = 0;
directoryName.value = '';
bodyPartIndex.value = 0;
equipmentIndex.value = 0;
selectedMusclesDisplay.value = '未选择';
primaryMuscles.value = [];
secondaryMuscles.value = [];
coverImagePath.value = DEFAULT_COVER;
coverUploaded.value = false;
videoFilePath.value = '';
videoUploaded.value = false;
isVideoFile.value = false;
urlTutorial.value = '';
videoDetailName.value = '';
showMusclePicker.value = false;
showActionTypePicker.value = false;
showDirectoryPicker.value = false;
};
// ==================== 页面加载 ====================
onLoad(async (options) => {
isEdit.value = options.isEdit === 'true';
const editId = Number(options.id);
editActionId.value = editId;
isEdit.value = options.isEdit === 'true'
const editId = Number(options.id)
editActionId.value = editId
console.log('编辑状态', isEdit, '编辑动作ID', editId)
// 清空表单状态
resetForm()
resetForm();
await Promise.all([
loadbodyPartOptions(),
loadequipmentOptions(),
loadMuscleGroups()
])
loadMuscleGroups(),
]);
if (isEdit.value) {
loadexercisedetail(editId)
loadexercisedetail(editId);
}
});
})
// 加载单个动作详情
// const loadexercisedetail = async (id) => {
// if (!id || !Number(id)) return
// try {
// const response = await ExercisesApi.getExerciseById(id);
// actionDetail.value = response.data;
// console.log('打印动作详情', actionDetail.value);
// } catch (err) {
// console.error('加载动作详情失败:', err)
// actionDetail.value = {}
// }
// };
// 加载单个动作详情并回显表单
// ==================== 编辑回显 ====================
const loadexercisedetail = async (id) => {
if (!id || !Number(id)) return
if (!id || !Number(id)) return;
try {
const response = await ExercisesApi.getExerciseById(id);
actionDetail.value = response.data;
const detail = actionDetail.value
console.log('打印动作详情', detail);
const detail = actionDetail.value;
// 1. 动作名称
exerciseName.value = detail.name || ''
exerciseName.value = detail.name || '';
// 2. 动作类型回显(id匹配,编辑只读)
const targetType = actionTypeOptions.value.find(item => item.id === detail.exerciseType)
const targetType = actionTypeOptions.value.find((item) => item.id === detail.exerciseType);
if (targetType) {
actionTypeIndex.value = actionTypeOptions.value.findIndex(item => item.id === detail.exerciseType)
actionType.value = targetType.name
actionTypeIndex.value = actionTypeOptions.value.findIndex((item) => item.id === detail.exerciseType);
actionType.value = targetType.name;
}
// 3. 目录:身体部位categoryId + 器械equipmentId 匹配下标
const bodyIdx = bodyPartOptions.value.findIndex(item => item.id === detail.categoryId)
if (bodyIdx > -1) bodyPartIndex.value = bodyIdx
const equipIdx = equipmentOptions.value.findIndex(item => item.id === detail.equipmentId)
if (equipIdx > -1) equipmentIndex.value = equipIdx
// 拼接目录展示文字
const selBody = bodyPartOptions.value[bodyIdx]
const selEquip = equipmentOptions.value[equipIdx]
const bodyIdx = bodyPartOptions.value.findIndex((item) => item.id === detail.categoryId);
if (bodyIdx > -1) bodyPartIndex.value = bodyIdx;
const equipIdx = equipmentOptions.value.findIndex((item) => item.id === detail.equipmentId);
if (equipIdx > -1) equipmentIndex.value = equipIdx;
const selBody = bodyPartOptions.value[bodyIdx];
const selEquip = equipmentOptions.value[equipIdx];
if (selBody && selEquip) {
directoryName.value = ` ${selBody.name}( ${selEquip.name})`
directoryName.value = `${selBody.name} (${selEquip.name})`;
}
// 4. 肌肉部位:后端返回字符串 "[30,28,29]" 转数字数组
if (detail.primaryMuscles) {
primaryMuscles.value = JSON.parse(detail.primaryMuscles)
} else {
primaryMuscles.value = []
}
if (detail.secondaryMuscles) {
secondaryMuscles.value = JSON.parse(detail.secondaryMuscles)
} else {
secondaryMuscles.value = []
}
// 直接使用后端返回的拼接好的肌肉名称展示文本,不用重新拼接
const primaryStr = detail.primaryMuscleNames?.length ? detail.primaryMuscleNames.join('、') + '(主)' : ''
const secondaryStr = detail.secondaryMuscleNames?.length ? detail.secondaryMuscleNames.join('、') + '(次)' : ''
let display = ''
if (primaryStr) display += primaryStr
primaryMuscles.value = detail.primaryMuscles ? JSON.parse(detail.primaryMuscles) : [];
secondaryMuscles.value = detail.secondaryMuscles ? JSON.parse(detail.secondaryMuscles) : [];
const primaryStr = detail.primaryMuscleNames?.length ? detail.primaryMuscleNames.join('、') + '(主)' : '';
const secondaryStr = detail.secondaryMuscleNames?.length ? detail.secondaryMuscleNames.join('、') + '(次)' : '';
let display = '';
if (primaryStr) display += primaryStr;
if (secondaryStr) {
if (display) display += ', '
display += secondaryStr
if (display) display += ', ';
display += secondaryStr;
}
selectedMusclesDisplay.value = display || '未选择'
selectedMusclesDisplay.value = display || '未选择';
// 5. 封面图 url3dAnimation
if (detail.url3dAnimation) {
coverImagePath.value = detail.url3dAnimation
coverUploaded.value = true // 标记已有封面,跳过新增封面校验
coverImagePath.value = detail.url3dAnimation;
coverUploaded.value = true;
}
// 6. 动作实拍视频/图片 urlRealPerson
if (detail.urlRealPerson) {
videoFilePath.value = detail.urlRealPerson
videoUploaded.value = true
// 简单判断是否视频(后端返回mp4则标记video)
isVideoFile.value = detail.urlRealPerson.includes('.mp4')
videoFilePath.value = detail.urlRealPerson;
videoUploaded.value = true;
isVideoFile.value = detail.urlRealPerson.includes('.mp4');
}
// 7. 详解视频 urlTutorial
if (detail.urlTutorial) {
urlTutorial.value = detail.urlTutorial
videoDetailName.value = '已选择详解视频'
urlTutorial.value = detail.urlTutorial;
videoDetailName.value = '已选择详解视频';
}
} catch (err) {
console.error('加载动作详情失败:', err)
actionDetail.value = {}
console.error('加载动作详情失败:', err);
actionDetail.value = {};
}
};
// 肌肉部位列表(从接口获取)
const muscleGroups = ref([]);
// 加载肌肉部位
const loadMuscleGroups = async () => {
try {
const res = await ExercisesApi.getMotionPartPage();
muscleGroups.value = res.data;
console.log('加载部位分类:', muscleGroups.value)
} catch (err) {
console.error('加载部位失败', err);
}
// ==================== 动作类型 picker ====================
const openActionTypePicker = () => {
if (isEdit.value) return;
showActionTypePicker.value = true;
};
// 主要和次要部位(存储 ID 数组)
const primaryMuscles = ref([]); // 主要部位 ID 列表
const secondaryMuscles = ref([]); // 次要部位 ID 列表
const onActionTypeConfirm = (res) => {
const idx = res.indexs[0];
actionType.value = actionTypeOptions.value[idx].name;
actionTypeIndex.value = idx;
showActionTypePicker.value = false;
};
// 弹窗控制
const showMusclePicker = ref(false);
// ==================== 目录 picker ====================
const onDirectoryConfirm = (res) => {
const [bodyIdx, equipIdx] = res.indexs;
bodyPartIndex.value = bodyIdx;
equipmentIndex.value = equipIdx;
const selBody = bodyPartOptions.value[bodyIdx];
const selEquip = equipmentOptions.value[equipIdx];
directoryName.value = `${selBody.name} (${selEquip.name})`;
showDirectoryPicker.value = false;
};
// 部位分类
const bodyPartOptions = ref([]);
const loadbodyPartOptions = async () => {
const res = await LeftmotionApi.getAllCategories();
bodyPartOptions.value = res.data;
// ==================== 肌肉部位选择 ====================
const togglePrimary = (id) => {
if (primaryMuscles.value.includes(id)) {
primaryMuscles.value = primaryMuscles.value.filter((i) => i !== id);
} else {
primaryMuscles.value.push(id);
secondaryMuscles.value = secondaryMuscles.value.filter((i) => i !== id);
}
};
// 用具分类
const equipmentOptions = ref([]);
const loadequipmentOptions = async () => {
const res = await EquipmentListApi.Getallequipments();
equipmentOptions.value = res.data;
const toggleSecondary = (id) => {
if (secondaryMuscles.value.includes(id)) {
secondaryMuscles.value = secondaryMuscles.value.filter((i) => i !== id);
} else {
secondaryMuscles.value.push(id);
primaryMuscles.value = primaryMuscles.value.filter((i) => i !== id);
}
};
// 弹窗状态
const showActionTypePicker = ref(false);
const showDirectoryPicker = ref(false);
const showCoverSelector = ref(false);
const showVideoSelector = ref(false);
const confirmMuscleSelection = () => {
const primaryNames = primaryMuscles.value
.map((id) => muscleGroups.value.find((g) => g.id === id)?.name || '')
.filter(Boolean);
const secondaryNames = secondaryMuscles.value
.map((id) => muscleGroups.value.find((g) => g.id === id)?.name || '')
.filter(Boolean);
// 弹窗控制
const openActionTypePicker = () => {
if (isEdit.value) return;
showActionTypePicker.value = true;
};
const openDirectoryPicker = () => {
showDirectoryPicker.value = true;
let displayText = '';
if (primaryNames.length) displayText += primaryNames.join('、') + '(主)';
if (secondaryNames.length) {
if (displayText) displayText += ', ';
displayText += secondaryNames.join('、') + '(次)';
}
selectedMusclesDisplay.value = displayText || '未选择';
showMusclePicker.value = false;
};
// 封面图片路径
const coverImagePath = ref('https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260316/order-empty_1773628059920.png');
// 视频路径(可选)
const videoFilePath = ref('');
// ✅ 新增:标记是否上传成功(用于判断)
const coverUploaded = ref(false);
const videoUploaded = ref(false);
// 视频详解
const urlTutorial = ref('');
const videoDetailName = ref('');
// ==================== 视频详解(上传组件)====================
const chooseTutorialVideo = () => {
uni.chooseMedia({
// ==================== 文件上传 ====================
const openCoverSelector = () => {
uni.chooseImage({
count: 1,
mediaType: ['video'], // 只允许选视频
success: async (res) => {
const file = res.tempFiles[0];
const result = await FileApi.uploadFile(file.tempFilePath);
urlTutorial.value = result.data;
videoDetailName.value = '已选择详解视频';
uni.showToast({ title: '选择成功', icon: 'success' });
const path = res.tempFilePaths[0];
uni.showLoading({ title: '上传中...' });
try {
const result = await FileApi.uploadFile(path);
coverImagePath.value = result.data;
coverUploaded.value = true;
uni.showToast({ title: '封面上传成功', icon: 'success' });
} catch (err) {
uni.showToast({ title: '上传失败', icon: 'none' });
} finally {
uni.hideLoading();
}
},
fail: () => {
uni.showToast({ title: '选择失败', icon: 'none' });
}
})
coverUploaded.value = false;
uni.showToast({ title: '封面选择失败', icon: 'none' });
},
});
};
const isVideoFile = ref(false)
// 打开视频/图片选择器 + 上传成功判断 这里上传的是3D的那个字段
const openVideoSelector = () => {
uni.chooseMedia({
count: 1,
mediaType: ['image', 'video'],
success: async (res) => {
const file = res.tempFiles[0];
console.log('选择的文件类型:', file.fileType);
isVideoFile.value = file.fileType === 'video';
// 上传到服务器
const result = await FileApi.uploadFile(file.tempFilePath);
videoFilePath.value = result.data;
videoUploaded.value = true; // 标记视频上传成功
videoUploaded.value = true;
uni.showToast({
title: file.fileType === 'video' ? '视频选择成功' : '图片选择成功',
icon: "success"
icon: 'success',
});
},
fail: () => {
videoUploaded.value = false; // ❌ 选择失败
uni.showToast({ title: "选择失败", icon: "none" });
}
})
videoUploaded.value = false;
uni.showToast({ title: '选择失败', icon: 'none' });
},
});
};
// 封面选择 + 真实上传
const openCoverSelector = () => {
uni.chooseImage({
const chooseTutorialVideo = () => {
uni.chooseMedia({
count: 1,
mediaType: ['video'],
success: async (res) => {
const path = res.tempFilePaths[0];
uni.showLoading({ title: "上传中..." });
// 封面上传
try {
const result = await FileApi.uploadFile(path);
console.log(res, 'res');
coverImagePath.value = result.data;
console.log('coverImagePath', coverImagePath.value);
coverUploaded.value = true;
uni.showToast({
title: "封面上传成功",
icon: "success"
});
} catch (err) {
uni.showToast({
title: "上传失败",
icon: "none"
});
} finally {
uni.hideLoading();
}
const file = res.tempFiles[0];
const result = await FileApi.uploadFile(file.tempFilePath);
urlTutorial.value = result.data;
videoDetailName.value = '已选择详解视频';
uni.showToast({ title: '选择成功', icon: 'success' });
},
fail: () => {
coverUploaded.value = false;
uni.showToast({
title: "封面选择失败",
icon: "none"
});
}
uni.showToast({ title: '选择失败', icon: 'none' });
},
});
};
const closeAllPopups = () => {
showActionTypePicker.value = false;
showDirectoryPicker.value = false;
showCoverSelector.value = false;
showVideoSelector.value = false;
showMusclePicker.value = false;
// showVideoDetailPicker.value = false;
};
// 动作类型
const onActionTypeChange = (e) => {
actionTypeIndex.value = e.detail.value[0];
};
// 动作分类列表
const confirmActionType = () => {
const actionstype = actionTypeOptions.value[actionTypeIndex.value];
actionType.value = `${actionstype.name}`;
closeAllPopups();
};
// 目录管理
const onBodyPartChange = (e) => {
bodyPartIndex.value = e.detail.value[0];
};
const onEquipmentChange = (e) => {
equipmentIndex.value = e.detail.value[0];
};
const confirmDirectory = () => {
const selectedBodyPart = bodyPartOptions.value[bodyPartIndex.value];
const selectedEquipment = equipmentOptions.value[equipmentIndex.value];
directoryName.value = ` ${selectedBodyPart.name}( ${selectedEquipment.name})`;
closeAllPopups();
};
// =================================保存动作====================
// ==================== 保存 & 删除 ====================
const saveExercise = async () => {
if (!exerciseName.value.trim()) {
uni.showToast({ title: '请输入动作名称', icon: 'none' });
return;
}
console.log('--', equipmentOptions.value[equipmentIndex.value]);
console.log('--', bodyPartOptions.value[bodyPartIndex.value]);
// 获取 categoryId 和 equipmentId
const selectedBodyPart = bodyPartOptions.value[bodyPartIndex.value];
const selectedEquipment = equipmentOptions.value[equipmentIndex.value];
if (!selectedBodyPart || !selectedEquipment) {
... ... @@ -554,66 +478,55 @@ const saveExercise = async () => {
return;
}
// 动作类型映射:label -> value
const selectedActionType = actionTypeOptions.value[actionTypeIndex.value];
if (!selectedActionType) {
uni.showToast({ title: '请选择动作类型', icon: 'none' });
return;
}
// 提示选择封面
if (!coverUploaded.value) {
uni.showToast({ title: '请选择封面', icon: 'none' });
return;
}
// 视频非必填,所以不强制,但可以提示
if (videoFilePath.value && !videoUploaded.value) {
uni.showToast({ title: '视频未选择成功', icon: 'none' });
return;
}
// 构建请求参数
const payload = {
name: exerciseName.value.trim(),
categoryId: selectedBodyPart.id, // 注意:必须是 id,不是 name
equipmentId: selectedEquipment.id, // 同上
exerciseType: selectedActionType.id, // 这里传数字 1/2/3
primaryMuscles: JSON.stringify(primaryMuscles.value), //后端要求改json格式
categoryId: selectedBodyPart.id,
equipmentId: selectedEquipment.id,
exerciseType: selectedActionType.id,
primaryMuscles: JSON.stringify(primaryMuscles.value),
secondaryMuscles: JSON.stringify(secondaryMuscles.value),
// urlImage: coverImagePath.value, // 封面地址
url3dAnimation: coverImagePath.value,
urlRealPerson: videoFilePath.value, //真人实拍视频
urlTutorial: urlTutorial.value, // 详解视频
urlRealPerson: videoFilePath.value,
urlTutorial: urlTutorial.value,
};
// 编辑追加id
if (isEdit.value) {
payload.id = editActionId.value
payload.id = editActionId.value;
}
console.log('【发送的 payload】', payload);
let res;
try {
if (isEdit.value) {
res = await ExercisesApi.updateexercises(payload);
console.log('【更新接口返回】', res);
} else {
res = await ExercisesApi.createExercise(payload);
console.log('【创建接口返回】', res);
}
const res = isEdit.value
? await ExercisesApi.updateexercises(payload)
: await ExercisesApi.createExercise(payload);
if (res.code === 0) {
uni.showToast({ title: isEdit.value ? '编辑成功' : '保存成功', icon: 'success' });
setTimeout(() => {
uni.navigateBack();
}, 1000);
setTimeout(() => uni.navigateBack(), 1000);
} else {
uni.showToast({ title: res.msg || '保存失败', icon: 'none' });
}
} catch (error) {
console.error('创建动作失败:', error);
console.error('保存动作失败:', error);
uni.showToast({ title: '网络错误,请重试', icon: 'none' });
}
};
// 删除动作
const deleteAction = () => {
uni.showModal({
title: '提示',
... ... @@ -621,501 +534,384 @@ const deleteAction = () => {
success: async (res) => {
if (res.confirm) {
try {
const delRes = await ExercisesApi.deleteexercises(editActionId.value)
const delRes = await ExercisesApi.deleteexercises(editActionId.value);
if (delRes.code === 0) {
uni.showToast({ title: '删除成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 1000)
uni.showToast({ title: '删除成功', icon: 'success' });
setTimeout(() => uni.navigateBack(), 1000);
} else {
uni.showToast({ title: delRes.msg || '删除失败', icon: 'none' })
uni.showToast({ title: delRes.msg || '删除失败', icon: 'none' });
}
} catch (err) {
uni.showToast({ title: '删除请求失败', icon: 'none' })
}
}
}
})
}
const openMusclePicker = () => {
showMusclePicker.value = true;
};
const togglePrimary = (id) => {
if (primaryMuscles.value.includes(id)) {
primaryMuscles.value = primaryMuscles.value.filter((i) => i !== id);
} else {
primaryMuscles.value.push(id);
// 自动取消次要
secondaryMuscles.value = secondaryMuscles.value.filter((i) => i !== id);
}
};
const toggleSecondary = (id) => {
if (secondaryMuscles.value.includes(id)) {
secondaryMuscles.value = secondaryMuscles.value.filter((i) => i !== id);
} else {
secondaryMuscles.value.push(id);
// 自动取消主要
primaryMuscles.value = primaryMuscles.value.filter((i) => i !== id);
}
};
const confirmMuscleSelection = () => {
// 构建显示文本,例如:“胸部(主), 背部(次)”
const primaryNames = primaryMuscles.value
.map((id) => muscleGroups.value.find((g) => g.id === id)?.name || '')
.filter(Boolean);
const secondaryNames = secondaryMuscles.value
.map((id) => muscleGroups.value.find((g) => g.id === id)?.name || '')
.filter(Boolean);
let displayText = '';
if (primaryNames.length) {
displayText += primaryNames.join('、') + '(主)';
uni.showToast({ title: '删除请求失败', icon: 'none' });
}
if (secondaryNames.length) {
if (displayText) displayText += ', ';
displayText += secondaryNames.join('、') + '(次)';
}
// 可以存到一个新字段,比如 selectedMusclesDisplay
selectedMusclesDisplay.value = displayText || '未选择';
closeAllPopups();
},
});
};
onMounted(() => {
// loadbodyPartOptions();
// loadequipmentOptions();
// loadMuscleGroups();
// loadexercisesGroups();
});
</script>
<style scoped>
.add-custom-exercise-page {
background-color: #1a1a1a;
<style lang="scss" scoped>
// ==================== 变量 ====================
$bg-dark: #1c1c1e;
$card-bg: #fff;
$card-radius: 24rpx;
$primary: #ff9500;
$primary-dark: #e68600;
$text-main: #1a1a1a;
$text-sub: #666;
$text-hint: #999;
$divider: #f0f0f0;
// ==================== 页面 ====================
.page {
min-height: 100vh;
padding: 20rpx;
color: white;
}
/* 肌肉部位图 */
.muscle-container {
display: flex;
justify-content: space-between;
align-items: center;
gap: 40rpx;
background: $bg-dark;
padding: 24rpx 24rpx 0;
color: #fff;
}
/* 肌肉部位图容器 - 占满红色框,无多余内边距 */
.muscle-area {
background: #1a1a1a;
border-radius: 20rpx;
/* 移除原有padding,让容器完全贴合红色框 */
padding: 0;
margin: 20rpx;
// ==================== 肌肉部位卡片 ====================
.muscle-card {
position: relative;
/* 固定容器高度,适配移动端,可根据需求调整 */
height: 500rpx;
margin-bottom: 24rpx;
border-radius: $card-radius;
height: 440rpx;
overflow: hidden;
}
/* 肌肉图样式 - 100%占满容器,保持比例不拉伸 */
.muscle-image-front {
.muscle-img {
width: 100%;
height: 100%;
/* aspectFill:保持比例,裁剪超出部分,完美占满容器 */
/* 若要完整显示图片,改为 mode="aspectFit",同时调整容器高度 */
object-fit: cover;
opacity: 1;
/* 移除原有半透明,清晰显示 */
}
.muscle-image-back {
transform: rotateY(180deg);
}
.legend {
display: flex;
justify-content: center;
margin-top: 20rpx;
font-size: 24rpx;
color: #ccc;
}
}
.legend-item {
.muscle-tip {
position: absolute;
bottom: 20rpx;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
margin: 0 20rpx;
}
.dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
margin-right: 10rpx;
}
.primary {
background-color: #ff9900;
}
.secondary {
background-color: #66ccff;
gap: 8rpx;
padding: 12rpx 28rpx;
background: rgba(0, 0, 0, 0.55);
border-radius: 40rpx;
backdrop-filter: blur(10rpx);
font-size: 24rpx;
color: rgba(255, 255, 255, 0.9);
}
}
/* 表单区域 */
.form-section {
background: white;
border-radius: 20rpx;
// ==================== 表单卡片 ====================
.form-card {
background: $card-bg;
border-radius: $card-radius;
overflow: hidden;
margin-top: 20rpx;
}
.form-item {
display: flex;
flex-direction: column;
padding: 30rpx 30rpx 30rpx 40rpx;
border-bottom: 1rpx solid #eee;
position: relative;
}
// ---- 通用行 ----
.form-row {
padding: 28rpx 32rpx;
border-bottom: 1rpx solid $divider;
transition: background 0.15s;
.action-name {
width: 285rpx;
}
&:last-of-type {
border-bottom: none;
}
.label {
font-size: 32rpx;
color: #333;
margin-bottom: 10rpx;
}
.form-label {
font-size: 30rpx;
font-weight: 600;
color: $text-main;
display: block;
.center-label {
text-align: center;
color: #c44b4b;
.optional {
font-size: 24rpx;
font-weight: 400;
color: $text-hint;
}
}
}
.input-group {
// ---- 动作名称行(横排) ----
.name-row {
display: flex;
align-items: center;
position: relative;
}
align-items: flex-start;
gap: 24rpx;
.input-field {
.name-input-area {
flex: 1;
font-size: 28rpx;
color: #333;
padding: 0;
background: none;
border: none;
outline: none;
}
.input-placeholder {
color: #999;
}
min-width: 0;
.icon-wrapper {
width: 40rpx;
height: 40rpx;
margin-left: 10rpx;
}
.form-label {
margin-bottom: 12rpx;
}
.edit-icon {
width: 24rpx;
height: 24rpx;
}
.name-input {
height: 72rpx;
font-size: 28rpx;
color: $text-main;
background: #f5f5f5;
border-radius: 12rpx;
padding: 0 20rpx;
}
}
.cover-btn {
position: absolute;
right: 30rpx;
top: 30rpx;
.cover-upload {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100rpx;
height: 100rpx;
background: #f0f0f0;
border-radius: 10rpx;
padding: 10rpx;
}
gap: 8rpx;
.cover-icon {
width: 40rpx;
height: 40rpx;
}
.cover-preview {
width: 140rpx;
height: 140rpx;
border-radius: 14rpx;
background: #f5f5f5;
}
.btn-text {
font-size: 20rpx;
color: #333;
margin-top: 5rpx;
.cover-label {
font-size: 22rpx;
color: $text-hint;
}
}
}
.value-wrapper {
// ---- 选择行(训练部位/动作类型/目录管理) ----
.select-row {
display: flex;
align-items: center;
justify-content: space-between;
&:active {
background: #fafafa;
}
.select-value {
display: flex;
align-items: center;
font-size: 28rpx;
color: #333;
margin-top: 10rpx;
}
gap: 8rpx;
max-width: 60%;
overflow: hidden;
.value {
.sel-text {
font-size: 26rpx;
color: #28c76f;
font-weight: bold;
}
.arrow-icon {
width: 20rpx;
height: 20rpx;
transform: rotate(90deg);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
.upload-btn {
position: absolute;
right: 30rpx;
top: 30rpx;
// ---- 媒体上传行(动作视频/视频详解) ----
.media-row {
.media-upload {
margin-top: 20rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100rpx;
height: 100rpx;
background: #f0f0f0;
border-radius: 10rpx;
padding: 10rpx;
}
.preview-media {
width: 100rpx;
height: 100rpx;
border-radius: 10rpx;
object-fit: cover;
}
/* 隐藏视频默认控制条(可选) */
.upload-btn ::deep video::-webkit-media-controls {
display: none !important;
}
width: 100%;
height: 200rpx;
background: #f8f8f8;
border-radius: 16rpx;
border: 2rpx dashed #ddd;
gap: 12rpx;
overflow: hidden;
position: relative;
transition: border-color 0.2s, background 0.2s;
.camera-icon {
width: 40rpx;
height: 40rpx;
}
&:active {
background: #f0f0f0;
border-color: #bbb;
}
.upload-text {
.media-placeholder {
font-size: 24rpx;
color: #333;
margin-top: 5rpx;
}
.play-icon {
width: 30rpx;
height: 30rpx;
}
.save-button {
margin: 40rpx 30rpx 0;
}
color: $text-hint;
}
.btn-save {
.media-preview {
position: absolute;
inset: 0;
width: 100%;
height: 80rpx;
background: linear-gradient(90deg, #ff9900, #ff6600);
color: white;
border-radius: 40rpx;
font-size: 32rpx;
font-weight: bold;
border: none;
outline: none;
}
/* 弹窗通用样式 */
.popup-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.4);
z-index: 999;
display: flex;
justify-content: flex-end;
align-items: flex-end;
}
height: 100%;
object-fit: cover;
}
.popup-content {
width: 100%;
background: white;
border-radius: 16rpx 16rpx 0 0;
max-height: 70vh;
overflow: hidden;
.media-replace-btn {
position: absolute;
bottom: 16rpx;
right: 16rpx;
padding: 6rpx 18rpx;
background: rgba(0, 0, 0, 0.55);
border-radius: 20rpx;
font-size: 22rpx;
color: #fff;
backdrop-filter: blur(8rpx);
}
}
}
.popup-header {
// ---- 删除行 ----
.delete-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 32rpx;
border-bottom: 1rpx solid #eee;
}
justify-content: center;
gap: 10rpx;
padding: 32rpx;
.close-btn {
font-size: 40rpx;
color: #333;
}
.delete-text {
font-size: 30rpx;
color: #e74c3c;
font-weight: 500;
}
.title {
font-size: 32rpx;
font-weight: bold;
margin: 0;
&:active {
background: #fef2f2;
}
}
.confirm-btn {
width: 80rpx;
height: 40rpx;
line-height: 40rpx;
background-color: #ff9900;
color: white;
border-radius: 20rpx;
// ==================== 保存按钮栏 ====================
.save-bar {
padding: 24rpx 0;
// 预留底部安全区
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
.btn-save {
height: 88rpx;
line-height: 88rpx;
text-align: center;
font-size: 28rpx;
}
background: linear-gradient(135deg, $primary, $primary-dark);
color: #fff;
border-radius: 44rpx;
font-size: 32rpx;
font-weight: 700;
letter-spacing: 2rpx;
transition: opacity 0.2s, transform 0.15s;
.picker-view {
height: 200rpx;
width: 100%;
}
&:active {
transform: scale(0.98);
}
.picker-item {
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
color: #333;
height: 100%;
&.disabled {
opacity: 0.4;
pointer-events: none;
}
}
}
.double-picker {
// ==================== 弹窗通用 ====================
.popup-body {
background: #fff;
border-radius: 16rpx 16rpx 0 0;
max-height: 65vh;
display: flex;
flex-direction: column;
}
.double-picker .picker-view.half {
width: 50%;
}
/* 封面预览 */
.cover-preview {
.popup-head {
display: flex;
align-items: center;
justify-content: center;
margin: 30rpx 0;
}
padding: 28rpx 32rpx;
border-bottom: 1rpx solid $divider;
position: relative;
.cover-image {
width: 200rpx;
height: 200rpx;
}
.popup-title {
font-size: 32rpx;
font-weight: 700;
color: $text-main;
}
/* 上传区域 */
.upload-area {
.popup-close {
position: absolute;
right: 32rpx;
top: 50%;
transform: translateY(-50%);
width: 48rpx;
height: 48rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40rpx;
}
}
.upload-icon {
width: 60rpx;
height: 60rpx;
}
.popup-foot {
padding: 20rpx 32rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
border-top: 1rpx solid $divider;
.upload-text {
font-size: 28rpx;
color: #666;
margin-top: 10rpx;
}
.popup-done {
height: 80rpx;
line-height: 80rpx;
text-align: center;
background: linear-gradient(135deg, $primary, $primary-dark);
color: #fff;
border-radius: 40rpx;
font-size: 30rpx;
font-weight: 600;
transition: transform 0.15s;
/* === 美化后的肌肉选择列表 === */
.muscle-list-beautified {
padding: 20rpx 32rpx;
&:active {
transform: scale(0.97);
}
}
}
.muscle-item-beautified {
// ==================== 肌肉选择列表 ====================
.muscle-list {
overflow-y: auto;
padding: 8rpx 0;
flex: 1;
.muscle-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
padding: 22rpx 32rpx;
border-bottom: 1rpx solid $divider;
.muscle-name {
font-size: 32rpx;
color: #333;
.muscle-name {
font-size: 30rpx;
font-weight: 500;
color: $text-main;
flex: 1;
}
}
.selector-btn {
.muscle-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 12rpx 20rpx;
border-radius: 50rpx;
background-color: #f8f8f8;
transition: all 0.2s ease;
min-width: 120rpx;
text-align: center;
}
.selector-btn.primary {
border: 2rpx solid #ffcc99;
}
.selector-btn.primary.active {
background-color: #fff3e6;
border-color: #ff9900;
box-shadow: 0 4rpx 12rpx rgba(255, 153, 0, 0.2);
}
.selector-btn.secondary {
border: 2rpx solid #cce6ff;
}
.selector-btn.secondary.active {
background-color: #e6f7ff;
border-color: #66ccff;
box-shadow: 0 4rpx 12rpx rgba(102, 204, 255, 0.2);
}
.selector-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
}
.selector-dot.primary {
background-color: #ff9900;
}
.selector-dot.secondary {
background-color: #66ccff;
gap: 16rpx;
}
}
}
.selector-label {
font-size: 26rpx;
color: #666;
// ==================== 主要/次要 tag ====================
.tag {
padding: 10rpx 24rpx;
border-radius: 40rpx;
font-size: 24rpx;
font-weight: 500;
}
transition: all 0.2s;
background: #f5f5f5;
color: #999;
border: 2rpx solid transparent;
.selector-btn.primary.active .selector-label,
.selector-btn.secondary.active .selector-label {
color: #333;
font-weight: bold;
&.primary {
&.on {
background: #fff6ec;
color: $primary;
border-color: $primary;
}
}
&.secondary {
&.on {
background: #ecf6ff;
color: #3b9cff;
border-color: #3b9cff;
}
}
}
</style>
... ...
<!-- 模板大类详情页 就是进入横栏的模板,然后点击列表的模板,进入到的页面 -->
<!-- 第二层某一个模板大类的模板列表 -->
<!-- 模板大类详情页:进入横栏模板后,点击列表模板进入的详情页 -->
<template>
<view class="plan-detail-page">
<!-- 顶部安全区占位 -->
<!-- <view class="status-bar-placeholder" :style="{ height: statusBarHeight + 'px' }"></view> -->
<!-- 顶部导航栏 这里是模板大类的名字-->
<!-- <view class="header">
<view class="back-btn" @click="navigateBack">
<uni-icons class="back-icon" type="left" size="28" color="#fff"></uni-icons>
</view>
<view class="title">{{ templateList.name }}</view>
<u-navbar :title="templateList.name || ''" :autoBack="true" bgColor="rgba(26, 26, 26, 0.9)" titleColor="#fff"
leftIconColor="#fff" :placeholder="true" :safeAreaInsetTop="true" fixed />
<view class="description">
<text>{{ templateList?.description }}</text>
</view>
</view> -->
<view class="page-header" hover-class="none">
<!-- 动态导航栏:高度与胶囊对齐 -->
<view class="nav-bar" :style="{
paddingTop: menuButtonInfo.top + 'px',
height: menuButtonInfo.height + 'px'
}">
<view class="nav-left" @click="goBack">
<uni-icons class="back-icon" type="left" size="28" color="#fff"></uni-icons>
</view>
<view class="nav-title">{{ templateList.name }}</view>
<view class="nav-right"></view>
</view>
<view class="description">
{{ templateList?.description }}
</view>
</view>
<scroll-view class="content" scroll-y enable-backdrop-filter="{{false}}">
<!-- 筛选栏 -->
<view class="filter-bar">
<!-- 部位筛选 -->
<view class="filter-wrapper">
<view class="filter-item" @click="togglePartDropdown">
<text>{{ activePart }}</text>
<uni-icons :type="showPartDropdown ? 'up' : 'down'" size="22" color="#333"></uni-icons>
<uni-icons :type="showPartDropdown ? 'up' : 'down'" size="22" color="#333" />
</view>
<!-- 下拉菜单 移动到这里面!! -->
<view class="dropdown-menu" v-if="showPartDropdown">
<view class="dropdown-item" :class="{ selected: activePartId === item.id }" v-for="item in partList"
:key="item.id" @click="selectPart(item)">
... ... @@ -58,9 +26,8 @@
<view class="filter-wrapper">
<view class="filter-item" @click="toggleSceneDropdown">
<text>{{ activeScene }}</text>
<uni-icons :type="showSceneDropdown ? 'up' : 'down'" size="22" color="#333"></uni-icons>
<uni-icons :type="showSceneDropdown ? 'up' : 'down'" size="22" color="#333" />
</view>
<view class="dropdown-menu" v-if="showSceneDropdown">
<view class="dropdown-item" :class="{ selected: activeSceneId === item.id }" v-for="item in sceneList"
:key="item.id" @click="selectScene(item)">
... ... @@ -68,10 +35,9 @@
</view>
</view>
</view>
</view>
<!-- 新增:两列网格容器 -->
<!-- 两列网格卡片 -->
<view class="grid-container">
<view class="plan-card" v-for="(items, index) in filteredTemplates" :key="index" @click="godetail(items)">
<image :src="items.urlCover || lostImage" mode="aspectFill" class="card-cover" @error="handleImageError" />
... ... @@ -101,6 +67,7 @@
</text>
</view>
</view>
</view>
</scroll-view>
... ... @@ -109,425 +76,329 @@
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import TemplatesApi from '@/sheep/api/Template/Templates';
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import TemplatesApi from '@/sheep/api/Template/Templates'
import TemplateMenuPopup from '@/pages4/components/TemplateMenuPopup.vue'
const lostImage = "https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260316/order-empty_1773628059920.png"
const statusBarHeight = ref(0);
const headerHeight = ref(44);
const id = ref();
const templateList = ref({});
const isBigTemOffice = ref(false); // 新增:标记是否官方模板大类
// ================== 筛选功能 ==================
const filteredTemplates = ref([]); // 筛选后列表
// 部位(从接口拿)
const partList = ref([]);
const rawPartData = ref([]);
const activePartId = ref('0');
const activePart = ref('部位');
// 场景(固定)
const activeSceneId = ref('0');
const activeScene = ref('场景');
const sceneList = ref([
// ==================== 常量 ====================
const lostImage =
'https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260724/健身模板_1784871730506.png'
const SCENE_LIST = [
{ id: '0', title: '不限' },
{ id: '1', title: '健身房' },
{ id: '2', title: '仅哑铃' },
{ id: '3', title: '仅哑铃+杠铃' },
]);
]
// 模板右上角的符号
// ==================== 页面数据 ====================
const id = ref()
const templateList = ref({})
const isBigTemOffice = ref(false)
const folderList = ref([])
const templateMenuRef = ref(null)
// 下拉显示控制
const showPartDropdown = ref(false);
const showSceneDropdown = ref(false);
// 返回上一页函数
const goBack = () => {
uni.navigateBack();
};
// 获取部位分类(接口,和首页完全一样)
// ==================== 筛选 ====================
const filteredTemplates = ref([])
// 部位
const rawPartData = ref([])
const partList = ref([])
const activePartId = ref('0')
const activePart = ref('部位')
const showPartDropdown = ref(false)
// 场景
const sceneList = ref(SCENE_LIST)
const activeSceneId = ref('0')
const activeScene = ref('场景')
const showSceneDropdown = ref(false)
// 非官方模板大类时显示省略号菜单
const isIcon = ref(false)
// ==================== API 请求 ====================
const getPartCategories = async () => {
try {
const res = await TemplatesApi.getPartAllCategories();
const res = await TemplatesApi.getPartAllCategories()
if (res.code === 0) {
rawPartData.value = res.data;
rawPartData.value = res.data
partList.value = [
{ title: '不限', id: '0' },
...res.data.map(item => ({
title: item.name,
id: String(item.id)
}))
];
...res.data.map((item) => ({ title: item.name, id: String(item.id) })),
]
}
} catch (error) {
console.log('部位获取失败', error);
console.log('部位获取失败', error)
}
};
const togglePartDropdown = () => {
showPartDropdown.value = !showPartDropdown.value;
showSceneDropdown.value = false;
};
const toggleSceneDropdown = () => {
showSceneDropdown.value = !showSceneDropdown.value;
showPartDropdown.value = false;
};
const handleImageError = (e) => {
console.error('封面图加载失败,请检查路径:');
// 可选:设置默认图(需提前准备)
// plan.value.cover = '/static/images/default-plan.jpg';
};
}
const loadTemplates = async (id) => {
let response
// ======================
try {
// 官方模板大类
// ======================
if (isBigTemOffice.value) {
response = await TemplatesApi.selecttemplates(id);
const response = await TemplatesApi.selecttemplates(id)
templateList.value = response.data
filteredTemplates.value = response.data?.templates || []
} else {
// 个人模板大类(接口待接入,暂用空数据)
// const response = await TemplatesApi.getCustTemplateGroupDetail(id)
templateList.value = { templates: [] }
filteredTemplates.value = []
}
// ======================
// 个人模板大类
// ======================
else {
// 个人模板大类接口
// response = await TemplatesApi.getCustTemplateGroupDetail(id);
response = { data: { templates: [] } }; // 临时空数据,不报错
console.log('模板大类列表:', templateList.value)
} catch (e) {
console.error('加载模板失败', e)
templateList.value = {}
filteredTemplates.value = []
}
}
templateList.value = response.data;
console.log('模板大类列表:', templateList.value);
filteredTemplates.value = response.data.templates || [];
};
// 前端筛选:部位 + 场景
// ==================== 筛选方法 ====================
const doFilter = () => {
const list = templateList.value.templates || [];
const partId = activePartId.value;
const sceneId = activeSceneId.value;
const list = templateList.value.templates || []
const partId = activePartId.value
const sceneId = activeSceneId.value
console.log('👉 当前选中场景id:', sceneId)
console.log("👉 当前选中场景id:", sceneId);
// 全部不限 → 显示全部
if (partId === '0' && sceneId === '0') {
filteredTemplates.value = list;
return;
filteredTemplates.value = list
return
}
// 过滤
const result = list.filter(item => {
console.log("👉 单条场景字段:", item.scene, item.fitScene);
// 1. 部位匹配
let partMatch = true;
if (partId !== '0') {
const targetPart = partList.value.find(p => p.id === partId);
partMatch = item.primaryMuscleNames?.includes(targetPart.title);
}
// 2. 场景匹配
let sceneMatch = true;
if (sceneId !== '0') {
sceneMatch = String(item.scene) === sceneId;
}
return partMatch && sceneMatch;
});
filteredTemplates.value = result;
};
// 选择部位
filteredTemplates.value = list.filter((item) => {
const partMatch =
partId === '0' ||
(() => {
const targetPart = partList.value.find((p) => p.id === partId)
return item.primaryMuscleNames?.includes(targetPart?.title)
})()
const sceneMatch = sceneId == 0 || item.scene == sceneId
return partMatch && sceneMatch
})
}
const selectPart = (item) => {
activePart.value = item.title;
activePartId.value = item.id;
showPartDropdown.value = false;
doFilter();
};
activePart.value = item.title
activePartId.value = item.id
showPartDropdown.value = false
doFilter()
}
// 选择场景
const selectScene = (item) => {
activeScene.value = item.title;
activeSceneId.value = item.id;
showSceneDropdown.value = false;
doFilter();
};
// 关闭所有下拉
const closeAllDropdowns = () => {
showPartDropdown.value = false;
showSceneDropdown.value = false;
};
//
const showTemplateMenu = (item) => {
templateMenuRef.value.showTemplateMenu(item)
console.log('显示当前选中省略号模板详情:', item);
activeScene.value = item.title
activeSceneId.value = item.id
showSceneDropdown.value = false
doFilter()
}
};
const togglePartDropdown = () => {
showPartDropdown.value = !showPartDropdown.value
showSceneDropdown.value = false
}
const toggleSceneDropdown = () => {
showSceneDropdown.value = !showSceneDropdown.value
showPartDropdown.value = false
}
// ==================== 导航与事件 ====================
const handleImageError = (e) => {
console.error('封面图加载失败,请检查路径:', e)
}
const showTemplateMenu = (item) => {
templateMenuRef.value?.showTemplateMenu(item)
console.log('显示当前选中省略号模板详情:', item)
}
//查看模板详情
const godetail = (items) => {
uni.navigateTo({
url: `/pages4/pages/xunji/xunji-moban-xiangqing?id=${items.id}&isOffice=true`,
});
};
let isIcon = false
// 这里收到模板大类传来的id,用它来查询模板大类包含的模板
onLoad((options) => {
id.value = options.id;
isBigTemOffice.value = options.isBigTemOffice === 'true';
console.log("接收的文件夹列表:", options.foldList);
// 解析回来
if (options.foldList) {
folderList.value = JSON.parse(decodeURIComponent(options.foldList));
isIcon = true
}
console.log("模板大类ID:", id.value);
console.log("是否官方大类:", isBigTemOffice.value);
})
}
loadTemplates(id.value);
});
onMounted(() => {
const systemInfo = uni.getSystemInfoSync();
statusBarHeight.value = systemInfo.statusBarHeight;
getPartCategories();
const handleRefreshTemplate = () => {
loadTemplates(id.value)
}
// ==================== 生命周期 ====================
onLoad((options) => {
id.value = options.id
isBigTemOffice.value = options.isBigTemOffice === 'true'
// 获取小程序胶囊位置信息,实现导航栏与胶囊完美对齐
// #ifdef MP-WEIXIN
try {
const rect = uni.getMenuButtonBoundingClientRect();
if (rect) {
menuButtonInfo.value = {
top: rect.top,
height: rect.height
};
}
} catch (e) {
console.log('获取胶囊信息失败,使用默认值', e);
console.log('接收的文件夹列表:', options.foldList)
if (options.foldList) {
folderList.value = JSON.parse(decodeURIComponent(options.foldList))
isIcon.value = true
}
// #endif
// #ifndef MP-WEIXIN
// 非微信小程序环境,使用系统状态栏高度 + 标准导航栏高度
try {
const systemInfo = uni.getSystemInfoSync();
const statusBarHeight = systemInfo.statusBarHeight || 20;
menuButtonInfo.value = {
top: statusBarHeight,
height: 44 // 标准导航栏高度
};
} catch (e) {
console.log('获取系统信息失败', e);
}
// #endif
});
console.log('模板大类ID:', id.value)
console.log('是否官方大类:', isBigTemOffice.value)
const menuButtonInfo = ref({
top: 44, // 默认值,避免获取失败时样式异常
height: 32
});
loadTemplates(id.value)
})
getPartCategories()
</script>
<style lang="scss" scoped>
/* 样式保持不变 */
// ==================== CSS 变量 ====================
$color-bg: #f5f5f5;
$color-dark: #1a1a1a;
$color-white: #fff;
$glass-bg: rgba(26, 26, 26, 0.9);
$radius-card: 16rpx;
$radius-pill: 30rpx;
$shadow-card: 0 4rpx 16rpx rgba(0, 0, 0, 0.05);
$shadow-dropdown: 0 6rpx 16rpx rgba(0, 0, 0, 0.08);
// ==================== 页面布局 ====================
.plan-detail-page {
width: 100%;
height: 100vh;
height: 100%;
display: flex;
flex-direction: column;
background-color: #f5f5f5;
background-color: $color-bg;
box-sizing: border-box;
}
// .status-bar-placeholder {
// width: 100%;
// background-color: #1a1a1a;
// }
.page-header {
position: fixed;
width: 100%;
top: 0;
left: 0;
right: 0;
background-color: rgba(26, 26, 26, 0.9);
z-index: 999;
flex-shrink: 0;
// background-color: #fff;
// ==================== 顶部导航 ====================
.header-description {
padding: 20rpx 30rpx 20rpx;
color: $color-white;
font-size: 28rpx;
line-height: 46rpx;
background-color: $glass-bg;
}
/* 动态导航栏:高度与胶囊完全对齐 */
.nav-bar {
display: flex;
align-items: center;
justify-content: space-between;
// background-color: #1a1a1a;
/* 左右留出安全间距,避免内容贴边 */
padding-left: 16rpx;
padding-right: 16rpx;
/* 高度和顶部内边距由动态 style 控制 */
box-sizing: content-box;
// ==================== 内容区域 ====================
.content {
min-height: calc(100vh - 80px);
}
/* 左侧返回按钮区域 - 固定宽度确保居中对齐 */
.nav-left {
width: 60rpx;
// ==================== 筛选栏 ====================
.filter-bar {
padding: 20rpx 30rpx;
display: flex;
align-items: center;
justify-content: flex-start;
flex-shrink: 0;
}
.back-icon {
display: block;
}
/* 标题区域 - 自适应居中 */
.nav-title {
flex: 1;
text-align: center;
font-size: 36rpx;
font-weight: bold;
color: #fff;
line-height: 1;
}
.nav-right {
width: 60rpx;
flex-shrink: 0;
gap: 20rpx;
background-color: $color-white;
}
.description {
padding: 20rpx 30rpx 20rpx;
color: #fff;
// background-color: #1a1a1a;
font-size: 28rpx;
line-height: 46rpx;
.filter-wrapper {
position: relative;
}
//
.header {
width: 100%;
// position: relative;
.filter-item {
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
background-color: #1a1a1a;
color: #fff;
font-size: 36rpx;
font-weight: 600;
// z-index: 10;
// z-index: 999;
padding: 0 15rpx;
line-height: 60rpx;
border-radius: $radius-pill;
background-color: $color-white;
border: 1px solid #ddd;
color: #333;
font-size: 28rpx;
font-weight: 400;
gap: 8rpx;
&:active {
background-color: $color-bg;
}
}
.back-btn {
.dropdown-menu {
position: absolute;
left: 20rpx;
top: 50%;
transform: translateY(-50%);
width: 50rpx;
height: 50rpx;
display: flex;
align-items: center;
justify-content: center;
z-index: 11;
color: #fff;
top: calc(100% + 8rpx);
left: 0;
background: $color-white;
border-radius: 14rpx;
box-shadow: $shadow-dropdown;
padding: 12rpx 0;
min-width: 160rpx;
max-height: 320rpx;
overflow-y: auto;
z-index: 9999;
}
.back-icon {
font-size: 32rpx;
color: #fff;
font-weight: bold;
}
.dropdown-item {
padding: 16rpx 24rpx;
font-size: 28rpx;
line-height: 1.4;
color: $color-dark;
background-color: $color-white;
white-space: nowrap;
.content {
flex: 1;
min-height: 0;
padding-top: 20rpx;
// margin-top: v-bind(statusBarHeight + headerHeight + 'px');
margin-top: 236rpx;
/* #ifdef H5 */
min-height: 890px;
margin-top: 100px;
/* #endif */
&.selected {
background: #f0f9f4;
color: #2e9d5a;
font-weight: 500;
}
&:active {
opacity: 0.8;
}
}
// ==================== 网格卡片 ====================
.grid-container {
display: grid;
// 核心:两列等宽布局
grid-template-columns: repeat(2, 1fr);
// 列之间的间距(左右两个卡片的空隙)
column-gap: 20rpx;
// 行之间的间距(上下两个卡片的空隙)
row-gap: 20rpx;
// 适配小程序安全区域,避免贴边
padding-bottom: 40rpx;
padding: 0 20rpx 40rpx;
}
/* 调整卡片样式,适配网格布局 */
.plan-card {
// 移除原来的 margin-top,改用 grid 的 row-gap 控制间距
margin-top: 0;
width: 100%;
background-color: #fff;
border-radius: 16rpx;
background-color: $color-white;
border-radius: $radius-card;
overflow: hidden;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.05);
box-shadow: $shadow-card;
position: relative;
}
.card-cover {
width: 100%;
// 保持你原来的高度,和参考图一致
height: 300rpx;
}
.card-info {
// 核心优化:增强背景对比度 + 视觉层次
position: absolute;
left: 0;
right: 0;
bottom: 0;
top: 0;
padding: 20rpx 20rpx 24rpx;
// 渐变遮罩:从下往上由深到浅,完美衬托文字,不遮挡图片主体
background: linear-gradient(to top, rgba(0, 0, 0, 0.85) 0%, rgba(0, 0, 0, 0.6) 60%, rgba(0, 0, 0, 0) 100%);
// 文字阴影:进一步强化文字辨识度
background: linear-gradient(to top,
rgba(0, 0, 0, 0.85) 0%,
rgba(0, 0, 0, 0.6) 60%,
rgba(0, 0, 0, 0) 100%);
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.5);
display: flex; // 新增:开启弹性布局
flex-direction: column; // 新增:内部元素垂直排列
justify-content: space-between; // 新增:标题在最上,其他内容挤在最下
display: flex;
flex-direction: column;
justify-content: space-between;
}
.card-title {
font-size: 35rpx; // 字号微增
font-weight: 700; // 字重加粗
color: #ffffff; // 纯白保证最高对比度
font-size: 35rpx;
font-weight: 700;
color: #ffffff;
margin-top: 0;
line-height: 1.3;
display: flex;
justify-content: space-between;
.name {
max-width: 70%;
white-space: pre-wrap;
word-wrap: break-word;
/* 最多显示 2 行,超出省略 */
display: -webkit-box;
line-clamp: 2;
-webkit-box-orient: vertical;
... ... @@ -536,12 +407,9 @@ const menuButtonInfo = ref({
}
.card-stats {
// 原来的横向布局改成纵向
display: flex;
flex-direction: column; // 关键:让三个stat-item从上到下排成一列
gap: 4rpx; // 调整每个项目之间的间距
// margin-bottom: 16rpx;
// 去掉原来的align-items: center,改成左对齐
flex-direction: column;
gap: 4rpx;
align-items: flex-start;
}
... ... @@ -553,97 +421,23 @@ const menuButtonInfo = ref({
}
.stat-number {
font-size: 32rpx; // 字号放大
font-weight: 700; // 加粗
font-size: 32rpx;
font-weight: 700;
color: #ffffff;
line-height: 1.2;
}
.stat-label {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.9); // 提高透明度,更清晰
color: rgba(255, 255, 255, 0.9);
line-height: 1.2;
}
.card-tags {
margin-top: 8rpx;
}
.tags-text {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.9);
/* 让文本不换行,超出部分由:maxLines="1"控制省略 */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 筛选栏 */
.filter-bar {
padding: 20rpx 30rpx;
display: flex;
gap: 20rpx;
background-color: #fff;
}
.filter-item {
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
padding: 0 15rpx;
line-height: 60rpx;
border-radius: 30rpx;
background-color: #fff;
border: 1px solid #ddd;
color: #333;
font-size: 28rpx;
font-weight: 400;
gap: 8rpx;
&:active {
background-color: #f5f5f5;
}
}
/* 筛选器容器 */
.filter-wrapper {
position: relative;
// z-index: 999;
}
/* 下拉菜单 */
.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;
min-width: 160rpx;
max-height: 320rpx;
overflow-y: auto;
z-index: 9999;
}
/* 下拉菜单文字 */
.dropdown-item {
padding: 16rpx 24rpx;
font-size: 28rpx;
line-height: 1.4;
color: #1a1a1a;
background-color: #fff;
white-space: nowrap;
&.selected {
background: #f0f9f4;
color: #2e9d5a;
font-weight: 500;
}
&:active {
opacity: 0.8;
}
}
</style>
... ...
<template>
<view class="template-select-page">
<view class="page-header" hover-class="none">
<!-- 动态导航栏:高度与胶囊对齐 -->
<view class="nav-bar" :style="{
paddingTop: menuButtonInfo.top + 'px',
height: menuButtonInfo.height + 'px'
}">
<view class="nav-left" @click="goBack">
<uni-icons class="back-icon" type="left" size="28"></uni-icons>
</view>
<view class="nav-title">选择模板</view>
<view class="nav-right"></view>
</view>
<view class="page-header">
<!-- 标签页 -->
<view class="tab-bar">
<!-- 左边的筛选按钮组 -->
... ... @@ -21,7 +9,7 @@
<view class="filter-wrapper">
<view class="tab-item" :class="{ active: activePartId !== null }" @click="togglePartDropdown">
{{ activePartId === null ? '部位' : activePart }}
<uni-icons :type="showPartDropdown ? 'up' : 'down'" size="18" color="#333"></uni-icons>
<up-icon :name="showPartDropdown ? 'arrow-up' : 'arrow-down'" size="18" color="#333"></up-icon>
</view>
<view class="dropdown-menu" v-if="showPartDropdown">
<view class="dropdown-item" :class="{ selected: activePartId === item.id }" v-for="item in partList"
... ... @@ -34,7 +22,7 @@
<view class="filter-wrapper">
<view class="tab-item" :class="{ active: activeSceneId !== null }" @click="toggleSceneDropdown">
{{ activeSceneId === null ? '场景' : activeScene }}
<uni-icons :type="showSceneDropdown ? 'up' : 'down'" size="18" color="#333"></uni-icons>
<up-icon :name="showSceneDropdown ? 'arrow-up' : 'arrow-down'" size="18" color="#333"></up-icon>
</view>
<view class="dropdown-menu" v-if="showSceneDropdown">
<view class="dropdown-item" :class="{ selected: activeSceneId === item.id }" v-for="item in sceneList"
... ... @@ -46,30 +34,7 @@
</view>
<!-- 右边的加号 -->
<view class="add-btn" @click="toggleAddMenu">
<uni-icons type="plus" size="35" color="#333"></uni-icons>
<view v-if="showAddMenu" class="add-menu-mask" @click.stop="closeAddMenu"></view>
<!-- 加号菜单弹窗 -->
<view v-if="showAddMenu" class="add-menu-popup">
<view class="menu-item" @click="handleAddMenuClick">
<view class="menu-icon">
<!-- 新建模板图标,这里用uni-icons代替,也可以自己换图片 -->
<uni-icons type="compose" size="24" color="#333"></uni-icons>
</view>
<text>新建模板</text>
</view>
<view class="menu-item" @click.stop="openCreateFolder">
<view class="menu-icon">
<uni-icons type="folder-add" size="24" color="#333"></uni-icons>
</view>
<text>新建文件夹</text>
</view>
<!-- <view class="menu-item">
<view class="menu-icon">
<uni-icons type="scan" size="24" color="#333"></uni-icons>
</view>
<text>扫码获取模板</text>
</view> -->
</view>
<up-icon name="plus" size="35" color="#333"></up-icon>
</view>
</view>
</view>
... ... @@ -77,8 +42,8 @@
<view class="List">
<!-- 文件夹列表 -->
<scroll-view scroll-y="true" class="files">
<view class="file-card" v-for="folder in folderList" :key="folder.id" @click="goToFolderDetail(folder)">
<template v-if="folder.templatesCount > 0">
<view v-for="folder in folderList" :key="folder.id">
<view class="file-card" v-if="folder.templatesCount > 0" @click="goToFolderDetail(folder)">
<view class="left">
<image class="img" :src="folder.urlCover || defaultFolderCover" mode="aspectFill" />
<view class="template-state">
... ... @@ -87,11 +52,11 @@
</view>
</view>
<view class="right" @click.stop="showFolderMenu(folder)">
<uni-icons type="more-filled" size="22" color="#333" />
<up-icon name="more-dot-fill" size="22" color="#333" />
</view>
</template>
</view>
</view>
</scroll-view>
<!-- 个人模板 -->
... ... @@ -104,7 +69,7 @@
<view class="card-top">
<text class="card-title">{{ item.name }}</text>
<view @click.stop="showTemplateMenu(item)">
<uni-icons type="more-filled" size="22" color="#fff" />
<up-icon name="more-dot-fill" size="22" color="#fff" />
</view>
</view>
<view class="card-stats">
... ... @@ -131,28 +96,27 @@
</view>
<!-- 文件夹操作弹窗 -->
<uni-popup ref="folderMenuPopup" type="bottom" background-color="#fff">
<up-popup :show="showFolderPopup" mode="bottom" bgColor="#fff" @close="closeFolderMenu">
<view class="folder-menu-popup">
<view class="popup-title">{{ currentFolder?.name }}</view>
<view class="menu-item" @click="handleRename">
<uni-icons type="compose" size="22" color="#333" />
<up-icon name="edit-pen" size="22" color="#333" />
<text>重命名</text>
</view>
<view class="menu-item danger" @click="handleDelete">
<uni-icons type="trash" size="22" color="#e53935" />
<up-icon name="trash" size="22" color="#e53935" />
<text>删除文件夹</text>
</view>
<!-- <view class="cancel-btn" @click="closeFolderMenu">取消</view> -->
</view>
</uni-popup>
</up-popup>
<!-- 新建文件夹弹窗 -->
<uni-popup ref="createFolderPopup" type="bottom" background-color="#fff">
<up-popup :show="showCreateFolderPopup" mode="bottom" bgColor="#fff" @close="closeCreateFolder">
<view class="create-folder-popup">
<!-- 顶部条 -->
<view class="popup-header">
<view class="close-btn" @click="closeCreateFolder">
<uni-icons type="closeempty" size="24" color="#333" />
<up-icon name="close" size="24" color="#333" />
</view>
<view class="popup-title">文件夹名称</view>
<view class="save-btn" @click="handleCreateFolder">保存</view>
... ... @@ -162,7 +126,26 @@
<textarea class="text-area" v-model="newFolderName" placeholder="请输入文件夹名称" maxlength="20" auto-height />
</view>
</view>
</uni-popup>
</up-popup>
<!-- 加号菜单遮罩层 -->
<view v-if="showAddMenu" class="add-menu-mask" @click.stop="closeAddMenu"></view>
<!-- 加号菜单弹窗 -->
<view v-if="showAddMenu" class="add-menu-popup"
:style="{ top: menuPosition.top + 'px', right: menuPosition.right + 'px' }">
<view class="menu-item" @click="handleAddMenuClick">
<view class="menu-icon">
<up-icon name="edit-pen" size="24" color="#333"></up-icon>
</view>
<text>新建模板</text>
</view>
<view class="menu-item" @click.stop="openCreateFolder">
<view class="menu-icon">
<up-icon name="file-text" size="24" color="#333"></up-icon>
</view>
<text>新建文件夹</text>
</view>
</view>
<TemplateMenuPopup ref="templateMenuRef" @refresh="handleRefreshTemplate" :folder-list="folderList" />
... ... @@ -175,7 +158,7 @@ import { onShow } from '@dcloudio/uni-app';
import TemplatesApi from '@/sheep/api/Template/Templates';
import TemplateMenuPopup from '@/pages4/components/TemplateMenuPopup.vue'
const defaultFolderCover = "https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260316/order-empty_1773628059920.png";
const defaultFolderCover = "https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260724/文件夹_1784867551849.png";
// 获取模板列表
const templateList = ref([]);
... ... @@ -194,11 +177,11 @@ const partList = ref([]);
const rawPartData = ref([]);
// 文件夹弹窗相关
const folderMenuPopup = ref(null);
const showFolderPopup = ref(false);
const currentFolder = ref(null);
// 新建文件夹弹窗相关
const createFolderPopup = ref(null);
const showCreateFolderPopup = ref(false);
const newFolderName = ref('');
// 编辑文件夹
... ... @@ -208,7 +191,7 @@ const editingFolderId = ref(null);
// 菜单模板
const templateMenuRef = ref(null)
const lostImage = "https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260316/order-empty_1773628059920.png";
const lostImage = "https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260724/健身模板_1784871730506.png";
const sceneList = ref([
{ id: null, title: '不限' },
... ... @@ -276,14 +259,13 @@ const openCreateFolder = () => {
console.log('DOM更新后 showAddMenu.value =', showAddMenu.value);
isRenaming.value = false;
newFolderName.value = '';
createFolderPopup.value?.open();
showCreateFolderPopup.value = true;
});
};
// 关闭新建文件夹弹窗
const closeCreateFolder = () => {
createFolderPopup.value?.close();
// 关闭弹窗时,重置模式标记
showCreateFolderPopup.value = false;
newFolderName.value = '';
isRenaming.value = false;
editingFolderId.value = null;
... ... @@ -368,12 +350,12 @@ const doFilter = async () => {
// 显示文件夹菜单弹窗
const showFolderMenu = (folder) => {
currentFolder.value = folder;
folderMenuPopup.value?.open();
showFolderPopup.value = true;
};
// 关闭文件夹菜单弹窗
const closeFolderMenu = () => {
folderMenuPopup.value?.close();
showFolderPopup.value = false;
currentFolder.value = null;
};
... ... @@ -384,44 +366,70 @@ const handleRename = () => {
newFolderName.value = currentFolder.value.name;
closeFolderMenu();
nextTick(() => {
createFolderPopup.value?.open();
showCreateFolderPopup.value = true;
});
};
// 删除文件夹
const handleDelete = () => {
// 先捕获 folderId,再关闭弹窗,避免弹窗关闭时将 currentFolder 置空
const folderId = currentFolder.value?.id;
if (!folderId) {
uni.showToast({ title: '文件夹信息丢失,请重试', icon: 'none' });
closeFolderMenu();
return;
}
closeFolderMenu();
uni.showModal({
title: '确认删除?',
content: '删除后无法恢复',
confirmColor: '#e53935',
success: async (res) => {
if (res.confirm) {
try {
uni.showLoading({ title: '删除中...' });
await TemplatesApi.deleteCustTemplateGroup(currentFolder.value.id);
uni.hideLoading();
const res = await TemplatesApi.deleteCustTemplateGroup(folderId);
uni.showToast({ title: '删除成功' });
// 重新拉取最新文件夹列表
getFolderList();
} catch (err) {
uni.hideLoading();
console.error('删除失败:', err);
uni.showToast({ title: '删除失败', icon: 'none' });
}
}
closeFolderMenu();
}
})
},
});
};
const showAddMenu = ref(false);
const menuPosition = ref({ top: 0, right: 0 });
// 点击加号按钮,显示/隐藏菜单
const toggleAddMenu = () => {
showAddMenu.value = !showAddMenu.value;
// 关闭其他下拉菜单
showPartDropdown.value = false;
showSceneDropdown.value = false;
if (!showAddMenu.value) {
// 先获取加号按钮位置,再显示菜单弹窗
uni.createSelectorQuery()
.select('.add-btn')
.boundingClientRect()
.exec((res) => {
if (res[0]) {
const sysInfo = uni.getSystemInfoSync();
menuPosition.value = {
top: res[0].bottom + 10,
right: sysInfo.windowWidth - res[0].right,
};
}
showAddMenu.value = true;
});
} else {
showAddMenu.value = false;
}
};
// 模板图标菜单弹窗
... ... @@ -433,21 +441,20 @@ const showTemplateMenu = (item) => {
// 点击菜单选项后关闭弹窗
const handleAddMenuClick = () => {
showAddMenu.value = false;
showPartDropdown.value = false;
showSceneDropdown.value = false;
uni.navigateTo({
url: '/pages4/pages/xunji/wode-xinjian-moban',
});
};
// 点击遮罩关闭弹窗
// 关闭遮罩
const closeAddMenu = () => {
showAddMenu.value = false;
};
// 返回上一页
const goBack = () => {
uni.navigateBack();
};
const goToFolderDetail = (item) => {
// 传入的是模板大类的id,可以通过模板大类id查询到模板大类包含的模板,这个页面在page4
uni.navigateTo({
... ... @@ -463,11 +470,7 @@ const goToPersonalDetail = (item) => {
});
};
// 获取胶囊按钮信息,用于动态适配导航栏高度
const menuButtonInfo = ref({
top: 44, // 默认值,避免获取失败时样式异常
height: 32
});
// 添加 onShow 生命周期
onShow(() => {
... ... @@ -479,6 +482,8 @@ onShow(() => {
activeScene.value = '不限';
showPartDropdown.value = false;
showSceneDropdown.value = false;
showAddMenu.value = false;
console.log('onShow刷新页面');
... ... @@ -507,34 +512,6 @@ onMounted(() => {
handleRefreshTemplate();
});
// 获取小程序胶囊位置信息,实现导航栏与胶囊完美对齐
// #ifdef MP-WEIXIN
try {
const rect = uni.getMenuButtonBoundingClientRect();
if (rect) {
menuButtonInfo.value = {
top: rect.top,
height: rect.height
};
}
} catch (e) {
console.log('获取胶囊信息失败,使用默认值', e);
}
// #endif
// #ifndef MP-WEIXIN
// 非微信小程序环境,使用系统状态栏高度 + 标准导航栏高度
try {
const systemInfo = uni.getSystemInfoSync();
const statusBarHeight = systemInfo.statusBarHeight || 20;
menuButtonInfo.value = {
top: statusBarHeight,
height: 44 // 标准导航栏高度
};
} catch (e) {
console.log('获取系统信息失败', e);
}
// #endif
});
onUnmounted(() => {
... ... @@ -549,61 +526,10 @@ onUnmounted(() => {
background-color: #f6f6f6;
min-height: 100vh;
overflow: hidden;
padding-top: calc(var(--menu-top, 44px) + var(--menu-height, 32px) + 80rpx);
}
.page-header {
position: fixed;
width: 100%;
top: 0;
left: 0;
right: 0;
z-index: 999;
flex-shrink: 0;
background-color: #fff;
}
/* 动态导航栏:高度与胶囊完全对齐 */
.nav-bar {
display: flex;
align-items: center;
justify-content: space-between;
background-color: #fff;
/* 左右留出安全间距,避免内容贴边 */
padding-left: 16rpx;
padding-right: 16rpx;
/* 高度和顶部内边距由动态 style 控制 */
box-sizing: content-box;
}
/* 左侧返回按钮区域 - 固定宽度确保居中对齐 */
.nav-left {
width: 60rpx;
display: flex;
align-items: center;
justify-content: flex-start;
flex-shrink: 0;
}
.back-icon {
display: block;
}
/* 标题区域 - 自适应居中 */
.nav-title {
flex: 1;
text-align: center;
font-size: 36rpx;
font-weight: bold;
color: #333;
line-height: 1;
}
.nav-right {
width: 60rpx;
flex-shrink: 0;
}
/* 标签页 */
.tab-bar {
... ... @@ -773,6 +699,7 @@ onUnmounted(() => {
position: absolute;
top: calc(100% + 8rpx);
left: 0;
z-index: 999;
background: #fff;
border-radius: 16rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
... ... @@ -931,9 +858,7 @@ onUnmounted(() => {
/* 加号菜单弹窗:直接放在加号下方 */
.add-menu-popup {
position: absolute;
top: calc(100% + 20rpx);
right: 0; // 右对齐加号按钮
position: fixed;
background-color: #fff;
border-radius: 16rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.15);
... ...
<template>
<view class="level-page">
<!-- 导航栏 -->
<uni-nav-bar title="华创信" left-icon="left" @click-left="goBack" :fixed="true" :status-bar="true" />
<view class="top-right">
<!-- <u-icon name="document" size="24" color="#fff" @click="showRules"></u-icon> -->
<text class="right-text">华创信</text>
</view>
<view class="faq-page">
<!-- 用户信息 -->
<view class="user-info">
<image src="https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260316/order-empty_1773628059920.png"
mode="aspectFill" class="avatar"></image>
<text class="username">华创信开发者</text>
<!-- FAQ 列表 -->
<view class="faq-container">
<view v-for="category in faqCategories" :key="category.id" class="category-block">
<!-- 分类标题 -->
<view class="category-header">
<text class="category-dot"></text>
<text class="category-title">{{ category.title }}</text>
</view>
<!-- 更多权益 -->
<view class="more-benefits">
<view class="more-text">华创信开发者</view>
<view class="more-text">系统开发中</view>
<view class="more-text">敬请期待~</view>
<!-- 问题列表 -->
<view class="faq-list">
<view
v-for="(item, index) in category.items"
:key="item.id"
class="faq-item"
:class="{ 'faq-item-last': index === category.items.length - 1 }"
>
<view class="faq-question" @click="toggleItem(category.id, item.id)">
<text class="question-text">{{ item.question }}</text>
<text class="arrow-icon" :class="{ expanded: isExpanded(category.id, item.id) }">&#9660;</text>
</view>
<view class="faq-answer" :class="{ show: isExpanded(category.id, item.id) }">
<view class="answer-divider"></view>
<text class="answer-text">{{ item.answer }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
data() {
return {
expandedItems: {},
faqCategories: [
{
id: 1,
title: '训练相关',
items: [
{
id: 11,
question: '新手应该怎么制定训练计划?',
answer: '建议从每周3-4次训练开始,每次45-60分钟。前两周以熟悉动作为主,选择较轻的重量,每个动作做3组,每组12-15次。优先选择复合动作(如深蹲、卧推、硬拉),逐步建立动作模式后再提升强度。'
},
{
id: 12,
question: '一周训练几次比较合适?',
answer: '初学者建议每周3-4次,中级训练者每周4-5次,高级训练者每周5-6次。关键是保证训练质量而非数量,确保每组有足够的恢复时间。建议采用"练一休一"或"练二休一"的节奏。'
},
{
id: 13,
question: '每组应该做几个,做几组?',
answer: '增肌目标:每组8-12次,每个动作3-4组;力量增长:每组3-6次,每个动作4-5组;耐力训练:每组15-20次,每个动作2-3组。组间休息时间:增肌60-90秒,力量训练2-3分钟。'
},
{
id: 14,
question: '有氧运动和力量训练怎么安排?',
answer: '如果目标是增肌,建议力量训练后进行20-30分钟低强度有氧,或将有氧安排在休息日。如果目标是减脂,可以在力量训练后做30-45分钟中等强度有氧。不建议在力量训练前做长时间有氧,以免影响训练表现。'
},
{
id: 15,
question: '训练后肌肉酸痛怎么办?',
answer: '延迟性肌肉酸痛(DOMS)是正常现象,通常在训练后24-72小时内出现。可以通过充分拉伸、轻度活动、热敷、充足睡眠和补充蛋白质来缓解。如果疼痛异常剧烈或持续时间超过5天,建议就医检查。'
}
]
},
{
id: 2,
title: '饮食营养',
items: [
{
id: 21,
question: '健身期间应该怎么吃?',
answer: '遵循"高蛋白、适量碳水、低脂肪"原则。每餐应有优质蛋白(鸡胸肉、鱼、蛋、豆制品),搭配复合碳水(糙米、红薯、全麦面包)和蔬菜。每日蛋白质摄入建议为体重(kg)x 1.6-2.2克。'
},
{
id: 22,
question: '要不要喝蛋白粉?',
answer: '蛋白粉是方便的蛋白质补充方式,但不是必需品。优先通过食物摄取蛋白质,如果日常饮食难以满足蛋白质需求(尤其是训练后30分钟内),可以使用蛋白粉作为补充。建议选择乳清蛋白或植物蛋白粉。'
},
{
id: 23,
question: '训练前需要吃东西吗?',
answer: '建议训练前1-2小时摄入一顿含碳水和蛋白质的轻餐,例如:全麦面包+鸡蛋、香蕉+酸奶。训练前30分钟可以补充一根香蕉或一小把坚果。避免在训练前立即进食大量食物,以免引起胃部不适。'
}
]
},
{
id: 3,
title: '功能使用',
items: [
{
id: 31,
question: '如何添加自定义训练动作?',
answer: '进入"动作管理"页面,点击"新增动作",填写动作名称、选择目标肌群、设置动作类型后保存即可。添加后的动作会出现在您的动作库中,可在创建训练模板时选择使用。'
},
{
id: 32,
question: '怎么创建训练模板?',
answer: '进入"我的模板"页面,点击"新建模板",为模板命名后添加训练动作。可以设置每个动作的组数、次数和组间休息时间。创建完成后,每次训练可以直接使用模板,快速开始训练。'
},
{
id: 33,
question: '训练数据会自动保存吗?',
answer: '训练过程中数据会实时暂存,完成训练后请点击"结束训练"按钮以确保数据被完整保存到服务器。如果中途意外退出,系统会尝试恢复未完成的训练记录。'
},
{
id: 34,
question: '如何查看历史训练记录?',
answer: '在首页或训练日历中,可以按日期查看历史训练记录。点击具体日期可查看当天的训练详情,包括完成的动作、组数、重量和训练时长等信息。'
}
]
},
{
id: 4,
title: '其他问题',
items: [
{
id: 41,
question: '训练多久能看到效果?',
answer: '每个人情况不同,一般在坚持规律训练和合理饮食的前提下:4-6周开始感觉力量提升,8-12周可以看到体型变化,3-6个月出现较为明显的变化。请保持耐心,健身是一场长期旅程。'
},
{
id: 42,
question: '经期/生病期间可以训练吗?',
answer: '经期可以进行低强度训练(如散步、瑜伽),但应避免高强度力量训练和高冲击动作。生病期间建议充分休息,待身体完全康复后再逐步恢复训练,尤其是发烧时绝对不要训练。'
},
{
id: 43,
question: '遇到问题如何联系我们?',
answer: '如有任何问题或建议,可通过"我的-意见反馈"提交,或发送邮件至客服邮箱。我们会在1-3个工作日内回复处理,感谢您的支持与理解。'
}
]
}
]
};
},
methods: {
goBack() {
uni.navigateBack();
},
showRules() {
uni.showToast({ title: '查看开发内容', icon: 'success' });
},
toggleItem(categoryId, itemId) {
const key = `${categoryId}_${itemId}`;
if (this.expandedItems[key]) {
this.$delete(this.expandedItems, key);
} else {
this.$set(this.expandedItems, key, true);
}
},
isExpanded(categoryId, itemId) {
const key = `${categoryId}_${itemId}`;
return !!this.expandedItems[key];
}
}
};
</script>
<style scoped>
.level-page {
position: relative;
.faq-page {
width: 100%;
min-height: 100vh;
background-color: #1a1a1a;
color: white;
color: #ffffff;
padding-bottom: 40rpx;
}
.top-right {
position: absolute;
top: 20rpx;
right: 20rpx;
display: flex;
align-items: center;
gap: 10rpx;
color: white;
font-size: 24rpx;
}
.user-info {
display: flex;
align-items: center;
gap: 20rpx;
/* 分类区块 */
.faq-container {
padding: 20rpx;
margin-top: 20rpx;
}
.avatar {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
object-fit: cover;
}
.username {
font-size: 28rpx;
}
.level-card {
width: 600rpx;
height: 300rpx;
background-color: rgba(173, 216, 230, 0.3);
border-radius: 20rpx;
padding: 30rpx;
margin: 20rpx auto;
position: relative;
overflow: hidden;
}
.level-name {
font-size: 40rpx;
font-weight: bold;
margin-bottom: 10rpx;
.category-block {
margin-bottom: 30rpx;
}
.level-en {
font-size: 36rpx;
font-weight: bold;
/* 分类标题 */
.category-header {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 20rpx;
padding-left: 10rpx;
}
.require {
font-size: 24rpx;
margin-bottom: 20rpx;
.category-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background-color: #007aff;
flex-shrink: 0;
}
.detail {
font-size: 24rpx;
color: #ccc;
.category-title {
font-size: 32rpx;
font-weight: bold;
color: #ffffff;
}
.progress-bar {
width: 600rpx;
height: 20rpx;
background-color: #333;
border-radius: 10rpx;
margin: 20rpx auto;
position: relative;
/* FAQ 列表 */
.faq-list {
background-color: rgba(255, 255, 255, 0.06);
border-radius: 16rpx;
overflow: hidden;
}
.progress-track {
width: 100%;
height: 100%;
background-color: #333;
border-radius: 10rpx;
.faq-item {
border-bottom: 1rpx solid rgba(255, 255, 255, 0.08);
}
.progress-fill {
width: 20%;
height: 100%;
background-color: #007aff;
border-radius: 10rpx;
.faq-item-last {
border-bottom: none;
}
.progress-dot {
position: absolute;
top: 50%;
left: 20%;
transform: translate(-50%, -50%);
width: 12rpx;
height: 12rpx;
background-color: #007aff;
border-radius: 50%;
}
.benefits-section {
margin: 20rpx;
padding: 0 20rpx;
}
.section-title {
font-size: 32rpx;
margin-bottom: 20rpx;
padding-left: 10rpx;
}
.benefit-list {
/* 问题行 */
.faq-question {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 16rpx;
padding: 28rpx 24rpx;
cursor: pointer;
}
.benefit-item {
width: 180rpx;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 12rpx;
padding: 16rpx 12rpx;
text-align: center;
box-sizing: border-box;
.question-text {
flex: 1;
font-size: 28rpx;
color: #e0e0e0;
line-height: 1.5;
padding-right: 20rpx;
}
.icon {
width: 36rpx;
height: 36rpx;
object-fit: cover;
margin-bottom: 8rpx;
/* 展开/收起箭头 */
.arrow-icon {
font-size: 22rpx;
color: #999;
flex-shrink: 0;
transition: transform 0.3s ease;
}
.benefit-name {
font-size: 22rpx;
margin-bottom: 4rpx;
font-weight: bold;
.arrow-icon.expanded {
transform: rotate(180deg);
color: #007aff;
}
.benefit-desc {
font-size: 18rpx;
color: #ccc;
line-height: 1.4;
/* 答案区域 */
.faq-answer {
max-height: 0;
overflow: hidden;
transition: max-height 0.35s ease, padding 0.35s ease;
}
.time-tag {
font-size: 18rpx;
color: white;
background-color: #ff6b00;
padding: 4rpx 8rpx;
border-radius: 8rpx;
margin-top: 8rpx;
display: inline-block;
.faq-answer.show {
max-height: 1200rpx;
}
.more-benefits {
margin: 30rpx 20rpx 0;
padding: 20rpx;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 12rpx;
text-align: center;
.answer-divider {
height: 1rpx;
background-color: rgba(255, 255, 255, 0.06);
margin: 0 24rpx;
}
.more-text {
font-size: 24rpx;
color: #ccc;
margin-bottom: 10rpx;
.answer-text {
display: block;
padding: 24rpx 24rpx 32rpx 24rpx;
font-size: 26rpx;
color: #b0b0b0;
line-height: 1.8;
}
</style>
... ...
<template>
<view class="level-page">
<!-- 导航栏 -->
<uni-nav-bar title="华创信" left-icon="left" @click-left="goBack" :fixed="true" :status-bar="true" />
<view class="top-right">
<!-- <u-icon name="document" size="24" color="#fff" @click="showRules"></u-icon> -->
<text class="right-text">华创信</text>
</view>
<uni-nav-bar title="关于我们" left-icon="left" @click-left="goBack" :fixed="true" :status-bar="true" />
<!-- 用户信息 -->
<!-- 用户信息 / 品牌标识 -->
<view class="user-info">
<image src="https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260316/order-empty_1773628059920.png"
mode="aspectFill" class="avatar"></image>
<text class="username">华创信开发者</text>
<view class="brand-info">
<text class="brand-slogan">科学训练,重塑自我</text>
</view>
</view>
<!-- 介绍区域 -->
<view class="about-section">
<view class="about-card">
<view class="about-title">品牌理念</view>
<view class="about-content">
红星健身致力于为每一位健身爱好者提供<span
class="highlight">科学、高效、个性化</span>的训练解决方案。我们相信,健身不仅是改变体型的手段,更是一种积极向上的生活方式。无论你是健身新手还是资深训练者,红星健身都将陪伴你完成每一次突破,见证你的每一点进步。
</view>
</view>
<view class="about-card">
<view class="about-title">核心功能</view>
<view class="feature-list">
<view class="feature-item" v-for="(item, index) in features" :key="index">
<view class="feature-num">{{ index + 1 }}</view>
<view class="feature-text">
<view class="feature-name">{{ item.name }}</view>
<view class="feature-desc">{{ item.desc }}</view>
</view>
</view>
</view>
</view>
<!-- 更多权益 -->
<view class="more-benefits">
<view class="more-text">华创信开发者</view>
<view class="more-text">系统开发中</view>
<view class="more-text">敬请期待~</view>
<!-- <view class="about-card">
<view class="about-title">联系我们</view>
<view class="contact-list">
<view class="contact-item">
<view class="contact-label">微信公众号</view>
<view class="contact-value">红星健身</view>
</view>
<view class="contact-item">
<view class="contact-label">客服邮箱</view>
<view class="contact-value">support@hongxingfitness.com</view>
</view>
<view class="contact-item">
<view class="contact-label">官方网站</view>
<view class="contact-value">www.hongxingfitness.com</view>
</view>
</view>
</view> -->
<view class="about-card">
<view class="about-title">用户协议与隐私</view>
<view class="link-list">
<view class="link-item" @click="openAgreement('service')">
<text>服务协议</text>
<up-icon name="arrow-right" color="#fff" size="20"></up-icon>
</view>
<view class="link-item" @click="openAgreement('privacy')">
<text>隐私政策</text>
<up-icon name="arrow-right" color="#fff" size="20"></up-icon>
</view>
</view>
</view>
</view>
<!-- 版本信息 -->
<view class="version-info">
<text>当前版本 1.0.0</text>
<text class="copyright">Copyright © 2026 红星健身 All Rights Reserved</text>
</view>
</view>
</template>
<script>
export default {
data() {
return {
features: [
{
name: '个性化训练计划',
desc: '根据你的目标和水平,智能生成专属训练方案',
},
{
name: '海量动作库',
desc: '覆盖全身肌群的标准动作示范与详细讲解',
},
{
name: '训练数据追踪',
desc: '记录每一次训练,可视化你的成长轨迹',
},
{
name: '训练日历管理',
desc: '灵活安排训练日程,养成规律健身习惯',
},
],
};
},
methods: {
goBack() {
uni.navigateBack();
},
showRules() {
uni.showToast({ title: '查看开发内容', icon: 'success' });
openAgreement(type) {
const titles = {
service: '服务协议',
privacy: '隐私政策',
};
uni.showToast({ title: titles[type] || '功能开发中', icon: 'none' });
},
},
};
... ... @@ -43,174 +122,185 @@ export default {
min-height: 100vh;
background-color: #1a1a1a;
color: white;
padding-bottom: 40rpx;
}
.top-right {
position: absolute;
top: 20rpx;
right: 20rpx;
display: flex;
align-items: center;
gap: 10rpx;
color: white;
font-size: 24rpx;
padding-bottom: 60rpx;
}
/* ========== 品牌标识 ========== */
.user-info {
display: flex;
align-items: center;
gap: 20rpx;
padding: 20rpx;
margin-top: 20rpx;
gap: 24rpx;
padding: 40rpx 30rpx;
background: linear-gradient(135deg, #1e1e1e 0%, #2a2a2a 100%);
border-bottom: 1rpx solid rgba(255, 255, 255, 0.06);
}
.avatar {
width: 60rpx;
height: 60rpx;
width: 100rpx;
height: 100rpx;
border-radius: 50%;
object-fit: cover;
border: 3rpx solid rgba(255, 107, 0, 0.4);
}
.brand-info {
display: flex;
flex-direction: column;
gap: 6rpx;
}
.username {
font-size: 28rpx;
font-size: 36rpx;
font-weight: bold;
color: #fff;
}
.level-card {
width: 600rpx;
height: 300rpx;
background-color: rgba(173, 216, 230, 0.3);
border-radius: 20rpx;
padding: 30rpx;
margin: 20rpx auto;
position: relative;
overflow: hidden;
.brand-slogan {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.5);
letter-spacing: 2rpx;
}
.level-name {
font-size: 40rpx;
font-weight: bold;
margin-bottom: 10rpx;
/* ========== 关于我们内容区 ========== */
.about-section {
padding: 24rpx 24rpx 0;
}
.level-en {
font-size: 36rpx;
font-weight: bold;
.about-card {
background-color: rgba(255, 255, 255, 0.05);
border-radius: 16rpx;
padding: 28rpx 24rpx;
margin-bottom: 20rpx;
border: 1rpx solid rgba(255, 255, 255, 0.08);
}
.require {
font-size: 24rpx;
.about-title {
font-size: 30rpx;
font-weight: bold;
margin-bottom: 20rpx;
padding-left: 14rpx;
border-left: 5rpx solid #ff6b00;
color: #fff;
}
.detail {
font-size: 24rpx;
color: #ccc;
.about-content {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.75);
line-height: 1.8;
text-align: justify;
}
.progress-bar {
width: 600rpx;
height: 20rpx;
background-color: #333;
border-radius: 10rpx;
margin: 20rpx auto;
position: relative;
overflow: hidden;
.highlight {
color: #ff6b00;
font-weight: bold;
}
.progress-track {
width: 100%;
height: 100%;
background-color: #333;
border-radius: 10rpx;
}
.progress-fill {
width: 20%;
height: 100%;
background-color: #007aff;
border-radius: 10rpx;
}
.progress-dot {
position: absolute;
top: 50%;
left: 20%;
transform: translate(-50%, -50%);
width: 12rpx;
height: 12rpx;
background-color: #007aff;
/* ========== 核心功能列表 ========== */
.feature-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.feature-item {
display: flex;
align-items: flex-start;
gap: 20rpx;
padding: 14rpx 0;
}
.feature-num {
width: 48rpx;
height: 48rpx;
background: linear-gradient(135deg, rgba(255, 107, 0, 0.25), rgba(255, 107, 0, 0.08));
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
font-weight: bold;
color: #ff6b00;
flex-shrink: 0;
}
.benefits-section {
margin: 20rpx;
padding: 0 20rpx;
.feature-text {
flex: 1;
}
.section-title {
font-size: 32rpx;
margin-bottom: 20rpx;
padding-left: 10rpx;
.feature-name {
font-size: 28rpx;
font-weight: bold;
color: #fff;
margin-bottom: 4rpx;
}
.feature-desc {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.5);
line-height: 1.5;
}
.benefit-list {
/* ========== 联系我们 ========== */
.contact-list {
display: flex;
flex-direction: column;
gap: 14rpx;
}
.contact-item {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 16rpx;
align-items: center;
padding: 8rpx 0;
}
.benefit-item {
width: 180rpx;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 12rpx;
padding: 16rpx 12rpx;
text-align: center;
box-sizing: border-box;
.contact-label {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.6);
}
.icon {
width: 36rpx;
height: 36rpx;
object-fit: cover;
margin-bottom: 8rpx;
.contact-value {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.9);
}
.benefit-name {
font-size: 22rpx;
margin-bottom: 4rpx;
font-weight: bold;
/* ========== 链接列表 ========== */
.link-list {
display: flex;
flex-direction: column;
}
.benefit-desc {
font-size: 18rpx;
color: #ccc;
line-height: 1.4;
.link-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 0;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.8);
border-bottom: 1rpx solid rgba(255, 255, 255, 0.06);
}
.time-tag {
font-size: 18rpx;
color: white;
background-color: #ff6b00;
padding: 4rpx 8rpx;
border-radius: 8rpx;
margin-top: 8rpx;
display: inline-block;
}
.more-benefits {
margin: 30rpx 20rpx 0;
padding: 20rpx;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 12rpx;
.link-item:last-child {
border-bottom: none;
}
.link-arrow {
color: rgba(255, 255, 255, 0.3);
font-size: 26rpx;
}
/* ========== 版本信息 ========== */
.version-info {
text-align: center;
padding: 40rpx 0 20rpx;
display: flex;
flex-direction: column;
gap: 8rpx;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.35);
}
.more-text {
font-size: 24rpx;
color: #ccc;
margin-bottom: 10rpx;
.copyright {
font-size: 20rpx;
}
</style>
... ...
... ... @@ -16,14 +16,23 @@ export const useActionStore = defineStore(
return allCategories.value.filter((item) => !hiddenCategories.value.includes(item.id));
});
// 获取分类
// 获取分类(首次加载,有缓存则跳过)
const getloadCategories = async () => {
if (allCategories.value.length > 0) return;
const response = await LeftmotionApi.getAllCategories();
// 把接口数据存进去
allCategories.value = response.data || [];
// 追加超级组分类(保持原有逻辑)
// 追加超级组分类
allCategories.value.push({
id: 'super',
name: '超级组',
});
};
// 强制刷新分类(编辑/新增后使用)
const reloadCategories = async () => {
const response = await LeftmotionApi.getAllCategories();
allCategories.value = response.data || [];
allCategories.value.push({
id: 'super',
name: '超级组',
... ... @@ -47,6 +56,7 @@ export const useActionStore = defineStore(
showCategories,
hiddenCategories,
getloadCategories,
reloadCategories,
hideCategory,
showCategory,
};
... ... @@ -57,8 +67,11 @@ export const useActionStore = defineStore(
enabled: true,
strategies: [
{
key: 'action-category-store', // 修正 key 名称
key: 'action-category-store',
paths: ['hiddenCategories'],
// 关键修复:插件默认 save 用 uni.setStorage(localStorage)、load 用 window.sessionStorage,
// 两端不一致会导致刷新后数据丢失。显式指定 storage 为 localStorage,保证存读同源。
storage: typeof window !== 'undefined' ? window.localStorage : undefined,
},
],
},
... ...
... ... @@ -118,6 +118,8 @@ const app = defineStore({
strategies: [
{
key: 'app-store',
// 修复插件 H5 存读后端不一致:save 走 localStorage、load 走 sessionStorage,显式指定 storage 保证同源
storage: typeof window !== 'undefined' ? window.localStorage : undefined,
},
],
},
... ...
... ... @@ -113,6 +113,8 @@ const cart = defineStore({
strategies: [
{
key: 'cart-store',
// 修复插件 H5 存读后端不一致:save 走 localStorage、load 走 sessionStorage,显式指定 storage 保证同源
storage: typeof window !== 'undefined' ? window.localStorage : undefined,
},
],
},
... ...
... ... @@ -21,6 +21,9 @@ const modal = defineStore({
{
key: 'modal-store',
paths: ['lastTimer', 'advHistory'],
// 与 action.js 同样的修复:插件默认 save 用 uni.setStorage(localStorage)、load 用 window.sessionStorage,
// 两端不一致会导致 H5 刷新后数据丢失。显式指定 storage 为 localStorage,保证存读同源。
storage: typeof window !== 'undefined' ? window.localStorage : undefined,
},
],
},
... ...
... ... @@ -24,6 +24,8 @@ const sys = defineStore({
strategies: [
{
key: 'sys-store',
// 修复插件 H5 存读后端不一致:save 走 localStorage、load 走 sessionStorage,显式指定 storage 保证同源
storage: typeof window !== 'undefined' ? window.localStorage : undefined,
},
],
},
... ...
... ... @@ -58,7 +58,7 @@ const useTrainingStore = defineStore(
// 持久化:关闭APP再进来,训练还在!
persist: {
enabled: true,
strategies: [{ key: 'training-store' }],
strategies: [{ key: 'training-store', storage: typeof window !== 'undefined' ? window.localStorage : undefined }],
},
},
);
... ...
import { defineStore } from 'pinia';
import dayjs from 'dayjs';
import ExercisesApi from '@/sheep/api/motion/exercises';
import SupersetsApi from '@/sheep/api/motion/supersets';
import TemplatesApi from '@/sheep/api/Template/Templates';
// ======================
// 常量
// ======================
const DEFAULT_USER_WEIGHT = 70;
const DEFAULT_REST_TIME = 60;
// ======================
// 工具函数
// ======================
/** 秒数 → { h, m, s } 两位数字符串 */
function secondsToHMS(totalSec) {
const d = dayjs.duration(totalSec || 0, 'seconds');
return {
h: String(Math.floor(d.as('hours'))).padStart(2, '0'),
m: String(d.minutes()).padStart(2, '0'),
s: String(d.seconds()).padStart(2, '0'),
};
}
/** 单组数据 → 页面标准格式 */
function convertSetToRecord(set) {
return {
weight: set.weight ?? '',
reps: set.reps ?? '',
duration: set.duration ?? '',
distance: set.distance ?? '',
restTime: set.restTime ?? '',
...secondsToHMS(set.duration),
quickTimeDisplay: (set.restTime || DEFAULT_REST_TIME) + 's',
isActive: (set.isCompleted ?? 0) === 1,
};
}
/** 批量转换 sets → records */
function convertSetsToRecords(sets) {
return (sets || []).map(convertSetToRecord);
}
export const useTrainingStore = defineStore('training', {
state: () => ({
id: null, // 页面传过来的 id
... ... @@ -88,13 +128,13 @@ export const useTrainingStore = defineStore('training', {
units.forEach((unit, unitIndex) => {
let records = {};
// 按顺序赋值,不依赖 exerciseId,动作可改、可换
unit.exercises?.forEach((ex, exIndex) => {
unit.exercises?.forEach((ex) => {
records[ex.exerciseId] = ex.sets || [];
});
this.unitRecords[unitIndex] = {
records: records,
userWeight: 70,
records,
userWeight: DEFAULT_USER_WEIGHT,
};
});
... ... @@ -114,41 +154,17 @@ export const useTrainingStore = defineStore('training', {
const exercises = unit.exercises || [];
exercises.forEach((ex) => {
const sets = ex.sets || [];
// 转换 sets → 页面标准格式
const converted = sets.map((set) => {
const totalSec = set.duration || 0;
const h = String(Math.floor(totalSec / 3600)).padStart(2, '0');
const m = String(Math.floor((totalSec % 3600) / 60)).padStart(2, '0');
const s = String(totalSec % 60).padStart(2, '0');
return {
weight: set.weight ?? '',
reps: set.reps ?? '',
duration: set.duration ?? '',
distance: set.distance ?? '',
restTime: set.restTime ?? '',
h,
m,
s,
quickTimeDisplay: (set.restTime || 60) + 's',
// isActive: false,
isActive: (set.isCompleted ?? 0) === 1,
};
});
records[ex.exerciseId] = converted;
records[ex.exerciseId] = convertSetsToRecords(ex.sets);
});
// 直接存入 Pinia
this.unitRecords[unitIndex] = {
records: records,
userWeight: 70,
records,
userWeight: DEFAULT_USER_WEIGHT,
};
});
},
//新增:每日模板专用赋值(真实训练记录)
// 每日模板专用赋值(真实训练记录)
initDailyTemplateRecords() {
console.log('✅ 每日模板:直接赋值真实训练记录');
this.unitRecords = {};
... ... @@ -157,44 +173,13 @@ export const useTrainingStore = defineStore('training', {
units.forEach((unit, unitIndex) => {
let records = {};
const exercises = unit.exercises || [];
// exercises.forEach((ex) => {
// records[ex.exerciseId] = ex.sets || [];
// });
exercises.forEach((ex) => {
const sets = ex.sets || [];
// 转换 sets → 页面标准格式
const converted = sets.map((set) => {
const totalSec = set.duration || 0;
const h = String(Math.floor(totalSec / 3600)).padStart(2, '0');
const m = String(Math.floor((totalSec % 3600) / 60)).padStart(2, '0');
const s = String(totalSec % 60).padStart(2, '0');
return {
weight: set.weight ?? '',
reps: set.reps ?? '',
duration: set.duration ?? '',
distance: set.distance ?? '',
restTime: set.restTime ?? '',
h,
m,
s,
quickTimeDisplay: (set.restTime || 60) + 's',
// isActive: false,
isActive: (set.isCompleted ?? 0) === 1,
};
});
converted.forEach((item, idx) => {
console.log(`第${idx}组:`, item.h, item.m, item.s);
});
records[ex.exerciseId] = converted;
(unit.exercises || []).forEach((ex) => {
records[ex.exerciseId] = convertSetsToRecords(ex.sets);
});
this.unitRecords[unitIndex] = {
records: records,
userWeight: 70,
records,
userWeight: DEFAULT_USER_WEIGHT,
};
});
},
... ... @@ -208,7 +193,6 @@ export const useTrainingStore = defineStore('training', {
return (list || []).map((item) => ({
id: item.exerciseId,
name: item.exerciseName,
// urlImage: item.exerciseCover || item.url3dAnimation,
urlImage: item.url3dAnimation || item.exerciseCover || item.urlImage,
exerciseType: item.exerciseType,
}));
... ... @@ -227,9 +211,7 @@ export const useTrainingStore = defineStore('training', {
return {
id: act.exerciseId,
name: act.exerciseName,
// urlImage: act.exerciseCover || act.url3dAnimation || act.urlImage,
urlImage: act.url3dAnimation || act.exerciseCover || act.urlImage,
// urlImage
exerciseType: act.exerciseType,
categoryDescription: act.categoryDescription,
equipmentDescription: act.equipmentDescription,
... ... @@ -265,7 +247,6 @@ export const useTrainingStore = defineStore('training', {
exerciseId: this.actionDetail.id,
exerciseName: this.actionDetail.name,
exerciseType: this.actionDetail.exerciseType,
// urlImage: this.actionDetail.urlImage || this.actionDetail.url3dAnimation,
urlImage: this.actionDetail.url3dAnimation || this.actionDetail.urlImage,
categoryDescription: this.actionDetail.categoryDescription,
equipmentDescription: this.actionDetail.equipmentDescription,
... ... @@ -303,7 +284,7 @@ export const useTrainingStore = defineStore('training', {
saveUnitRecord(unitIndex, data) {
this.unitRecords[unitIndex] = {
records: data.records || {},
userWeight: data.userWeight ?? 70,
userWeight: data.userWeight ?? DEFAULT_USER_WEIGHT,
};
},
... ... @@ -312,7 +293,7 @@ export const useTrainingStore = defineStore('training', {
return (
this.unitRecords[unitIndex] || {
records: {},
userWeight: 70,
userWeight: DEFAULT_USER_WEIGHT,
}
);
},
... ... @@ -328,7 +309,7 @@ export const useTrainingStore = defineStore('training', {
if (!this.unitRecords[unitIndex]) {
this.unitRecords[unitIndex] = {
records: {},
userWeight: 70,
userWeight: DEFAULT_USER_WEIGHT,
};
}
... ... @@ -369,10 +350,8 @@ export const useTrainingStore = defineStore('training', {
this.totalSeconds = seconds;
},
openTimePicker() {
const h = Math.floor(this.totalSeconds / 3600);
const m = Math.floor((this.totalSeconds % 3600) / 60);
const s = this.totalSeconds % 60;
this.defaultTimeIndex = [h, m, s];
const d = dayjs.duration(this.totalSeconds, 'seconds');
this.defaultTimeIndex = [Math.floor(d.as('hours')), d.minutes(), d.seconds()];
this.showPicker = true;
},
closeTimePicker() {
... ... @@ -416,7 +395,6 @@ export const useTrainingStore = defineStore('training', {
this.loading = false;
this.unitRecords = {};
this.trainingTimeText = '';
// this.min = false;
this.isSystem = 1;
this.trainingName = '';
this.defaultTimeIndex = [0, 0, 0];
... ...
... ... @@ -217,6 +217,8 @@ const useUserStore = defineStore(
strategies: [
{
key: 'user-store',
// 修复插件 H5 存读后端不一致:save 走 localStorage、load 走 sessionStorage,显式指定 storage 保证同源
storage: typeof window !== 'undefined' ? window.localStorage : undefined,
},
],
},
... ...