Authored by Bad

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

Too many changes to show.

To preserve performance only 16 of 16+ files are displayed.

... ... @@ -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,120 +140,82 @@ 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%;
display: flex;
flex-direction: column;
align-items: center;
gap: 70rpx;
margin-bottom: 100rpx;
}
/* 选项列表 */
.popup-options {
width: 100%;
.add-train-popup {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 20rpx;
}
/* 单个选项项 */
.popup-option {
width: 100%;
// background: rgba(255, 255, 255, 0.1);
border-radius: 16rpx;
padding: 24rpx 30rpx;
display: flex;
align-items: center;
gap: 24rpx;
}
/* 黄色图标容器 */
.icon-wrap {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: #ffd60a;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
image {
width: 40rpx;
height: 40rpx;
padding: 0 30rpx 40rpx;
// 顶部拖拽指示条
.popup-indicator {
width: 72rpx;
height: 8rpx;
border-radius: 4rpx;
background: rgba(255, 255, 255, 0.3);
margin: 16rpx 0 32rpx;
}
}
/* 文字区域 */
.text-wrap {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.option-title {
font-size: 32rpx;
color: #fff;
font-weight: 500;
}
// 菜单选项列表
.popup-menu {
width: 100%;
display: flex;
flex-direction: column;
gap: 16rpx;
.menu-item {
display: flex;
align-items: center;
gap: 24rpx;
padding: 24rpx 30rpx;
border-radius: 16rpx;
}
.option-desc {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.7);
}
.menu-icon {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: #ffd60a;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
/* 底部关闭按钮 */
.close-btn {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
.menu-text {
display: flex;
flex-direction: column;
gap: 8rpx;
.menu-title {
font-size: 32rpx;
color: #fff;
font-weight: 500;
}
.menu-desc {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.7);
}
}
}
text {
font-size: 40rpx;
color: #000;
line-height: 1;
// 底部关闭按钮
.close-btn {
margin-top: 60rpx;
width: 88rpx;
height: 88rpx;
border-radius: 50%;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
}
}
</style>
\ No newline at end of file
</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>
... ... @@ -84,7 +79,7 @@
<view class="action-top">
<text class="action-name">{{ unit.exercises[0]?.exerciseName || '动作名称' }}</text>
<text class="action-totalWeight">{{ unit.totalWeight
}}kg</text>
}}kg</text>
</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,152 +1492,145 @@ 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 {
display: flex;
align-items: center;
padding: 24rpx 20rpx;
position: relative;
gap: 16rpx;
}
.superset-wrapper .super-image {
width: 75rpx;
height: 75rpx;
flex-shrink: 0;
}
.super-head {
display: flex;
align-items: center;
padding: 24rpx 20rpx;
position: relative;
gap: 16rpx;
}
.superset-wrapper .super-title-wrap {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.super-image {
width: 75rpx;
height: 75rpx;
flex-shrink: 0;
}
.superset-wrapper .super-title {
font-size: 32rpx;
color: #111;
font-weight: 500;
}
.super-title-wrap {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.superset-wrapper .super-totalWeight {
font-size: 24rpx;
color: #666;
}
.super-title {
font-size: 32rpx;
color: #111;
font-weight: 500;
}
.superset-wrapper .super-actionName {
padding: 0 20rpx 20rpx 70rpx;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.super-totalWeight {
font-size: 24rpx;
color: #666;
}
.superset-wrapper .name-item {
display: flex;
align-items: center;
gap: 12rpx;
}
.super-actionName {
padding: 0 20rpx 20rpx 70rpx;
display: flex;
flex-direction: column;
gap: 12rpx;
.superset-wrapper .letter {
font-size: 28rpx;
font-weight: bold;
color: #333;
}
.action-name {
font-size: 28rpx;
color: $color-primary;
}
}
.superset-wrapper .super-actionName .action-name {
font-size: 28rpx;
color: #333;
}
.name-item {
display: flex;
align-items: center;
gap: 12rpx;
}
.superset-wrapper .super-set-list {
padding: 0 20rpx 20rpx 20rpx;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.letter {
font-size: 28rpx;
font-weight: bold;
color: $color-primary;
}
.superset-wrapper .super-set-item {
display: flex;
// flex-direction: column; // 新增:垂直排列
align-items: stretch; // 让每个动作占满宽度
gap: 12rpx; // 垂直间距(可根据需要微调)
padding: 16rpx;
background: #f9f9f9;
border-radius: 12rpx;
}
.super-set-list {
padding: 0 20rpx 20rpx 20rpx;
display: flex;
flex-direction: column;
gap: 12rpx;
}
// 为了让序号独立占一行
.superset-wrapper .super-set-item {
margin-bottom: 8rpx;
}
.super-set-item {
display: flex;
align-items: stretch;
gap: 12rpx;
padding: 16rpx;
background: #f9f9f9;
border-radius: 12rpx;
margin-bottom: 8rpx;
}
.superset-wrapper .set-number {
font-size: 26rpx;
background: #e4e4e4;
color: #282828;
width: 37rpx;
height: 37rpx;
text-align: center;
flex-shrink: 0; // /* 不被父容器挤压变形,保持固定大小 */
border-radius: 50%;
// line-height: 60rpx;
}
.set-number {
font-size: 26rpx;
background: #e4e4e4;
color: #282828;
width: 37rpx;
height: 37rpx;
text-align: center;
flex-shrink: 0; // /* 不被父容器挤压变形,保持固定大小 */
border-radius: 50%;
// line-height: 60rpx;
}
.superset-wrapper .set-column {
display: flex;
flex-direction: column;
}
.set-column {
display: flex;
flex-direction: column;
}
.superset-wrapper .action-in-set {
// display: flex;
// flex-direction: column;
width: 100%;
display: flex;
align-items: center;
margin-bottom: 8rpx;
}
.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 {
margin-bottom: 0;
}
.action-in-set:last-child {
margin-bottom: 0;
}
.superset-wrapper .action-letter {
display: inline-block;
font-size: 26rpx;
color: #666;
// width: 35rpx;
white-space: nowrap;
margin-right: 12rpx;
flex-shrink: 0;
}
.action-letter {
display: inline-block;
font-size: 26rpx;
color: #666;
// width: 35rpx;
white-space: nowrap;
margin-right: 12rpx;
flex-shrink: 0;
}
.superset-wrapper .set-content {
font-size: 26rpx;
color: inherit;
flex: 1;
}
.set-content {
font-size: 26rpx;
color: inherit;
flex: 1;
}
.superset-wrapper .rest-time {
font-size: 24rpx;
// color: inherit;
margin-left: 10rpx;
flex-shrink: 0;
}
.rest-time {
font-size: 24rpx;
// color: inherit;
margin-left: 10rpx;
flex-shrink: 0;
}
.superset-wrapper .check {
font-size: 26rpx;
color: #333;
margin-left: 275rpx !important;
/* 这里控制和左边内容的距离,数值你可以自己调 */
flex-shrink: 0;
/* 防止被压缩 */
.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 isToday = (fullDate) => fullDate === getTodayStr();
// 切换日期选中状态(仅允许选择今天及之后)
const toggleDate = (day) => {
if (day.fullDate < minDateStr) {
/** 获取日期格子的样式类 */
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,121 +251,163 @@ 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 {
opacity: 0;
pointer-events: none;
}
&.other-month .day-number {
color: #ccc;
/* 今天 */
&.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);
.day-number {
color: #000;
font-weight: bold;
.date-text {
color: #1a1a1a;
font-weight: 700;
}
&.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;
}
}
}
</style>
\ No newline at end of file
</style>
... ...
... ... @@ -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,29 +233,33 @@ const handleDeleteTemplate = async () => {
return;
}
uni.showModal({
title: '确认删除',
content: '确定要删除这个模板吗?删除后无法恢复',
confirmColor: '#ff4444',
success: async (res) => {
if (res.confirm) {
try {
uni.showLoading({ title: '删除中...' });
await TemplatesApi.deleteCustTemplate(currentTemplate.value.id);
uni.hideLoading();
uni.showToast({ title: '删除成功', icon: 'success' });
closeTemplateMenu();
// 通知父组件刷新列表
emit('refresh');
} catch (err) {
uni.hideLoading();
console.error('删除失败:', err);
uni.showToast({ title: '删除失败', icon: 'none' });
// 先保存模板ID,再关闭菜单弹窗,避免弹窗叠加
const templateId = currentTemplate.value.id;
closeTemplateMenu();
setTimeout(() => {
uni.showModal({
title: '确认删除',
content: '确定要删除这个模板吗?删除后无法恢复',
confirmColor: '#ff4444',
success: async (res) => {
if (res.confirm) {
try {
uni.showLoading({ title: '删除中...' });
await TemplatesApi.deleteCustTemplate(templateId);
uni.hideLoading();
uni.showToast({ title: '删除成功', icon: 'success' });
// 通知父组件刷新列表
emit('refresh');
} catch (err) {
uni.hideLoading();
console.error('删除失败:', err);
uni.showToast({ title: '删除失败', icon: 'none' });
}
}
}
}
});
});
}, 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 } = actionStore;
const { hideCategory, showCategory, getloadCategories, reloadCategories } = actionStore;
// 页面数据就绪标记(避免 allCategories 为空时渲染空白)
const isDataReady = ref(false);
// 计算属性:获取隐藏分类的对象数组
// 隐藏分类的对象数组(含名称)
const hiddenCategoryObjects = computed(() => {
return actionStore.allCategories.filter(category =>
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 fetchCustomCategories = async () => {
const res = await ExercisesApi.getCustomCategories();
customCategories.value = res.data || [];
};
// 打开编辑弹窗
const openEditPopup = async () => {
try {
const res = await ExercisesApi.getCustomCategories();
customCategories.value = res.data || [];
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 {
transform: scale(0.96);
background-color: #fafafa;
&: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 {
transform: scale(0.96);
background-color: #f2f2f2;
&:active {
transform: scale(0.96);
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,46 +13,76 @@
<!-- 右侧动作列表 -->
<scroll-view scroll-y class="right-content">
<!-- 部位筛选标签 -->
<view class="tip" v-if="motionPart.length > 0">
<view class="item" @click="handlePartClick('')" :class="{ active: activeMotionPart == '' }">全部</view>
<view class="item" v-for="item in motionPart" :key="item.id" :class="{ active: activeMotionPart == item.id }"
@click="handlePartClick(item.id)">
{{ item.name }}
</view>
<!-- 初始引导:未选择分类 -->
<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="exercise-grid">
<view class="content" v-if="exercises.length > 0">
<view class="equipment-list">
<view class="equipment-item" v-for="item in exercises" :key="item.equipmentId">
<view class="equipment-name">{{ item.equipmentName }}</view>
<view class="action-list">
<view class="action-item" v-for="e in item.exercises" :key="e.id"
:class="{ selected: selectedIds.has(e.id) }" @click="toggleAction(e)">
<image :src="e.url3dAnimation" mode="aspectFill" class="action-img"></image>
<view class="action-name">{{ e.name }}</view>
</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>
<!-- 部位筛选标签 -->
<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="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">
<view class="action-item" v-for="e in item.exercises" :key="e.id"
:class="{ selected: selectedIds.has(e.id) }" @click="toggleAction(e)">
<image :src="e.url3dAnimation" mode="aspectFill" class="action-img"></image>
<view class="action-name">{{ e.name }}</view>
</view>
</view>
</view>
</view>
<view class="empty-state" v-else>
<text class="empty-text">暂无动作数据</text>
<!-- 无动作数据 -->
<view class="content-tip empty-state" v-else>
<text class="tip-text">该分类下暂无动作</text>
<text class="tip-subtext">可尝试切换分类或部位查看其他动作</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">
<text class="name">{{ item.name }}</text>
<uni-icons type="close" size="16" color="#fff" @click="removeAction(item)"></uni-icons>
<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
const res = await ExercisesApi.getexercises({
categoriesId: activeNav.value,
subCategoriesId: id
})
exercises.value = res.data || []
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,334 +286,351 @@ 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;
}
// ==================== 主体布局 ====================
.layout-container {
.search-bar {
padding-top: var(--status-bar-height);
height: 88rpx;
background: #fff;
min-height: 0;
display: flex;
overflow: hidden;
padding-bottom: calc(100rpx + 30rpx); // 底部按钮栏 + 间距
}
.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;
// 左侧导航
.left-nav {
width: 180rpx;
background: $color-bg-nav;
border-right: 1rpx solid $color-border;
.nav-item {
padding: $spacing-lg $spacing-sm;
font-size: $font-sm;
color: $color-text-secondary;
position: relative;
white-space: nowrap;
text-align: center;
&.active {
background: $color-white;
color: #000;
font-weight: 500;
&::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 6rpx;
height: 36rpx;
background: $color-primary;
}
}
}
}
.menu-item {
display: flex;
align-items: center;
gap: 16rpx;
}
// 右侧内容
.right-content {
flex: 1;
padding: $spacing-md;
padding-top: $spacing-lg;
.menu-text {
box-sizing: border-box;
display: flex;
font-size: 28rpx;
// margin-left: 200rpx;
}
// 通用内容提示块
.content-tip {
display: flex;
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;
}
}
// .search-bar {
// padding: 20rpx 30rpx;
// background: #fff;
// 初始引导
.init-guide {
.tip-text {
font-size: $font-lg;
color: $color-text-secondary;
font-weight: 500;
}
}
// .search-input {
// width: 100%;
// height: 70rpx;
// border-radius: 35rpx;
// background: #f7f7f7;
// padding: 0 24rpx;
// font-size: 28rpx;
// border: 1rpx solid #eee;
// }
// }
// 错误重试按钮
.retry-btn {
margin-top: $spacing-md;
padding: 12rpx 40rpx;
font-size: $font-sm;
color: #000;
background: $color-primary;
border-radius: $radius-round;
}
.layout-container {
flex: 1;
display: flex;
overflow: hidden;
padding-bottom: 130rpx;
// 部位筛选标签(横向滚动)
.part-filter {
margin-bottom: $spacing-md;
white-space: nowrap;
.left-nav {
width: 150rpx;
background: #fafafa;
border-right: 1rpx solid #eee;
.part-filter-inner {
display: inline-flex;
flex-wrap: nowrap;
gap: 15rpx;
}
.nav-item {
padding: 30rpx 20rpx;
font-size: 26rpx;
color: #666;
position: relative;
.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: #fff;
background: $color-primary;
color: #000;
font-weight: 500;
&::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 6rpx;
height: 36rpx;
background: #f8d714;
}
}
}
}
.right-content {
flex: 1;
padding: 20rpx;
// 部位筛选为空
.part-empty-tip {
padding: 30rpx $spacing-lg 40rpx;
}
.tip {
display: flex;
flex-wrap: wrap;
gap: 15rpx;
margin-bottom: 20rpx;
.equipment-item {
margin-bottom: $spacing-lg;
.item {
padding: 10rpx 20rpx;
background: #fff;
border-radius: 16rpx;
font-size: 24rpx;
.equipment-name {
font-size: $font-md;
font-weight: 500;
color: $color-text-secondary;
margin-bottom: $spacing-sm;
padding-left: $spacing-xs;
border-left: 6rpx solid $color-primary;
}
&.active {
background: #f8d714;
color: #000;
.action-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: $spacing-sm;
.action-item {
background: $color-white;
border-radius: $radius-md;
padding: $spacing-sm;
display: flex;
flex-direction: column;
align-items: center;
gap: $spacing-xs;
border: 2rpx solid transparent;
transition: border-color 0.2s, transform 0.15s;
&.selected {
border-color: $color-primary;
}
}
}
.equipment-item {
margin-bottom: 30rpx;
&:active {
transform: scale(0.97);
}
.equipment-name {
font-size: 28rpx;
font-weight: 500;
margin-bottom: 16rpx;
}
.action-img {
width: 100%;
height: 200rpx;
border-radius: $radius-sm;
}
.action-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
.action-item {
background: #fff;
border-radius: 16rpx;
padding: 16rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
&.selected {
border: 2rpx solid #f8d714;
}
.action-img {
width: 100%;
height: 200rpx;
border-radius: 12rpx;
}
.action-name {
font-size: 24rpx;
color: #333;
}
.action-name {
font-size: $font-xs;
color: $color-text-primary;
}
}
}
}
.empty-state {
padding-top: 100rpx;
text-align: center;
color: #999;
font-size: 28rpx;
}
// 无动作空状态
.empty-state {
padding-top: 100rpx;
}
}
// ==================== 底部已选标签栏 ====================
.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>
\ No newline at end of file
</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,35 +16,29 @@
</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">
<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"
/>
<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" :isTick="false" />
</view>
</template>
<view v-else class="empty-area">
<view class="empty-text">
尚未添加任何动作<br />
点击最下方 + 开始添加动作
</view>
</view>
</template>
<view v-else class="empty-area">
<view class="empty-text">
尚未添加任何动作<br />
点击最下方 + 开始添加动作
</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' ? '在此处输入模板介绍' : '请输入模板标题'"
/>
<textarea class="intro-textarea" v-model="popupValue"
:placeholder="popupType === 'intro' ? '在此处输入模板介绍' : '请输入模板标题'" />
</view>
</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();
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;
// 叠加倒计时:如果已经在倒计时中则累加,否则开始新的倒计时
if (restCountdownInterval) {
restCountdownSeconds.value += seconds;
restCountdownTotal.value += seconds;
} else {
restCountdownTotal.value = seconds;
restCountdownSeconds.value = seconds;
restCountdownInterval = setInterval(() => {
restCountdownSeconds.value--;
if (restCountdownSeconds.value <= 0) {
clearRestCountdown();
// 倒计时结束:不取消勾选状态,不恢复主计时器(主计时器始终在运行)
}
}, 1000);
}
}
// 不暂停主计时器,训练计时器始终继续
};
function onCancelRestTimer(unitIndex) {
clearRestCountdown();
currentRestUnitIndex.value = unitIndex;
uncheckCurrentUnit();
if (restTimerWasRunning) {
trainingStore.toggleTimer();
restTimerWasRunning = false;
function onCancelRestTimer(seconds) {
if (!restCountdownInterval) return;
// 取消勾选时减去对应秒数
restCountdownSeconds.value -= (seconds || 0);
restCountdownTotal.value -= (seconds || 0);
if (restCountdownSeconds.value <= 0) {
clearRestCountdown();
}
}
... ... @@ -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;
}
... ...