Authored by Bad

feat: 优化日历训练计划及自由训练流程

- 新增用户协议、隐私协议及使用教程页面
- 调整每日模板与历史记录的数据展示逻辑
- 支持未来日期保存为训练模板并锁定当日开练功能
... ... @@ -8,7 +8,7 @@ SHOPRO_VERSION=v2.4.1
# 后端接口 - 测试环境(通过 process.env.NODE_ENV = development)
SHOPRO_DEV_BASE_URL=http://192.168.1.200:48081
# SHOPRO_DEV_BASE_URL=http://192.168.1.85:48080
#SHOPRO_DEV_BASE_URL=https://xunji.geaktec.com
SHOPRO_DEV_BASE_URL=https://xunji.geaktec.com
# SHOPRO_DEV_BASE_URL=http://api-dashboard.yudao.iocoder.cn/
### SHOPRO_DEV_BASE_URL=http://10.171.1.188:48080
### SHOPRO_DEV_BASE_URL = http://yunai.natapp1.cc
... ... @@ -43,3 +43,5 @@ SHOPRO_TENANT_ID=1
# 默认头像
DEFAULT_AVATAR=https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260526/默认头像_1779779926983.png
# appLogo
APP_LOGO=https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260728/自己练logo_1785218679080.png
... ...
... ... @@ -116,7 +116,7 @@
{
"path": "pages/xunji/wode-jihua-paike",
"style": {
"navigationBarTitleText": "我的计划详情的排课设置"
"navigationBarTitleText": "计划排课"
}
},
{
... ... @@ -144,6 +144,13 @@
"style": {
"navigationBarTitleText": ""
}
},
{
"path": "pages/xunji/shiyon-jiaochen",
"style": {
"navigationBarTitleText": "使用教程"
}
}
]
},
... ... @@ -209,6 +216,18 @@
"navigationBarTitleText": "设置",
"navigationStyle": "default"
}
},
{
"path": "pages/user/yonhu-xieyi",
"style": {
"navigationBarTitleText": "用户协议"
}
},
{
"path": "pages/user/yinsi-xieyi",
"style": {
"navigationBarTitleText": ""
}
}
]
}
... ...
... ... @@ -17,7 +17,7 @@
<!-- 未登录引导区 -->
<view v-else class="section-card login-guide-box">
<view class="guide-txt">
<text class="title">欢迎加入鸿星运动</text>
<text class="title">欢迎加入自己练运动</text>
<text class="desc">登录后即可享受课程预约及资产管理</text>
</view>
<button class="login-btn" hover-class="btn-hover" @click="goLogin">立即登录</button>
... ... @@ -52,8 +52,9 @@
<uni-icons type="right" size="14" color="#E0E0E0" />
</view>
<view class="logout-wrap">
<button class="logout-btn" @click="handleLogout">退出登录</button>
<view class="settings-item logout-item" @click="handleLogout">
<text class="logout-label">退出登录</text>
<uni-icons type="right" size="14" color="#E0E0E0" />
</view>
</view>
... ... @@ -457,30 +458,15 @@ $transition-tap: background-color 0.2s ease, opacity 0.2s ease;
font-size: $font-body;
color: $color-text-primary;
}
}
.logout-wrap {
margin-top: 40rpx;
.logout-btn {
width: 100%;
background-color: $color-danger;
color: $color-text-white;
border: none;
border-radius: $radius-button;
padding: 26rpx 0;
font-size: 30rpx;
font-weight: 500;
transition: $transition-tap;
&::after {
border: none;
}
&:active {
background-color: $color-danger-active;
.logout-label {
font-size: $font-body;
color: $color-text-tertiary;
}
}
.logout-item {
border-bottom: none;
}
// 触摸反馈
... ...
... ... @@ -262,6 +262,13 @@ const exerciseNamesWithLabel = computed(() => {
})
const exposeRecordList = computed(() => {
// 每日模板编辑场景需要全量数据,不受 isActive 过滤影响
if (props.isDailyTemplates) {
return recordList.value.map(item => ({
...item,
weight: getRealWeight(item)
}))
}
return recordList.value.filter(item => item.isActive).map(item => ({
...item,
weight: getRealWeight(item)
... ... @@ -272,9 +279,11 @@ const exposeSuperRecordMap = computed(() => {
const map = {}
for (const key in superRecordMap.value) {
const subExercise = props.actionDetail?.exercises?.find(s => s.id == key)
map[key] = superRecordMap.value[key]
.filter(item => item.isActive)
.map(item => ({ ...item, weight: getRealWeight(item, subExercise?.exerciseType) }))
// 每日模板编辑场景需要全量数据,不受 isActive 过滤影响
const list = props.isDailyTemplates
? superRecordMap.value[key]
: superRecordMap.value[key]?.filter(item => item.isActive)
map[key] = (list || []).map(item => ({ ...item, weight: getRealWeight(item, subExercise?.exerciseType) }))
}
return map
})
... ... @@ -578,10 +587,13 @@ function onQuickConfirm(e) {
// ===================== 增删行/组 =====================
function addRow() {
recordList.value.push({
const last = recordList.value[recordList.value.length - 1]
const newItem = last ? JSON.parse(JSON.stringify(last)) : {
h: '00', m: '00', s: '00', quickTimeDisplay: formatQuickTime(60),
distance: '', weight: '', reps: '', duration: '', restTime: '', isActive: false
})
}
newItem.isActive = false
recordList.value.push(newItem)
}
function deleteRow(index) {
... ... @@ -595,10 +607,14 @@ function deleteRow(index) {
function addSuperSet() {
(props.actionDetail?.exercises || []).forEach(sub => {
if (superRecordMap.value[sub.id]) {
superRecordMap.value[sub.id].push({
const list = superRecordMap.value[sub.id]
const last = list[list.length - 1]
const newItem = last ? JSON.parse(JSON.stringify(last)) : {
h: '00', m: '00', s: '00', quickTimeDisplay: formatQuickTime(60),
distance: '', weight: '', reps: '', duration: '', restTime: '', isActive: false
})
}
newItem.isActive = false
list.push(newItem)
}
})
}
... ...
... ... @@ -155,7 +155,7 @@
<view class="section-title">训练部位</view>
<view class="muscle-card">
<image
src="https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260724/训练部位_1784878616826.jpg"
:src="actionDetail.urlImage || 'https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260724/训练部位_1784878616826.jpg'"
mode="widthFix" class="muscle-map" />
<!-- <view class="legend">
<view class="legend-item">
... ... @@ -172,13 +172,16 @@
<view class="muscle-group" v-if="actionDetail.primaryMuscleNames?.length">
<view class="group-label primary">主要训练部位</view>
<view class="pills">
<text class="pill primary-pill" v-for="(value, index) in actionDetail.primaryMuscleNames" :key="index">{{ value }}</text>
<text class="pill primary-pill" v-for="(value, index) in actionDetail.primaryMuscleNames"
:key="index">{{
value }}</text>
</view>
</view>
<view class="muscle-group" v-if="actionDetail.secondaryMuscleNames?.length">
<view class="group-label secondary">次要训练部位</view>
<view class="pills">
<text class="pill secondary-pill" v-for="(value, index) in actionDetail.secondaryMuscleNames" :key="index">{{ value }}</text>
<text class="pill secondary-pill" v-for="(value, index) in actionDetail.secondaryMuscleNames"
:key="index">{{ value }}</text>
</view>
</view>
</view>
... ... @@ -187,7 +190,7 @@
<view v-if="contentTab === 1" class="tab-pane slide-up">
<view class="history-list">
<template v-if="historyList && historyList.length > 0">
<view class="history-item" v-for="item in historyList" :key="item.id">
<view class="history-item" v-for="(item, index) in historyList" :key="index">
<view class="item-header">
<text class="date">{{ formatDate(item.date) }}</text>
<text class="tag">{{ item.name }}</text>
... ... @@ -198,14 +201,14 @@
<view class="dot">· </view>
<!-- <view class="difficulty">困难</view> -->
<view class="difficulty">{{
item.weight ? item.weight + 'kg' : '无负重'
item.weight != null ? item.weight + 'kg' : '无负重'
}}</view>
</view>
<view class="group-chips">
<view class="chip" v-for="(set, index) in item.setConfigList" :key="index">
<view class="idx">{{ index + 1 }}</view>
<view class="group-data">
{{ formatSetData(set) }}
{{ formatSetData(set, item.displayFields) }}
</view>
</view>
</view>
... ... @@ -490,17 +493,32 @@ const loadTrainHistoryDetail = async (id, type) => {
}
};
// 格式化时间
const formatDate = (dateArr) => {
if (!Array.isArray(dateArr) || dateArr.length < 3) return '';
return dayjs(new Date(dateArr[0], dateArr[1] - 1, dateArr[2])).format('YYYY/MM/DD dd');
const formatDate = (date) => {
if (!date) return '';
// 支持数组格式 [2026, 7, 28] 和字符串格式
if (Array.isArray(date) && date.length >= 3) {
return dayjs(new Date(date[0], date[1] - 1, date[2])).format('YYYY/MM/DD');
}
return dayjs(date).format('YYYY/MM/DD');
};
const formatSetData = (set) => {
const formatSetData = (set, displayFields) => {
if (!displayFields || displayFields.length === 0) return '无数据';
const parts = [];
if (set.weight != null && set.weight !== '') parts.push(`${set.weight}kg`);
if (set.reps != null && set.reps !== '') parts.push(`${set.reps}次`);
if (set.duration != null && set.duration !== '') parts.push(dayjs.duration(set.duration, 'seconds').format('HH:mm:ss'));
if (set.distance != null && set.distance !== '') parts.push(`${set.distance}m`);
const fieldMap = {
weight: () => set.weight != null ? `${set.weight}kg` : null,
reps: () => set.reps != null ? `${set.reps}次` : null,
distance: () => set.distance != null ? `${set.distance}km` : null,
duration: () => set.duration != null ? dayjs.duration(set.duration, 'seconds').format('HH:mm:ss') : null,
restTime: () => set.restTime != null ? `休息${set.restTime}s` : null,
};
displayFields.forEach(field => {
const fn = fieldMap[field];
if (fn) {
const result = fn();
if (result) parts.push(result);
}
});
return parts.length > 0 ? parts.join(' × ') : '无数据';
};
... ...
... ... @@ -64,14 +64,17 @@
<WodeJihuaTianjiaTancuang
v-model:visible="showPlanPopup"
@get-plan-list-length="getPlanListLength"
@success="handlePlanAddSuccess"
:is-add="true"
:plan-id="lastPlanId"
:is-my-plan="true"
:selected-date="props.date"
/>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import dayjs from 'dayjs'
import WodeJihuaTianjiaTancuang from '@/pages/xunji/components/wode-jihua-tianjia-tancuang.vue'
import QueryPlanApi from '@/sheep/api/plan/queryplan'
... ... @@ -81,9 +84,13 @@ const props = defineProps({
type: Boolean,
default: false,
},
date: {
type: String,
default: '',
},
})
const emit = defineEmits(['update:visible'])
const emit = defineEmits(['update:visible', 'successAddTrain'])
// ==================== 响应式状态 ====================
const showPlanPopup = ref(false)
... ... @@ -113,15 +120,29 @@ const handleAddFromPlan = () => {
showPlanPopup.value = true
}
/** 从训练计划添加成功后,通知父组件刷新 */
const handlePlanAddSuccess = () => {
emit('successAddTrain')
}
/** 使用训练模板:跳转模板选择页 */
const handleAddFromTemplate = () => {
uni.navigateTo({ url: '/pages4/pages/xunji/xunji-rili-tianjia-moban' })
uni.navigateTo({ url: `/pages4/pages/xunji/xunji-rili-tianjia-moban?date=${props.date}` })
close()
}
/** 自由训练:跳转动作练习页 */
/** 自由训练:非未来日期直接训练,未来日期保存为训练模板 */
const handleFreeTraining = () => {
uni.navigateTo({ url: '/pages4/pages/xunji/xunji-dongzuo-lianxi?isTraining=true' })
const today = dayjs().format('YYYY-MM-DD')
if (props.date && props.date > today) {
// 未来日期:跳转训练页构建动作,完成后保存为当日模板
uni.navigateTo({
url: `/pages4/pages/xunji/xunji-dongzuo-lianxi?isFutureTemplate=true&targetDate=${props.date}`,
})
} else {
// 非未来日期:直接开始训练,携带目标日期确保训练记录日期正确
uni.navigateTo({ url: `/pages4/pages/xunji/xunji-dongzuo-lianxi?isTraining=true&targetDate=${props.date}` })
}
close()
}
... ...
... ... @@ -27,21 +27,17 @@
</view>
<template v-if="resdailyData.length > 0">
<!-- Tab 按钮:如���有多个训记,用来源名作为标签 -->
<!-- Tab 按钮:如有多个训记,用来源名作为标签 -->
<scroll-view class="plan-tabs" scroll-x v-if="resdailyData.length > 1">
<button class="tab-btn" :class="{ active: currentPlanIndex === index }" v-for="(item, index) in resdailyData"
:key="index" @click.stop="switchPlan(index)">
{{ item.sourceType === 1 || item.sourceType === 2 ? (item.sourceName || item.name) : (item.name || '训记' + (index + 1)) }}
{{ item.sourceType === 1 || item.sourceType === 2 ? (item.sourceName || item.name) : (item.name || '训记' +
(index + 1)) }}
</button>
</scroll-view>
<!-- 训练内容区域 -->
<view v-if="resdailyData.length > 0 && currentPlan" class="training-container">
<!-- 训练来源标签 -->
<view class="source-tag-row" :class="getSourceClass(currentPlan.sourceType || 4)">
<text class="source-text">
{{ currentPlan.sourceType === 1 || currentPlan.sourceType === 2 ? (currentPlan.sourceName || currentPlan.name) : getSourceLabel(currentPlan.sourceType || 4) }}
</text>
</view>
<view class="unitCart">
<!-- 训练计划头部卡片 -->
<view class="plan-header-card">
... ... @@ -60,8 +56,10 @@
<view class="plan-header-btns">
<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>
<button class="plan-btn go-train-btn-small" :class="{ locked: trainAgainDisabled }"
@click="handleTrainAgain">
<text class="go-train-text-sm">{{ trainAgainText }}</text>
<up-icon v-if="trainAgainText == '当日开练'" name="lock" size="15"></up-icon>
</button>
</view>
</view>
... ... @@ -88,7 +86,7 @@
}}kg</text>
</view>
<!-- 下半部分:组次行 → 向左对齐图片 ✅核心-->
<!-- 下半部分:组次行 → 向左对齐图片 ✅核心 -->
<view class="action-bottom">
<view class="action-sets">
<view class="set-item" v-for="(set, setIdx) in unit.exercises[0]?.sets || []" :key="setIdx">
... ... @@ -175,7 +173,7 @@
<!-- 空状态区域 -->
<view v-else class="empty-section">
<image class="empty-img"
src="https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260724/空状态自助_1784875704380.png"
src="https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260727/空状态自助_1785117390333.png"
mode="aspectFit">
</image>
<text class="empty-tip">今天没有安排</text>
... ... @@ -184,7 +182,7 @@
</view>
<AddTrainPopup v-model:visible="showAddTrainPopup" @successAddTrain="handleAddTrain" />
<AddTrainPopup v-model:visible="showAddTrainPopup" :date="selectedDate" @successAddTrain="handleAddTrain" />
<!-- 日期备注弹窗 -->
<RiliRiqibeizhu v-model:visible="showRiqibeizhu" :date="date" :note-id="currentEditId"
... ... @@ -230,7 +228,7 @@
<!-- 复制到弹窗/移动到/添加到 -->
<AddToCalendarPopup ref="calendarPopupRef" :template-id="currentPlan?.templateId ?? 0"
@success="handleCalendarSuccess" :mask-click="true" @click.stop
:daily-template-id="currentPlan?.dailyTemplateId" :is-copy="isCopyMode" />
:daily-template-id="currentPlan?.id" :is-copy="isCopyMode" />
<!-- 设置日历颜色弹窗 -->
<CalendarColorPicker v-model:visible="showColorPopup" :daily-template-id="selectedPlanId"
:current-color="currentPlan?.templateBackgroundColor || '#ffffff'" @success="calendarColorPickerSuccess" />
... ... @@ -242,7 +240,7 @@
<script setup>
import dayjs from 'dayjs';
import { ref, onMounted, watch, computed, nextTick } from 'vue';
import { ref, watch, computed, nextTick, onMounted, onBeforeUnmount } from 'vue';
import dailytemplateApi from '@/sheep/api/Template/Dailytemplate';
import TemplatesApi from '@/sheep/api/Template/Templates';
import RiliRiqibeizhu from '@/pages/xunji/components/rili-components/rili-riqibeizhu.vue'
... ... @@ -278,7 +276,7 @@ const moreShow = ref(false);
const showColorPopup = ref(false);
const calendarPopupRef = ref(null);
const currentEditId = ref(null);
const selectedPlanId = ref(null);
const selectedPlanId = ref(0);
// Store / 子组件引用
const trainingStore = useTrainingStore();
... ... @@ -325,7 +323,7 @@ const openXiuGai = (unit, idx) => {
// 打开日历弹窗
const openColorPopup = () => {
if (!currentPlan.value) { // 加这个
if (!currentPlan.value) {
uni.showToast({ title: '空白状态无法操作', icon: 'none' })
return
}
... ... @@ -340,11 +338,10 @@ const openColorPopup = () => {
const handleAddTrain = async () => {
await loaddailytemplate();
// 更新主页面
emit('refreshCalendar');
}
// ===== 计算属性 =====
// 当前正在显示的训记
const currentPlan = computed(() => {
if (!resdailyData.value.length) return null;
return resdailyData.value[currentPlanIndex.value] || null;
... ... @@ -353,29 +350,19 @@ const currentPlan = computed(() => {
// ===== API:加载每日模板数据 =====
const loaddailytemplate = async () => {
if (!selectedDate.value) return;
console.log('【子组件】开始加载数据...');
try {
const resdaily = await dailytemplateApi.getdailytemplate(String(selectedDate.value));
console.log('打印每日模板数据:resdaily', resdaily);
const noteListFromApi = resdaily.data.notes;
noteList.value = noteListFromApi;
console.log('打印每日模板详情的备注列表:noteList', noteList.value);
const historyFromApi = resdaily.data.noteHistoryList || [];
console.log('打印历史备注:', historyFromApi);
noteHistoryList.value = historyFromApi;
resdailyData.value = resdaily.data.templates || [];
// 切换回第一个训记
const oldIndex = currentPlanIndex.value;
if (resdailyData.value.length > oldIndex) {
currentPlanIndex.value = oldIndex;
} else {
currentPlanIndex.value = 0;
}
console.log('+++++【子组件】+++++加载完成,训记列表:', resdailyData.value)
} catch (error) {
console.error('加载训练模板失败:', error);
resdailyData.value = [];
... ... @@ -383,21 +370,18 @@ const loaddailytemplate = async () => {
};
const handleRenderSuccess = async () => {
await loaddailytemplate();
await nextTick()
emit('refreshCalendar');
uni.$emit('calendarDataRefresh');
}
const switchPlan = (index) => {
currentPlanIndex.value = index;
console.log('【切换标签后 - currentPlan】', currentPlan.value);
};
// ===== 更多弹窗:操作项 =====
const handlePlanMore = () => {
if (!currentPlan.value) { // 加这个
return
}
if (!currentPlan.value) return
moreShow.value = true;
}
... ... @@ -407,28 +391,20 @@ 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
}
console.log('进入一键打勾函数训练id=', currentPlan.value.id);
try {
// 2. 调用接口
await dailytemplateApi.completeDailyTemplate(currentPlan.value.id)
emit('refreshCalendar');
uni.showToast({ title: '一键打勾成功', icon: 'success' })
closeMorePopup()
// 刷新页面数据
await loaddailytemplate(selectedDate.value)
} catch (err) {
console.error('一键打勾失败:', err)
... ... @@ -438,54 +414,39 @@ const handleCompleteCheck = async () => {
// ===== 保存训练为个人模板 =====
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: '确认保存为你的训练模板?',
content: '',
editable: true, // 开启输入框
editable: true,
placeholderText: '请输入模板名称',
success: async (res) => {
// 点击确定
if (res.confirm) {
const templateName = res.content.trim()
// 非名校验
if (!templateName) {
return uni.showToast({ title: '请填写模板名称', icon: 'none' })
}
// 调用封装的数据转换函数,组装后端需要的入参
const reqData = formatTrainToTemplate(item, templateName)
// 发起创建接口
console.log('reqData', reqData);
await submitCreateTemplate(reqData)
}
}
})
}
/**
*/
const formatTrainToTemplate = (item, templateName) => {
// 初始化基础结构
const params = {
templateId: item.templateId,
// groupId: 0, // 默认存入根目录,后续选文件夹再传对应groupId
templateName: templateName,
scene: item.scene || 0,
templateCover: item.urlCover || '', // 封面取自训练封面
templateIntroduction: item.templateIntroduction || '', // 简介留空,可后续扩展
templateCover: item.urlCover || '',
templateIntroduction: item.templateIntroduction || '',
units: []
}
// 循环转换 units 结构(训练组→模板unit)
params.units = item.units.map(unit => {
return {
id: unit.unitId,
... ... @@ -495,16 +456,11 @@ const formatTrainToTemplate = (item, templateName) => {
exercises: unit.exercises
}
})
console.log('params', params);
return params
}
const submitCreateTemplate = async (reqData) => {
try {
// 调用创建个人模板接口
const res = await TemplatesApi.createCustTemplate(reqData)
if (res.code === 0) {
uni.showToast({ title: '模板保存成功' })
... ... @@ -521,9 +477,6 @@ const submitCreateTemplate = async (reqData) => {
const openAddTrainPopup = () => {
showAddTrainPopup.value = true;
};
const closeAddTrainPopup = () => {
showAddTrainPopup.value = false;
};
// ===== 工具函数 =====
const formatSecondsToHms = (seconds) => {
... ... @@ -531,7 +484,6 @@ const formatSecondsToHms = (seconds) => {
return dayjs.duration(parseInt(seconds, 10), 'seconds').format('HH:mm:ss');
}
// ===== 工具函数:根据动作类型生成组次显示文本 =====
const getSetDisplayText = (set, exerciseType) => {
if (!set) return '';
switch (exerciseType) {
... ... @@ -546,7 +498,6 @@ const getSetDisplayText = (set, exerciseType) => {
}
};
// 超级组中的动作显示(与普通动作在 type 4/5 上的展示略有差异)
const getSuperSetDisplayText = (set, exerciseType) => {
if (!set) return '';
switch (exerciseType) {
... ... @@ -561,17 +512,6 @@ const getSuperSetDisplayText = (set, exerciseType) => {
}
};
// 返回按钮逻辑
const goBack = () => {
uni.navigateBack({
delta: 1,
fail: () => {
uni.redirectTo({ url: '/pages/xunji/xunji-rili' });
},
});
};
// 日期格式化(使用 dayjs 处理,兼容性更好)
const formatDateWithWeek = (dateStr) => {
if (!dateStr) return '';
const d = dayjs(dateStr);
... ... @@ -582,18 +522,13 @@ const formatDateWithWeek = (dateStr) => {
// ===== 日期备注相关 =====
const handleDateNote = () => {
if (noteList.value.length >= 5) {
uni.showToast({
title: '最多添加5条,已超出数量无法添加',
icon: 'none',
duration: 2000
});
uni.showToast({ title: '最多添加5条,已超出数量无法添加', icon: 'none', duration: 2000 });
return;
}
showRiqibeizhu.value = true
}
const handleEditNote = (note) => {
console.log('准备编辑备注,ID:', note.id);
currentEditId.value = note.id;
showRiqibeizhu.value = true;
};
... ... @@ -608,8 +543,6 @@ const handleCloseNotePopup = () => {
// ===== 复制到 / 移动到 =====
const openCalendarPopup = (templateId) => {
console.log('点击了添加到日历按钮', templateId); // 新增日志
console.log('子组件实例', calendarPopupRef.value); // 新增日志
if (calendarPopupRef.value && typeof calendarPopupRef.value.open === 'function') {
calendarPopupRef.value.open();
} else {
... ... @@ -617,19 +550,47 @@ const openCalendarPopup = (templateId) => {
}
};
// 今日再练 / 去训练 / 当日开练
const trainAgainText = computed(() => {
const today = dayjs().format('YYYY-MM-DD');
if (!selectedDate.value) return '今日再练';
if (selectedDate.value === today) return '今日再练';
if (selectedDate.value > today) return '当日开练';
return '去训练';
});
const trainAgainDisabled = computed(() => {
const today = dayjs().format('YYYY-MM-DD');
return !!selectedDate.value && selectedDate.value > today;
});
// 2. 今日再练方法
const handleTrainAgain = async () => {
const today = dayjs().format('YYYY-MM-DD');
const planDate = selectedDate.value || today;
if (planDate > today) {
uni.showToast({ title: '训练将在当日解锁', icon: 'none' });
return;
}
if (planDate < today) {
trainingStore.isSystem = currentPlan.value.isSystem || false;
trainingStore.loadDailyTemplateForEdit(currentPlan.value);
trainingStore.initDailyTemplateRecords();
uni.navigateTo({
url: `/pages4/pages/xunji/xunji-dongzuo-lianxi?id=${currentPlan.value.templateId}&type=3&dailyTemplateId=${currentPlan.value.id}&isTraining=true`,
});
return;
}
if (!currentPlan.value?.templateId) {
uni.showToast({ title: '未找到训练计划', icon: 'none' });
return;
}
const todayDate = new Date().toISOString().split('T')[0]; // 格式:2026-05-19
const reqData = {
id: currentPlan.value.templateId,
// planId: currentPlan.value.planId || 0,
trainDateList: [todayDate] // 训练日期列表,这里只传今天
trainDateList: [today],
};
try {
... ... @@ -637,7 +598,7 @@ const handleTrainAgain = async () => {
if (res.code === 0) {
uni.showToast({ title: '今日再练成功', icon: 'success' });
loaddailytemplate(props.date);
emit('refreshCalendar')
emit('refreshCalendar');
} else {
uni.showToast({ title: res.msg || '添加失败', icon: 'none' });
}
... ... @@ -646,22 +607,19 @@ const handleTrainAgain = async () => {
uni.showToast({ title: '网络异常,请重试', icon: 'none' });
}
};
// 删除训练
const handleDeleteTemplate = async () => {
// 先判断有没有ID
if (!currentPlan.value?.id) {
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: '确认删除',
content: '确定要删除这条训练记录吗?删除后无法恢复',
... ... @@ -674,8 +632,8 @@ const handleDeleteTemplate = async () => {
await dailytemplateApi.deleteDailyTemplate(currentPlan.value.id);
emit('refreshCalendar');
uni.showToast({ title: '删除成功', icon: 'success' });
loaddailytemplate(props.date); // 刷新数据
moreShow.value = false; // 关闭弹窗
loaddailytemplate(props.date);
moreShow.value = false;
} catch (err) {
console.error('删除失败', err);
uni.showToast({ title: '删除失败,请重试', icon: 'none' });
... ... @@ -687,13 +645,8 @@ const handleDeleteTemplate = async () => {
// 更多弹窗的结束整个计划
const confirmDelete = async (id) => {
try {
console.log('打印计划个人id:', id);
const res = await QueryPlanApi.deletePlan(id);
console.log('删除结果:', res);
if (res.code === 0 && res.data) {
// 2. 删除成功,从列表移除
uni.showToast({ title: '计划已结束', icon: 'success' });
emit('refreshCalendar');
} else {
... ... @@ -710,18 +663,12 @@ const confirmDelete = async (id) => {
// 移动到:只打开弹窗
const handleMoveTo = async () => {
console.log('点击了更多弹窗的移动到');
// 安全判断
if (!currentPlan.value) {
uni.showToast({ title: '未找到训练信息', icon: 'none' });
return;
}
isMoveMode.value = true;
// 关闭更多弹窗
moreShow.value = false;
// 只做一件事:打开日期选择
nextTick(() => {
openCalendarPopup(currentPlan.value.templateId);
});
... ... @@ -730,9 +677,8 @@ const handleMoveTo = async () => {
// 复制到
const openCopyCalendarPopup = (templateId) => {
if (!templateId) return uni.showToast({ title: '模板ID不存在', icon: 'none' })
console.log('currentPlan?.dailyTemplateId=', currentPlan?.dailyTemplateId);
isCopyMode.value = true
isMoveMode.value = false // 清空移动标记,互斥
isMoveMode.value = false
moreShow.value = false
nextTick(() => {
calendarPopupRef.value.open();
... ... @@ -740,35 +686,13 @@ const openCopyCalendarPopup = (templateId) => {
}
const handleCalendarSuccess = () => {
// console.log('✅ 添加日历成功,当前模式:', isMoveMode.value ? '移动' : '复制');
// 复制模式:只提示成功,不用删原数据
// if (!isMoveMode.value) {
// uni.showToast({
// title: '复制成功',
// icon: 'success'
// });
// return;
// }
console.log('✅ 添加日历成功', {
move: isMoveMode.value,
copy: isCopyMode.value
});
// 复制模式:仅刷新日历,不删除原数据
if (isCopyMode.value) {
uni.showToast({
title: '复制成功',
icon: 'success'
});
// 重置标记
uni.showToast({ title: '复制成功', icon: 'success' });
isCopyMode.value = false
emit('refreshCalendar')
return;
}
// 移动模式:子组件已把数据新增到新日期,这里删除原日期的训练记录
if (!currentPlan.value?.id) {
return;
}
if (!currentPlan.value?.id) return;
dailytemplateApi.deleteDailyTemplate(currentPlan.value.id)
.then(() => {
uni.showToast({ title: '移动成功', icon: 'success' });
... ... @@ -788,15 +712,10 @@ const calendarColorPickerSuccess = () => {
// ===== 去训练 / 每日模板编辑 =====
const startTraining = () => {
if (trainingStore.isTraining) {
uni.showToast({
title: '当前已有正在进行的训练',
icon: 'none'
});
uni.showToast({ title: '当前已有正在进行的训练', icon: 'none' });
return;
}
trainingStore.isTraining = true;
if (resdailyData.value.length > 0) {
trainingStore.isSystem = currentPlan.value.isSystem || false;
... ... @@ -805,52 +724,44 @@ const startTraining = () => {
uni.navigateTo({
url: `/pages4/pages/xunji/xunji-dongzuo-lianxi?id=${currentPlan.value.templateId}&type=3&dailyTemplateId=${currentPlan.value.id}&isTraining=true`,
});
console.log('去训练开始进入编辑模板页面,模板ID:', currentPlan.value.templateId, '每日模板ID:', currentPlan.value.id);
console.log('打印传递给动作训练页面的模板id', currentPlan.value.templateId);
} else {
uni.navigateTo({ url: '/pages4/pages/xunji/xunji-dongzuo-lianxi' })
}
};
// 每日模板编辑
const templateEdit = (plan) => {
trainingStore.isSystem = plan.isSystem;
trainingStore.loadDailyTemplateForEdit(plan);
trainingStore.initDailyTemplateRecords()
uni.navigateTo({
url: `/pages4/pages/xunji/xunji-dongzuo-lianxi?id=${plan.templateId}&type=3&dailyTemplateId=${plan.id}`,
url: `/pages4/pages/xunji/xunji-dongzuo-lianxi?id=${plan.templateId}&type=3&dailyTemplateId=${plan.id}&isEdit=true`,
});
console.log('开始进入编辑模板页面,模板ID:', plan.templateId, '每日模板ID:', plan.id);
};
// ===== 监听 & 生命周期 =====
// 监听父组件传进来的 date 变化
watch(
() => props.date,
(newDate) => {
if (newDate && newDate.trim()) {
console.log('子组件监听到日期变化:', newDate);
selectedDate.value = newDate;
displayDate.value = formatDateWithWeek(newDate);
loaddailytemplate(newDate);
console.log('+++++++currentPlan:+++++++++++++++++', currentPlan.value)
// console.log('props.noteList=', props.noteList);
}
},
{ immediate: true }
);
// 监听来自编辑/训练页面的数据刷新事件,自动刷新弹窗数据
const handleCalendarDataRefresh = () => {
loaddailytemplate();
};
onMounted(() => {
console.log('【rilicell子组件】已挂载!!!');
console.log('【子组件】props.date:', props.date);
// loaddailytemplate(props.date);
uni.$on('calendarDataRefresh', handleCalendarDataRefresh);
});
onBeforeUnmount(() => {
uni.$off('calendarDataRefresh', handleCalendarDataRefresh);
});
</script>
... ... @@ -860,13 +771,6 @@ $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 {
... ... @@ -888,27 +792,11 @@ $radius-round: 40rpx;
justify-content: space-between;
padding: 24rpx 20rpx;
background-color: #fff;
// position: sticky;
top: 0;
z-index: 10;
gap: 16rpx;
}
.back-btn {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.date-wrapper {
flex-grow: 1;
text-align: left;
}
.close-btn {
.close-btn {
width: 50rpx;
height: 50rpx;
display: flex;
... ... @@ -917,16 +805,21 @@ $radius-round: 40rpx;
background: #f2f3f5;
border-radius: 50%;
flex-shrink: 0;
}
}
.date-wrapper {
flex-grow: 1;
text-align: left;
}
.date-text {
.date-text {
font-size: 28rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.date-note-btn {
.date-note-btn {
display: inline-flex;
align-items: center;
font-size: 28rpx;
... ... @@ -937,9 +830,9 @@ $radius-round: 40rpx;
border: none;
margin-right: 0;
flex-shrink: 0;
}
}
.go-train-btn {
.go-train-btn {
display: flex;
align-items: center;
background: #ffc107;
... ... @@ -947,49 +840,22 @@ $radius-round: 40rpx;
padding: 12rpx 24rpx;
border: none;
flex-shrink: 0;
}
.go-train-text {
.go-train-text {
font-size: 28rpx;
color: #000;
margin-right: 8rpx;
font-weight: 500;
}
}
.go-train-tag {
.go-train-tag {
font-size: 22rpx;
color: #ffc107;
background: #000;
border-radius: 50%;
padding: 4rpx 8rpx;
font-weight: bold;
}
/* 训练来源标签 */
.source-tag-row {
display: flex;
align-items: center;
padding: 16rpx 24rpx;
margin: 0rpx 20rpx;
border-radius: 12rpx;
font-size: 24rpx;
font-weight: 500;
.source-text {
color: #fff;
}
&.source-plan {
background: #3b82f6; /* 蓝色 - 计划 */
}
&.source-tpl {
background: #8b5cf6; /* 紫色 - 模板 */
}
&.source-custom {
background: #f59e0b; /* 橙色 - 自定义 */
}
&.source-free {
background: #10b981; /* 绿色 - 自由 */
}
}
... ... @@ -1003,102 +869,84 @@ $radius-round: 40rpx;
padding: 32rpx 24rpx;
margin: 20rpx;
margin-bottom: 24rpx;
}
.plan-header-img {
.plan-header-img {
display: none;
}
}
.plan-header-info {
.plan-header-info {
flex-grow: 1;
margin-bottom: 32rpx;
}
}
.plan-header-top {
.plan-header-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12rpx;
}
.plan-heander {
display: flex;
}
}
.plan-title {
.plan-title {
font-size: 36rpx;
font-weight: bold;
display: block;
margin-bottom: 12rpx;
color: #fff;
}
}
// 卡片头部的编辑按钮
.editButton {
.editButton {
display: flex;
align-items: center;
gap: 4rpx;
color: #fff;
font-size: 26rpx;
opacity: 0.8;
/* 让文字和图标稍微淡一点,和例图更像 */
border: 1rpx solid rgba(255, 255, 255, 0.7);
border-radius: 25rpx;
padding: 6rpx 12rpx;
}
}
.plan-meta {
.plan-meta {
font-size: 26rpx;
color: #ccc;
display: block;
}
}
.plan-header-btns {
.plan-header-btns {
display: flex;
flex-direction: row;
align-items: center;
gap: 20rpx;
justify-content: flex-start;
}
.plan-btn {
.plan-btn {
font-size: 26rpx;
border-radius: 40rpx;
border: none;
padding: 12rpx 28rpx;
line-height: 1;
}
}
.more-btn,
.copy-btn {
.more-btn,
.copy-btn {
background: #fff;
color: #000;
border: none;
}
}
.go-train-btn-small {
.go-train-btn-small {
color: #000;
display: flex;
align-items: center;
padding: 12rpx 20rpx;
background: inherit;
}
.go-train-text-sm {
.go-train-text-sm {
font-size: 24rpx;
margin-right: 4rpx;
font-weight: 500;
}
.go-train-tag-sm {
font-size: 20rpx;
background: #000;
color: #ffc107;
border-radius: 4rpx;
padding: 2rpx 6rpx;
font-weight: bold;
}
}
}
}
/* 训练动作列表 */
... ... @@ -1114,29 +962,49 @@ $radius-round: 40rpx;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.set-index {
display: none;
}
.set-content {
.set-content {
flex-grow: 1;
color: inherit;
display: flex;
justify-content: space-between;
white-space: nowrap;
}
}
.set-item {
display: flex;
align-items: center;
font-size: 26rpx;
color: #666;
justify-content: space-between;
.detai-data {
white-space: nowrap;
.set-left {
display: flex;
align-items: center;
gap: 8rpx;
}
.set-right {
display: flex;
gap: 20rpx;
}
.rest-time {
font-size: 24rpx;
margin-left: 40rpx;
flex-shrink: 0;
}
.check {
font-size: 26rpx;
color: #333;
margin-left: 20rpx;
flex-shrink: 0;
}
}
}
// 主卡片:横向排列
/* 主卡片:横向排列 */
.action-item {
display: flex;
align-items: flex-start;
... ... @@ -1145,134 +1013,60 @@ $radius-round: 40rpx;
padding: 24rpx 20rpx;
position: relative;
gap: 20rpx;
}
// 左侧:序号 + 图片
.action-left {
.action-left {
display: flex;
align-items: center;
gap: 16rpx;
flex-shrink: 0;
}
}
// 大序号
.action-index {
.action-index {
font-size: 34rpx;
font-weight: 600;
color: #333;
line-height: 1;
}
}
// 动作图片
.action-img {
.action-img {
width: 80rpx;
height: 80rpx;
border-radius: 8rpx;
}
}
// 中间区域
.action-middle {
.action-middle {
flex: 1;
display: flex;
flex-direction: column;
}
}
// 上半部分:名称 + 重量(左对齐)
.action-top {
.action-top {
margin-bottom: 8rpx;
}
.action-name {
.action-name {
font-size: 32rpx;
font-weight: 500;
color: #111;
display: block;
}
}
.action-totalWeight {
.action-totalWeight {
font-size: 26rpx;
color: #333;
margin-top: 6rpx;
display: block;
}
}
}
// 下半部分:组次行 → 向左对齐图片 ✅ 核心
.action-bottom {
.action-bottom {
margin-top: 6rpx;
margin-left: -96rpx;
/* 往左移动,对齐图片位置 */
}
.set-item {
display: flex;
align-items: center;
font-size: 26rpx;
color: #666;
justify-content: space-between;
}
.set-left {
display: flex;
align-items: center;
gap: 8rpx;
/* 控制 indexData、set-content、rest-time 之间的间距 */
}
.set-right {
display: flex;
gap: 20rpx;
}
// 灰色小圆点序号
.indexData {
width: 32rpx;
height: 32rpx;
border-radius: 50%;
background: #f2f3f5;
font-size: 22rpx;
color: #999;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 12rpx;
flex-shrink: 0;
}
// .set-content {
// color: #333;
// }
.rest-time {
// margin-left: 100rpx;
font-size: 24rpx;
// color: inherit;
margin-left: 40rpx;
flex-shrink: 0;
}
.check {
font-size: 26rpx;
color: #333;
margin-left: 20rpx;
/* 这里控制和左边内容的距离,数值你可以自己调 */
flex-shrink: 0;
/* 防止被压缩 */
}
}
// 右侧:修改 + 对勾
// .action-right {
// display: flex;
// flex-direction: column;
// align-items: center;
// justify-content: space-between;
// height: 100%;
// }
.action-right {
.action-right {
flex-shrink: 0;
}
.modify-btn {
.modify-btn {
font-size: 26rpx;
color: #333;
background: #f2f3f5;
... ... @@ -1280,18 +1074,10 @@ $radius-round: 40rpx;
border-radius: 40rpx;
padding: 8rpx 20rpx;
line-height: 1;
}
}
}
// .check-icon {
// font-size: 30rpx;
// color: #333;
// margin-top: 24rpx;
// }
//=========================
/* 空状态区域 */
.empty-section {
flex: 1;
... ... @@ -1301,32 +1087,32 @@ $radius-round: 40rpx;
justify-content: center;
padding: 80rpx 40rpx 120rpx;
box-sizing: border-box;
}
.empty-img {
.empty-img {
width: 360rpx;
height: 360rpx;
margin-bottom: 40rpx;
transform: scale(3);
}
transform: scale(1.5);
}
.empty-tip {
.empty-tip {
font-size: 32rpx;
color: #666;
font-weight: 500;
margin-bottom: 80rpx;
letter-spacing: 2rpx;
}
}
.add-train-btn {
.add-train-btn {
font-size: 30rpx;
color: #000;
background: $color-accent;
border-radius: $radius-round;
border-radius: 40rpx;
padding: 24rpx 80rpx;
border: none;
font-weight: 500;
box-shadow: 0 4rpx 12rpx rgba(255, 193, 7, 0.3);
}
}
/* 底部课程类型区域 */
... ... @@ -1346,25 +1132,25 @@ $radius-round: 40rpx;
background: #fff;
border-radius: 12rpx;
padding: 30rpx 0;
}
.course-icon {
.course-icon {
width: 100rpx;
height: 100rpx;
border-radius: 50%;
margin-bottom: 16rpx;
}
}
.course-title {
.course-title {
font-size: 28rpx;
color: #333;
margin-bottom: 8rpx;
font-weight: 500;
}
}
.course-desc {
.course-desc {
font-size: 24rpx;
color: #999;
}
}
/* 添加自助训练底部弹窗 */
... ... @@ -1389,14 +1175,6 @@ $radius-round: 40rpx;
box-sizing: border-box;
}
// .popup-indicator {
// width: 80rpx;
// height: 8rpx;
// background: #ddd;
// border-radius: 4rpx;
// margin: 0 auto 30rpx;
// }
.popup-title {
font-size: 34rpx;
color: #333;
... ... @@ -1410,16 +1188,12 @@ $radius-round: 40rpx;
display: flex;
flex-direction: column;
gap: 20rpx;
}
.popup-option-btn {
.popup-option-btn {
width: 100%;
height: 80rpx;
font-size: 30rpx;
// color: #ffffff;
// background: #f5f5f5;
border: none;
// border-radius: 12rpx;
display: flex;
flex-direction: column;
align-items: center;
... ... @@ -1429,27 +1203,26 @@ $radius-round: 40rpx;
.small {
font-size: 18rpx;
}
}
}
// 更多弹窗
/* ========== 更多弹窗样式 ========== */
:deep(.up-popup__content) {
border-radius: 10rpx 10rpx 0 0;
background: #f5f5f5;
padding: 30rpx 20rpx;
}
/* 更多弹窗 */
// ::deep(.up-popup__content) {
// border-radius: 10rpx 10rpx 0 0;
// background: #f5f5f5;
// padding: 30rpx 20rpx;
// }
.popup-header {
text-align: center;
margin-top: 30rpx;
margin-bottom: 30rpx;
}
.popup-title {
.popup-title {
font-size: 34rpx;
font-weight: 600;
color: #111;
}
}
.popup-list {
... ... @@ -1464,15 +1237,23 @@ $radius-round: 40rpx;
padding: 24rpx 20rpx;
background: transparent;
gap: 16rpx;
}
.popup-item:not(:last-child) {
&:not(:last-child) {
border-bottom: 1rpx solid #eee;
}
}
.item-text {
.item-text {
font-size: 30rpx;
color: #333;
}
&.delete-item {
background: #fff;
.item-text {
color: #ff4d4f;
}
}
}
.popup-divider {
... ... @@ -1481,36 +1262,25 @@ $radius-round: 40rpx;
margin: 20rpx 0;
}
.delete-item {
background: #fff;
}
.delete-text {
color: #ff4d4f;
}
/* 按钮通用重置 */
button {
.container {
/* 按钮通用重置 */
button {
line-height: 1;
}
button::after {
&::after {
border: none;
}
}
}
/* 训记切换标签 */
.plan-tabs {
white-space: nowrap;
padding: 16rpx 20rpx;
background: #fff;
// 给内部的标签留出间距,同时让它不换行
// display: flex;
gap: 16rpx;
}
.tab-btn {
.tab-btn {
display: inline-block;
padding: 10rpx 24rpx;
border-radius: 32rpx;
... ... @@ -1519,18 +1289,19 @@ button::after {
border: none;
flex-shrink: 0;
white-space: nowrap;
}
.tab-btn.active {
&.active {
background: #000;
color: #fff;
}
}
}
/* ========== 超级组样式 ========== */
/* 超级组样式 */
.superset-wrapper {
margin-bottom: 20rpx;
background: $color-white;
border-radius: $radius-md;
border-radius: 12rpx;
overflow: hidden;
.super-head {
... ... @@ -1613,9 +1384,8 @@ button::after {
width: 37rpx;
height: 37rpx;
text-align: center;
flex-shrink: 0; // /* 不被父容器挤压变形,保持固定大小 */
flex-shrink: 0;
border-radius: 50%;
// line-height: 60rpx;
}
.set-column {
... ... @@ -1624,23 +1394,20 @@ button::after {
}
.action-in-set {
// display: flex;
// flex-direction: column;
width: 100%;
display: flex;
align-items: center;
margin-bottom: 8rpx;
}
.action-in-set:last-child {
&:last-child {
margin-bottom: 0;
}
}
.action-letter {
display: inline-block;
font-size: 26rpx;
color: #666;
// width: 35rpx;
white-space: nowrap;
margin-right: 12rpx;
flex-shrink: 0;
... ... @@ -1650,11 +1417,14 @@ button::after {
font-size: 26rpx;
color: inherit;
flex: 1;
.detai-data {
white-space: nowrap;
}
}
.rest-time {
font-size: 24rpx;
// color: inherit;
margin-left: 10rpx;
flex-shrink: 0;
}
... ... @@ -1665,44 +1435,43 @@ button::after {
margin-left: 275rpx !important;
flex-shrink: 0;
}
}
.superset-title {
.superset-title {
font-size: 28rpx;
font-weight: bold;
padding: 12rpx 20rpx;
background: #fff6cc;
border-radius: 12rpx;
margin-bottom: 12rpx;
}
}
// 日程备注
/* 日程备注 */
.noteContent {
display: flex;
align-items: flex-start;
gap: 12rpx;
padding: 20rpx;
background: $color-white;
}
.noteTitle {
.noteTitle {
font-size: 26rpx;
color: #666;
flex-shrink: 0;
width: 150rpx;
}
}
.noteTags {
.noteTags {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
}
// u-tag 微调
:deep(.noteTag) {
width: auto !important;
font-size: 24rpx;
border-radius: 10rpx;
}
// ::deep(.noteTag) {
// width: auto !important;
// font-size: 24rpx;
// border-radius: 10rpx;
// }
</style>
... ...
... ... @@ -25,7 +25,7 @@
<script setup>
import dayjs from 'dayjs';
import { ref, computed, watch } from 'vue';
import { ref, computed, watch, onMounted } from 'vue';
const emit = defineEmits(['dateTypeChange', 'dateRangeChange']);
... ... @@ -102,6 +102,12 @@ watch(
},
{ immediate: true },
);
// 首次挂载时补发初始 dateTypeChange,确保父组件 handleTypeChange 被调用,
// 解决首次进入页面"周"数据不显示的问题(与手动切换行为保持一致)。
onMounted(() => {
emit('dateTypeChange', currentDateType.value);
});
</script>
<style scoped lang="scss">
... ...
... ... @@ -57,6 +57,7 @@
<script setup>
import { ref, onMounted, watch } from 'vue';
import dayjs from 'dayjs';
import QueryPlanApi from '@/sheep/api/plan/queryplan';
import WodeJihuaLibiaoTancuang from '@/pages/xunji/components/wode-jihua-libiao-tancuang.vue'
import { useTrainingStore } from '@/sheep/store/trainingStore'
... ... @@ -88,9 +89,13 @@ const props = defineProps({
isMyPlan: {
type: Boolean,
default: false
},
selectedDate: {
type: String,
default: () => dayjs().format('YYYY-MM-DD')
}
})
const emit = defineEmits(['update:visible', 'getPlanListLength'])
const emit = defineEmits(['update:visible', 'getPlanListLength', 'success'])
// 计划详情
const MyPlanDetail = ref({})
... ... @@ -135,21 +140,21 @@ const onSelectPlan = (planId) => {
}, 500)
}
// ====================== 添加到今日 ======================
// ====================== 添加到选中日期 ======================
const addTemplateToToday = async (templateId) => {
if (!templateId) {
uni.showToast({ title: '未找到训练模板', icon: 'none' });
return;
}
const todayDate = new Date().toISOString().split('T')[0];
const reqData = {
id: templateId,
trainDateList: [todayDate]
trainDateList: [props.selectedDate]
};
try {
const res = await QueryPlanApi.addPlanToCalendar(reqData);
if (res.code === 0) {
uni.showToast({ title: '添加到今日成功', icon: 'success' });
uni.showToast({ title: '添加成功', icon: 'success' });
emit('success');
emit('update:visible', false);
} else {
uni.showToast({ title: res.msg || '添加失败', icon: 'none' });
... ...
... ... @@ -4,7 +4,7 @@
<view class="top-bar">
<view class="search-wrapper">
<up-search v-model="searchKeyword" placeholder="搜索动作名称" :showAction="false" shape="round" bgColor="#f3f4f6"
placeholderColor="#9ca3af" searchIconColor="#6b7280" @clear="searchKeyword = ''" />
placeholderColor="#9ca3af" searchIconColor="#6b7280" @search="handleSearch" @clear="handleClearSearch" />
</view>
<view class="add-btn-wrapper">
... ... @@ -36,13 +36,16 @@
<view class="layout-container">
<!-- 左侧一级分类导航 -->
<scroll-view scroll-y class="left-nav" :show-scrollbar="false">
<template v-if="!isSearchMode">
<view class="nav-item" :class="{ active: activeNav === 'collect' }" @click="handleCollectClick">
<up-icon name="star-fill" size="14" :color="activeNav === 'collect' ? '#10b981' : '#9ca3af'"></up-icon>
<text class="nav-text">收藏</text>
</view>
</template>
<view v-for="nav in navItems" :key="nav.id" class="nav-item" :class="{ active: activeNav === nav.id }"
@click="switchNav(nav.id)">
<view v-for="nav in displayNavItems" :key="nav.id" class="nav-item"
:class="{ active: isSearchMode ? activeSearchCategoryId === nav.id : activeNav === nav.id }"
@click="handleNavClick(nav.id)">
<text class="nav-text">{{ nav.name }}</text>
</view>
</scroll-view>
... ... @@ -142,28 +145,30 @@ const searchKeyword = ref('');
const addShow = ref(false);
const actionDetailRef = ref(null);
// 搜索模式状态
const isSearchMode = computed(() => !!searchKeyword.value.trim());
const searchCategoryList = ref([]);
const activeSearchCategoryId = ref(null);
const searchCategoryData = ref([]);
// 左栏导航项:搜索模式显示部位列表,正常模式显示分类列表
const displayNavItems = computed(() => {
return isSearchMode.value ? searchCategoryList.value : navItems.value;
});
// 右栏动作列表:搜索模式返回选中部位的器械分组,正常模式直接返回
const displayExercises = computed(() => {
if (!searchKeyword.value.trim()) {
return exercises.value;
if (isSearchMode.value && activeSearchCategoryId.value) {
const category = searchCategoryData.value.find(item => item.categoryId === activeSearchCategoryId.value);
return category ? category.equipments : [];
}
const kw = searchKeyword.value.toLowerCase().trim();
return exercises.value
.map((group) => {
const filtered = (group.exercises || []).filter((ex) =>
ex.name && ex.name.toLowerCase().includes(kw)
);
return {
...group,
exercises: filtered,
};
})
.filter((group) => group.exercises && group.exercises.length > 0);
return exercises.value;
});
const handlePartClick = async (id) => {
try {
activeMotionPart.value = id;
searchKeyword.value = '';
const exerciseRes = await ExercisesApi.getexercises({
categoriesId: activeNav.value,
subCategoriesId: id,
... ... @@ -174,6 +179,60 @@ const handlePartClick = async (id) => {
}
};
// 服务端搜索动作
const handleSearch = async () => {
const keyword = searchKeyword.value.trim();
if (!keyword) return;
// 收藏Tab和超级组Tab不支持关键词搜索
if (activeNav.value === 'collect' || activeNav.value === 'super') return;
try {
await refreshCategories();
const exerciseRes = await ExercisesApi.getExercisesByCategory({
name: keyword,
});
const data = exerciseRes.data || [];
searchCategoryData.value = data;
// 搜索结果按部位分组,提取部位列表作为左栏导航
searchCategoryList.value = data.map(item => ({
id: item.categoryId,
name: item.categoryName,
}));
// 默认选中第一个部位
activeSearchCategoryId.value = searchCategoryList.value.length > 0 ? searchCategoryList.value[0].id : null;
} catch (e) {
console.error('搜索动作失败:', e);
}
};
// 清空搜索,重新加载当前分类数据
const handleClearSearch = async () => {
searchKeyword.value = '';
searchCategoryList.value = [];
activeSearchCategoryId.value = null;
searchCategoryData.value = [];
if (activeNav.value === 'collect') {
loadCollectList();
} else if (activeNav.value === 'super') {
// 超级组不需要重新加载
} else if (activeNav.value) {
await refreshCategories();
loadExercises(activeNav.value);
}
};
// 左栏导航点击:搜索模式按部位筛选,正常模式切换分类
const handleNavClick = (id) => {
if (isSearchMode.value) {
activeSearchCategoryId.value = id;
} else {
switchNav(id);
}
};
const handleCollectClick = () => {
activeNav.value = 'collect';
activeMotionPart.value = '';
... ... @@ -203,18 +262,22 @@ const loadSuperFavoriteList = async () => {
}
};
// 加载大类
const loadCategories = async () => {
// 仅刷新分类列表,不切换当前选中分类
const refreshCategories = async () => {
try {
await actionStore.getloadCategories();
navItems.value = actionStore.showCategories;
} catch (error) {
console.error('获取分类失败:', error);
}
};
// 加载大类(首次加载,自动切换到第一个分类)
const loadCategories = async () => {
await refreshCategories();
if (navItems.value.length > 0) {
switchNav(navItems.value[0].id);
}
} catch (error) {
console.error('获取分类失败:', error);
}
};
// 加载动作列表
... ...
... ... @@ -28,6 +28,8 @@
</view>
<!-- 日期网格 (动态 5行 或 6行) -->
<scroll-view class="scroll-view" scroll-y>
<view class="date-grid">
<view v-for="(date, index) in dates" :key="index" class="date-cell" :class="{
... ... @@ -50,7 +52,6 @@
<text class="tag-text">{{ item.text }}</text>
</view>
</template>
<!-- 溢出剩余条数 -->
<view v-if="getCellVisibleItems(date).remaining > 0" class="plan-extra">
+{{ getCellVisibleItems(date).remaining }}
... ... @@ -58,6 +59,10 @@
</view>
</view>
</view>
</scroll-view>
</view>
<!-- 单元格详情弹窗 -->
... ... @@ -74,34 +79,50 @@
<view class="explain-list">
<view class="explain-item">
<view class="color-block blue"></view>
<view class="explain-item-left">
<view class="color-block yellow">
</view>
<view class="item-info">
<text class="item-label">时长</text>
<text class="item-desc">在场馆的实际运动时长</text>
<text class="item-label">已完成</text>
<text class="item-desc">已完成或已结课的训练记录</text>
</view>
</view>
</view>
<view class="explain-item">
<view class="color-block green"></view>
<view class="explain-item-left">
<view class="color-block gray">
</view>
<view class="item-info">
<text class="item-label">容量</text>
<text class="item-desc">力量训练累计产生的总负重重量</text>
<text class="item-label">待训练</text>
<text class="item-desc">已安排的待训练计划</text>
</view>
</view>
</view>
<view class="explain-item">
<view class="color-block yellow"></view>
<view class="explain-item-left">
<view class="color-block green">
</view>
<view class="item-info">
<text class="item-label">课程</text>
<text class="item-desc">训记完成或已结课的线下课程</text>
<text class="item-label">训练容量</text>
<text class="item-desc">力量训练累计产生的总负重</text>
</view>
</view>
</view>
<view class="explain-item">
<view class="color-block gray"></view>
<view class="explain-item-left">
<view class="color-block blue">
</view>
<view class="item-info">
<text class="item-label">排课</text>
<text class="item-desc">训记排课计划或待上课的课程</text>
<text class="item-label">日程备注</text>
<text class="item-desc">相关日期的备注标记</text>
</view>
</view>
</view>
</view>
... ... @@ -111,7 +132,7 @@
</template>
<script setup>
import { ref, watch, computed, nextTick } from 'vue';
import { ref, watch, computed, nextTick, onMounted, onBeforeUnmount } from 'vue';
import dayjs from 'dayjs';
import DailyTemplateApi from '@/sheep/api/calendar/date';
import GridCellContentPopup from '@/pages/xunji/components/rili-components/grid-cell-content-popup.vue';
... ... @@ -250,7 +271,7 @@ const loadPlans = async (monthStr) => {
// 按 sourceType + sourceName 分组
const sourceMap = new Map();
plan.templates.forEach((template) => {
if (!template || !template.name) return;
if (!template) return;
const sourceKey = template.sourceType
? `src_${template.sourceType}_${template.sourceName || ''}`
: `tpl_${template.dailyTemplateId}`;
... ... @@ -323,6 +344,14 @@ const handleRefreshCalendar = () => {
loadPlans(currentMonth.value);
};
// 监听来自 xunji-moban-xiangqing 模板添加成功的刷新事件
onMounted(() => {
uni.$on('calendarDataRefresh', handleRefreshCalendar);
});
onBeforeUnmount(() => {
uni.$off('calendarDataRefresh', handleRefreshCalendar);
});
watch(
currentMonth,
(newVal) => {
... ... @@ -395,12 +424,16 @@ const selectDate = async (date) => {
}
}
.scroll-view {
height: 100%;
.date-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 10rpx;
}
// #ifdef MP-WEIXIN
padding-bottom: 100rpx;
// #endif
/* 日期格子基础样式:保证每个格子都有独立背景与微细边框 */
.date-cell {
min-width: 0;
... ... @@ -510,21 +543,27 @@ const selectDate = async (date) => {
}
}
}
}
}
}
.explain-popup-content {
padding: 36rpx 32rpx 48rpx 32rpx;
background-color: #ffffff;
padding: 40rpx 32rpx 60rpx 32rpx;
background-color: #f8f9fb;
.explain-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32rpx;
margin-bottom: 36rpx;
.explain-title {
font-size: 32rpx;
font-weight: 600;
font-size: 34rpx;
font-weight: 700;
color: #111827;
}
}
... ... @@ -532,50 +571,105 @@ const selectDate = async (date) => {
.explain-list {
display: flex;
flex-direction: column;
gap: 28rpx;
gap: 20rpx;
.explain-item {
background: #fff;
border-radius: 20rpx;
padding: 24rpx 24rpx 24rpx 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
.explain-item-left {
display: flex;
align-items: center;
gap: 20rpx;
}
.color-block {
width: 36rpx;
height: 36rpx;
border-radius: 8rpx;
margin-right: 24rpx;
width: 100rpx;
height: 92rpx;
border-radius: 14rpx;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
position: relative;
&.blue {
background-color: #bae6fd;
&.yellow {
background-color: #ffdd44;
}
&.gray {
background-color: #e8e8e8;
}
&.green {
background-color: #bbf7d0;
}
&.yellow {
background-color: #fef08a;
&.blue {
background-color: #bae6fd;
}
&.gray {
background-color: #e5e7eb;
.mini-cell-bg {
width: 58rpx;
height: 58rpx;
background: rgba(255, 255, 255, 0.7);
border-radius: 8rpx;
display: flex;
align-items: flex-end;
justify-content: center;
padding-bottom: 4rpx;
}
.mini-tag {
font-size: 14rpx;
font-weight: 600;
border-radius: 4rpx;
padding: 1rpx 6rpx;
line-height: 20rpx;
white-space: nowrap;
&.yellow-tag {
background: #fff3b0;
color: #8b6914;
}
&.gray-tag {
background: #f0f0f0;
color: #555;
}
&.green-tag {
background: #a7f3d0;
color: #14532d;
}
&.blue-tag {
background: #7dd3fc;
color: #0c4a6e;
}
}
}
.item-info {
display: flex;
align-items: center;
gap: 16rpx;
flex-direction: column;
gap: 6rpx;
flex: 1;
min-width: 0;
.item-label {
font-size: 28rpx;
font-size: 30rpx;
font-weight: 600;
color: #1f2937;
width: 72rpx;
line-height: 1.3;
}
.item-desc {
font-size: 26rpx;
color: #6b7280;
font-size: 24rpx;
color: #9ca3af;
line-height: 1.4;
}
}
}
... ...
... ... @@ -50,7 +50,7 @@
<view class="part-select-wrapper">
<up-select v-model:current="partId"
:label="partOptions.find((item) => item.id === partId)?.name || '全部'" :options="partOptions"
@select="onPartChange"></up-select>
maxHeight="400rpx" @select="onPartChange"></up-select>
</view>
</view>
... ... @@ -65,22 +65,25 @@
<!-- 统计图表(根据 showLastPeriod 切换 柱状图 / 折线图) -->
<view class="chart-container">
<template v-if="!showLastPeriod">
<LineChart v-if="chartCategories.length > 0" :categories="chartCategories" :series="chartSeries"
chartType="column" :reshow="true" :extra="{ column: { width: trendChartColumnWidth } }" />
</template>
<template v-else>
<LineChart v-if="chartCategories.length > 0" :categories="chartCategories" :series="chartSeries"
chartType="line" :reshow="true" :extra="{ column: { width: trendChartColumnWidth } }" />
</template>
:chartType="showLastPeriod ? 'line' : 'column'"
:reshow="true"
:extra="{ column: { width: trendChartColumnWidth } }"
:chartId="'trend-chart'"
/>
</view>
<!-- 显示上周/上月数据勾选框 -->
<view class="toggle-checkbox" @click="toggleLastWeek">
<checkbox :checked="showLastPeriod" color="#10b981" style="transform: scale(0.8);" />
<view class="toggle-checkbox">
<up-checkbox-group @change="toggleLastWeek">
<up-checkbox name="showLastPeriod" :checked="showLastPeriod" activeColor="#10b981" size="20" shape="square">
<template #label>
<text class="toggle-label">
{{ MainCurrentDateType === 'week' ? '显示上周数据' : '显示上月数据' }}
</text>
</template>
</up-checkbox>
</up-checkbox-group>
</view>
<!-- 3. 数据对比区域 -->
... ... @@ -138,19 +141,19 @@
<!-- 容量/组数 切换标签 -->
<view class="muscle-tabs">
<view class="tab-item" :class="{ active: muscleTabIndex === 0 }" @click="muscleTabIndex = 0">
<view class="tab-item" :class="{ active: partTabIndex === 0 }" @click="partTabIndex = 0">
容量
</view>
<view class="tab-item" :class="{ active: muscleTabIndex === 1 }" @click="muscleTabIndex = 1">
<view class="tab-item" :class="{ active: partTabIndex === 1 }" @click="partTabIndex = 1">
组数
</view>
</view>
<view class="tip">{{ TREND_TIPS[muscleTabIndex] }}</view>
<view class="tip">{{ TREND_TIPS[partTabIndex] }}</view>
<!-- 图表区域 -->
<view class="statistical">
<LineChart v-if="muscleShow && muscleChartCategories.length"
:key="`muscle-chart-${currentMuscleId}-${MainCurrentDateType}-${muscleTabIndex}`"
:key="`muscle-chart-${currentMuscleId}-${MainCurrentDateType}-${partTabIndex}`"
:categories="muscleChartCategories" :series="muscleChartSeries" chartType="column" :reshow="true"
:extra="{ column: { width: muscleChartColumnWidth } }" />
</view>
... ... @@ -266,7 +269,6 @@ const trainingData = ref({ setCountMap: {}, totalVolumeMap: {} });
// --- 肌肉弹窗 ---
const muscleShow = ref(false);
const muscleTabIndex = ref(0);
const currentMuscleId = ref(null);
const currentMuscleName = ref('');
const muscleDetailData = ref({ muscleId: 0, dailyStatsList: [] });
... ... @@ -345,11 +347,10 @@ const chartSeries = computed(() => {
const dataKey = TREND_DATA_KEYS[NumberTabIndex.value] || 'volume';
/** 单位转换:秒→分钟,米→公里,保留一位小数 */
/** 单位转换:秒→分钟,保留一位小数;距离后端已返回 km,无需转换 */
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;
};
... ... @@ -380,15 +381,13 @@ const listData = computed(() => {
if (!weeklyData.value) return [];
const d = weeklyData.value;
/** 单位转换:秒→分钟,米→公里,保留一位小数 */
/** 单位转换:秒→分钟,保留一位小数;距离后端已返回 km,无需转换 */
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: toMin(d.lastWeekTotalDuration), curr: toMin(d.totalDuration) },
{ label: '距离', last: toKm(d.lastWeekTotalDistance), curr: toKm(d.totalDistance) },
{ label: '距离', last: d.lastWeekTotalDistance, curr: d.totalDistance },
{ label: '次数', last: d.lastWeekTotalCount, curr: d.totalCount },
];
... ... @@ -420,10 +419,10 @@ const convertUnit = (val, key) => {
/** 肌肉弹窗图表 X 轴 */
const muscleChartCategories = computed(() => {
if (!muscleFullDates.value.length) return [];
if (MainCurrentDateType.value === 'week') {
return WEEKDAY_LABELS;
}
if (!muscleFullDates.value.length) return [];
return muscleFullDates.value.map((d) => dayjs(d).format('M/D'));
});
... ... @@ -432,9 +431,9 @@ const muscleSetsArr = computed(() => muscleProcessedData.value.setsArr || []);
/** 肌肉弹窗图表 Series */
const muscleChartSeries = computed(() => {
const data = muscleTabIndex.value === 0 ? muscleVolumeArr.value : muscleSetsArr.value;
const name = muscleTabIndex.value === 0 ? '容量' : '组数';
const color = muscleTabIndex.value === 0 ? '#ffc53d' : '#5b8ff9';
const data = partTabIndex.value === 0 ? muscleVolumeArr.value : muscleSetsArr.value;
const name = partTabIndex.value === 0 ? '容量' : '组数';
const color = partTabIndex.value === 0 ? '#ffc53d' : '#5b8ff9';
return [{ name, data: data || [], color }];
});
... ... @@ -566,20 +565,14 @@ const onPartChange = (item) => {
loadCategoryData(startDate.value);
};
/** 时间类型切换(周/月) */
const handleTypeChange = async (type) => {
/** 时间类型切换(周/月)
* 只负责更新类型状态,不加载数据。
* switchDateType 会同步触发 watch → emit dateRangeChange → handleDateChange,
* handleDateChange 拿到正确的日期后再统一加载数据,避免用旧日期请求接口。
*/
const handleTypeChange = (type) => {
MainCurrentDateType.value = type;
showLastPeriod.value = false;
loadTrainingData(startDate.value, endDate.value);
if (startDate.value) {
await loadCategoryData(startDate.value);
}
// 如果肌肉弹窗打开,同步刷新
if (muscleShow.value && currentMuscleId.value) {
await loadMuscleData(currentMuscleId.value, startDate.value);
}
};
/** 日期范围变更 */
... ... @@ -587,7 +580,7 @@ const handleDateChange = async (data) => {
startDate.value = data.startDate;
endDate.value = data.endDate;
await loadTrainingData(startDate.value, endDate.value);
loadCategoryData(startDate.value);
await loadCategoryData(startDate.value);
if (muscleShow.value && currentMuscleId.value) {
await loadMuscleData(currentMuscleId.value, startDate.value);
... ... @@ -598,7 +591,8 @@ const handleDateChange = async (data) => {
const openMuscleDetail = async (muscleId) => {
currentMuscleId.value = muscleId;
muscleShow.value = true;
muscleTabIndex.value = 0;
muscleFullDates.value = [];
muscleProcessedData.value = { volumeArr: [], setsArr: [] };
const muscle = subCategorieList.value.find((item) => item.id === muscleId);
currentMuscleName.value = muscle?.name || '';
... ... @@ -619,8 +613,8 @@ const closeMuscleDetail = () => {
};
/** 切换显示上一周期数据 */
const toggleLastWeek = () => {
showLastPeriod.value = !showLastPeriod.value;
const toggleLastWeek = (vals) => {
showLastPeriod.value = vals.includes('showLastPeriod');
if (startDate.value) {
loadCategoryData(startDate.value);
}
... ... @@ -631,6 +625,7 @@ const toggleLastWeek = () => {
onMounted(async () => {
await loadbodyPartOptions();
await loadGetSubCategorieList();
if (startDate.value && endDate.value) {
await loadTrainingData(startDate.value, endDate.value);
await loadCategoryData(startDate.value);
... ... @@ -865,8 +860,13 @@ $radius-item: 12rpx;
border-radius: 4rpx;
margin-right: 8rpx;
&.blue { background-color: $color-blue; }
&.yellow { background-color: $color-yellow; }
&.blue {
background-color: $color-blue;
}
&.yellow {
background-color: $color-yellow;
}
}
}
}
... ... @@ -886,8 +886,13 @@ $radius-item: 12rpx;
border-bottom: 1rpx solid #f0f0f0;
font-size: 26rpx;
&:last-child { border-bottom: none; }
&:nth-child(odd) { background-color: #f9fafc; }
&:last-child {
border-bottom: none;
}
&:nth-child(odd) {
background-color: #f9fafc;
}
.label {
flex: 1.5;
... ... @@ -903,8 +908,13 @@ $radius-item: 12rpx;
&.diff {
font-weight: 600;
&.positive { color: $color-green; }
&.negative { color: $color-red; }
&.positive {
color: $color-green;
}
&.negative {
color: $color-red;
}
}
}
}
... ...
... ... @@ -311,6 +311,9 @@ onMounted(() => {
width: 180rpx;
background-color: #ffffff;
border-radius: 16rpx;
min-height: 70vh;
height: 88%;
// #ifdef MP-WEIXIN
height: 86%;
... ... @@ -348,6 +351,7 @@ onMounted(() => {
flex: 1;
padding: 0 20rpx;
box-sizing: border-box;
min-height: 70vh;
height: 88%;
// #ifdef MP-WEIXIN
height: 86%;
... ...
... ... @@ -36,7 +36,7 @@
@click="navigateTo('/pages4/pages/xunji/xunji-wode-moban')" />
<up-cell title="我的计划" icon="order" :isLink="true"
@click="navigateTo('/pages4/pages/xunji/xunji-wode-jihua')" />
<up-cell title="训记使用教程" icon="question-circle" :isLink="true" @click="goTutorial" />
<up-cell title="训记使用教程" icon="question-circle" :isLink="true" @click="navigateTo('/pages4/pages/xunji/shiyon-jiaochen')" />
</up-cell-group>
</view>
</up-popup>
... ... @@ -61,7 +61,7 @@ import TrainingFloating from '@/pages/TrainingFloating.vue';
const userStore = useUserStore();
const drawerShow = ref(false);
const currentTab = ref(3);
const currentTab = ref(1);
const menuButtonHeight = ref(getMenuButtonHeight());
const topSafeArea = ref(getTopSafeArea());
... ... @@ -73,10 +73,7 @@ PageHeight.value = menuButtonHeight.value + topSafeArea.value;
const userInfo = computed(() => userStore.userInfo || {});
onLoad((options) => {
if (!userStore.isLogin) {
uni.redirectTo({ url: '/pages7/pages/index/login' });
return;
}
userStore.getInfo();
if (options && options.currentTab) {
currentTab.value = Number(options.currentTab);
... ... @@ -103,7 +100,7 @@ const handleTabClick = (index) => {
const goTutorial = () => { };
defineExpose({ switchTabIndex: handleTabClick });
</script>
... ...
... ... @@ -87,7 +87,7 @@ const sceneList = ref([
])
// 缺省图
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"
// ==================================
// 获取部位分类
... ...
<template>
<view class="tutorial-container">
<!-- 顶部导航栏 -->
<u-navbar title="使用教程" :autoBack="true" bgColor="#ffffff"
titleStyle="color: #303133; font-weight: 600; font-size: 32rpx;" leftIconColor="#303133" :placeholder="true"
fixed />
<view class="tutorial-body">
<!-- 1. 顶部应用概览 -->
<view class="overview-card">
<view class="overview-icon-wrapper">
<u-icon name="file-text-fill" color="#ffffff" size="28"></u-icon>
</view>
<view class="overview-content">
<text class="overview-title">什么是训记?</text>
<text class="overview-desc">
训记是一款专业的健身训练记录工具,帮助你科学规划训练计划、精准记录每次训练数据,让健身更高效。
</text>
</view>
</view>
<!-- 2. 核心功能 -->
<view class="section-container">
<view class="section-header">
<view class="header-line"></view>
<text class="header-title">核心功能</text>
<view class="header-line"></view>
</view>
<view class="features-grid">
<view class="feature-card" v-for="(item, index) in features" :key="index">
<view class="feature-icon" :style="{ backgroundColor: item.bgColor }">
<u-icon :name="item.icon" :color="item.iconColor" size="22"></u-icon>
</view>
<text class="feature-name">{{ item.name }}</text>
<text class="feature-desc">{{ item.desc }}</text>
</view>
</view>
</view>
<!-- 3. 使用指南(步骤流程) -->
<view class="section-container">
<view class="section-header">
<view class="header-line"></view>
<text class="header-title">使用指南</text>
<view class="header-line"></view>
</view>
<view class="steps-wrapper">
<view class="step-card" v-for="(step, sIndex) in steps" :key="sIndex">
<view class="step-header">
<view class="step-badge">{{ sIndex + 1 }}</view>
<view class="step-titles">
<text class="step-main-title">{{ step.title }}</text>
<text class="step-sub-title">{{ step.subtitle }}</text>
</view>
</view>
<view class="step-details">
<view class="detail-item" v-for="(detail, dIndex) in step.details" :key="dIndex">
<view class="dot"></view>
<text class="detail-text">{{ detail }}</text>
</view>
</view>
<!-- 提示块:使用 uview-plus 标准图标 info-circle-fill -->
<view class="step-tip" v-if="step.tip">
<u-icon name="info-circle-fill" color="#e6a23c" size="16"></u-icon>
<text class="tip-text">{{ step.tip }}</text>
</view>
</view>
</view>
</view>
<!-- 4. 常见问题 (手风琴交互) -->
<view class="section-container">
<view class="section-header">
<view class="header-line"></view>
<text class="header-title">常见问题</text>
<view class="header-line"></view>
</view>
<view class="faq-wrapper">
<u-collapse accordion :border="false">
<u-collapse-item v-for="(faq, fIndex) in faqs" :key="fIndex" :name="fIndex">
<template #title>
<view class="faq-title-box">
<view class="faq-tag q-tag">Q</view>
<text class="faq-question-text">{{ faq.question }}</text>
</view>
</template>
<view class="faq-answer-box">
<view class="faq-tag a-tag">A</view>
<text class="faq-answer-text">{{ faq.answer }}</text>
</view>
</u-collapse-item>
</u-collapse>
</view>
</view>
<!-- 5. 底部标语 -->
<view class="footer-section">
<text class="footer-text">训练贵在坚持,训记陪你一同成长</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
// 1. 核心功能数据 (均为 uview-plus 官方内置标准图标名)
const features = ref([
{
icon: 'order',
iconColor: '#ff9900',
name: '训练计划',
desc: '创建和管理个性化训练计划',
bgColor: '#fff7cc'
},
{
icon: 'grid',
iconColor: '#19be6b',
name: '动作库',
desc: '丰富的训练动作库与详情',
bgColor: '#dbf5e6'
},
{
icon: 'calendar',
iconColor: '#2979ff',
name: '训练日历',
desc: '日历视图记录每日训练',
bgColor: '#ecf5ff'
},
{
icon: 'list-dot',
iconColor: '#fa3534',
name: '数据分析',
desc: '多维度训练数据统计分析',
bgColor: '#fef0f0'
}
])
// 2. 使用步骤数据
const steps = ref([
{
title: '创建训练计划',
subtitle: '打造你的专属训练方案',
details: [
'进入「我的」页面,点击「训练计划」进入计划管理',
'选择「新建计划」,可从官方模板快速创建,也可自定义空白计划',
'设置训练周期、训练日安排,拖拽调整动作顺序',
'保存后即可在训练日历中按计划执行'
],
tip: '建议新手从官方模板开始,逐步了解后再自定义计划'
},
{
title: '添加训练动作',
subtitle: '每个动作都值得被记录',
details: [
'在训练计划中点击「添加动作」,进入动作库',
'按部位分类浏览动作,或直接搜索动作名称',
'点击动作可查看详细说明、肌肉示意图和训练要点',
'选择动作后设置组数、次数、重量等训练参数'
],
tip: '支持创建超级组,将两个动作组合交替训练,提升效率'
},
{
title: '使用训练日历',
subtitle: '一目了然的训练记录',
details: [
'进入「训练」页面查看训练日历,日历标记已训练日期',
'点击日期可添加当日训练内容,从计划模板一键导入',
'已完成的训练会用不同颜色标识,方便回顾',
'长按训练记录可编辑或删除'
],
tip: '坚持每天打卡,日历会越来越丰富哦'
},
{
title: '记录训练数据',
subtitle: '精准追踪每次进步',
details: [
'训练过程中逐组录入重量、次数、休息时间等数据',
'内置计时器功能,方便控制组间休息时长',
'支持查看每个动作的历史训练数据对比',
'训练完成后自动汇总本次训练总量'
],
tip: '详细记录每次数据,才能看到自己的进步轨迹'
},
{
title: '查看训练分析',
subtitle: '数据驱动,科学训练',
details: [
'在「数据」页面查看训练频率、训练量趋势图表',
'统计各部位训练次数,发现训练偏重或遗漏',
'查看个人纪录(PR),见证力量增长',
'训练日历热力图直观展示训练频率'
],
tip: '定期回顾训练数据,及时调整训练计划方向'
}
])
// 3. 常见问题数据
const faqs = ref([
{
question: '训记是免费的吗?',
answer: '训记基础功能完全免费,包括训练计划创建、动作记录、日历打卡等核心功能均可畅快使用。'
},
{
question: '训练数据会丢失吗?',
answer: '训练数据会自动保存在云端,只要登录同一账号,更换设备数据也不会丢失。'
},
{
question: '如何从模板快速创建计划?',
answer: '在「我的模板」或「模板中心」选择适合自己的训练模板,点击「使用模板」即可一键生成训练计划。'
},
{
question: '一个计划可以包含多少动作?',
answer: '训练计划对动作数量没有硬性限制,你可以根据实际训练需求自由添加和排序。'
},
{
question: '支持自定义动作吗?',
answer: '支持!你可以创建个人专属动作,自定义动作名称、目标部位和训练参数。'
}
])
</script>
<style lang="scss" scoped>
.tutorial-container {
min-height: 100vh;
background-color: #f6f7f9;
padding-bottom: calc(40rpx + constant(safe-area-inset-bottom));
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
}
.tutorial-body {
padding: 24rpx 30rpx;
}
/* 顶部概览卡片 */
.overview-card {
display: flex;
align-items: center;
background: linear-gradient(135deg, #2979ff 0%, #609cff 100%);
border-radius: 20rpx;
padding: 32rpx;
margin-bottom: 32rpx;
box-shadow: 0 8rpx 24rpx rgba(41, 121, 255, 0.15);
.overview-icon-wrapper {
width: 90rpx;
height: 90rpx;
background-color: rgba(255, 255, 255, 0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
flex-shrink: 0;
}
.overview-content {
flex: 1;
.overview-title {
font-size: 32rpx;
font-weight: 700;
color: #ffffff;
margin-bottom: 8rpx;
display: block;
}
.overview-desc {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.9);
line-height: 36rpx;
display: block;
}
}
}
/* 通用分区标题 */
.section-container {
margin-bottom: 32rpx;
}
.section-header {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24rpx;
.header-line {
width: 40rpx;
height: 2rpx;
background-color: #dcdfe6;
}
.header-title {
font-size: 28rpx;
font-weight: 600;
color: #909399;
margin: 0 20rpx;
}
}
/* 核心功能网格 */
.features-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
.feature-card {
background-color: #ffffff;
border-radius: 16rpx;
padding: 28rpx 20rpx;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.03);
.feature-icon {
width: 80rpx;
height: 80rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16rpx;
}
.feature-name {
font-size: 28rpx;
font-weight: 600;
color: #303133;
margin-bottom: 8rpx;
}
.feature-desc {
font-size: 22rpx;
color: #909399;
text-align: center;
line-height: 32rpx;
}
}
}
/* 步骤流程 */
.steps-wrapper {
.step-card {
background-color: #ffffff;
border-radius: 16rpx;
padding: 28rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.03);
.step-header {
display: flex;
align-items: center;
margin-bottom: 20rpx;
.step-badge {
width: 44rpx;
height: 44rpx;
background: #2979ff;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 26rpx;
font-weight: 700;
color: #ffffff;
margin-right: 16rpx;
flex-shrink: 0;
}
.step-titles {
display: flex;
flex-direction: column;
.step-main-title {
font-size: 30rpx;
font-weight: 600;
color: #303133;
}
.step-sub-title {
font-size: 22rpx;
color: #909399;
margin-top: 2rpx;
}
}
}
.step-details {
padding-left: 60rpx;
.detail-item {
display: flex;
align-items: flex-start;
margin-bottom: 12rpx;
.dot {
width: 10rpx;
height: 10rpx;
background-color: #2979ff;
border-radius: 50%;
margin-top: 12rpx;
margin-right: 12rpx;
flex-shrink: 0;
}
.detail-text {
font-size: 25rpx;
color: #606266;
line-height: 36rpx;
flex: 1;
}
}
}
.step-tip {
display: flex;
align-items: center;
background-color: #fdf6ec;
border-radius: 8rpx;
padding: 12rpx 16rpx;
margin-top: 16rpx;
margin-left: 60rpx;
.tip-text {
font-size: 22rpx;
color: #e6a23c;
margin-left: 10rpx;
line-height: 32rpx;
flex: 1;
}
}
}
}
/* FAQ 折叠面板适配 */
.faq-wrapper {
background-color: #ffffff;
border-radius: 16rpx;
padding: 10rpx 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.03);
.faq-title-box {
display: flex;
align-items: center;
.faq-question-text {
font-size: 26rpx;
font-weight: 600;
color: #303133;
}
}
.faq-answer-box {
display: flex;
align-items: flex-start;
padding: 12rpx 0;
.faq-answer-text {
font-size: 24rpx;
color: #606266;
line-height: 36rpx;
flex: 1;
}
}
.faq-tag {
width: 36rpx;
height: 36rpx;
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 20rpx;
font-weight: 700;
margin-right: 16rpx;
flex-shrink: 0;
&.q-tag {
background-color: #2979ff;
color: #ffffff;
}
&.a-tag {
background-color: #e1f3d8;
color: #67c23a;
}
}
}
/* 底部提示 */
.footer-section {
padding: 30rpx 0 10rpx;
text-align: center;
.footer-text {
font-size: 22rpx;
color: #c0c4cc;
}
}
</style>
\ No newline at end of file
... ...
<template>
<view class="paike-wrap">
<!-- 顶部导航 -->
<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 class="paike-container">
<!-- 1. 自定义顶部导航栏 (uview-plus) -->
<u-navbar
title="排课日历"
:auto-back="true"
placeholder
bg-color="#121212"
title-style="color: #FFFFFF; font-size: 34rpx; font-weight: 600;"
left-icon-color="#FFFFFF"
/>
<!-- 2. 星期吸顶头部 -->
<view class="week-header-sticky">
<view class="week-grid">
<view v-for="item in WEEK_TEXTS" :key="item" class="week-cell">
{{ item }}
</view>
<view class="nav-title">计划排课</view>
<view class="nav-right"></view>
</view>
</view>
<!-- 星期头部:一二三四五六日 -->
<view class="week-head">
<view v-for="item in weekText" :key="item" class="week-cell">{{ item }}</view>
<!-- 3. 加载中状态 -->
<view v-if="loading" class="state-box">
<u-loading-icon
mode="semicircle"
color="#E9EE50"
text="正在加载排课数据..."
text-color="#999999"
vertical
/>
</view>
<!-- 4. 日历主体列表 -->
<view v-else-if="monthList.length > 0" class="calendar-content">
<view
v-for="monthItem in monthList"
:key="`${monthItem.year}-${monthItem.month}`"
class="month-card"
>
<!-- 月份标题 -->
<view class="month-header">
<text class="month-title">{{ monthItem.year }}年 {{ monthItem.month }}月</text>
</view>
<!-- 日历主体容器 -->
<view class="calendar-box">
<!-- 月份标题行 -->
<view v-for="monthItem in monthList" :key="monthItem.month" class="month-wrap">
<view class="month-title">{{ monthItem.month }}月</view>
<!-- 当月日期网格 Grid7列 -->
<!-- 日期 7 列网格 -->
<view class="date-grid">
<!-- 前置空白占位格子(当月开头非周一填充空白) -->
<view v-for="empty in monthItem.emptyCount" :key="'empty' + empty" class="date-cell empty-cell"></view>
<!-- 有效日期格子 -->
<view v-for="day in monthItem.dayList" :key="day.day" class="date-cell"
@click="openTemplateDatil(day.DailyTemplateId, day.year, day.month, day.day)"
:class="{ hasClass: day.trainName }">
<!-- 日期数字 -->
<text class="day-num">{{ day.day }}</text>
<!-- 黄色排课标签 -->
<view class="train-label-box">
<view v-if="day.trainName" class="train-label">{{ day.trainName }}</view>
<view v-else class="empty-label"></view>
<!-- 开头空白填充格 -->
<view
v-for="emptyIdx in monthItem.emptyCount"
:key="`empty-${emptyIdx}`"
class="date-cell cell-empty"
/>
<!-- 有效日期格 -->
<view
v-for="day in monthItem.dayList"
:key="`${day.year}-${day.month}-${day.day}`"
class="date-cell"
:class="{ 'has-schedule': day.trainName }"
@tap="handleDateClick(day)"
>
<!-- 日期数 -->
<text class="day-number">{{ day.day }}</text>
<!-- 排课标签/未排课点位 -->
<view class="schedule-box">
<view v-if="day.trainName" class="schedule-label">
{{ day.trainName }}
</view>
<view v-else class="schedule-placeholder">
<view class="dot" />
</view>
</view>
</view>
</view>
</view>
<!-- 底部提示 -->
<view class="bottom-tip">
<text>最多仅可设置到未来 {{ MONTHS_AHEAD }} 个月哦~</text>
</view>
</view>
<!-- 5. 空数据状态 -->
<view v-else class="state-box">
<u-empty mode="data" text="暂无排课计划数据" icon-size="160rpx" />
</view>
<!-- 底部提示文案(适配底部安全区) -->
<view class="bottom-tip">最多仅可设置到未来2个月哦~</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import QueryPlanApi from '@/sheep/api/plan/queryplan';
import { onLoad } from '@dcloudio/uni-app';
// 顶部星期文案
const weekText = ['一', '二', '三', '四', '五', '六', '日']
import { ref, onMounted, onUnmounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import QueryPlanApi from '@/sheep/api/plan/queryplan'
// 排课接口信息
const resData = ref({})
// ==================== 常量定义 ====================
const WEEK_TEXTS = ['一', '二', '三', '四', '五', '六', '日']
const MONTHS_AHEAD = 2 // 最多展示未来月份数(当前月 + 2个月)
// 最终渲染日历数据
// ==================== 响应式状态 ====================
const planId = ref(0)
const loading = ref(true)
const monthList = ref([])
// ==================== 生命周期 ====================
onLoad((options) => {
planId.value = Number(options?.planid) || 0
})
const planId = ref(0)
onMounted(() => {
fetchPlanCalendar()
uni.$on('calendarDataRefresh', fetchPlanCalendar)
})
onLoad((options) => {
// 接收从详情页传过来的 planid
planId.value = Number(options.planid) || 0
console.log('收到计划ID:', planId.value, typeof planId.value)
onUnmounted(() => {
uni.$off('calendarDataRefresh', fetchPlanCalendar)
})
// 格式化日期、后端数据转日历
const formatCalendar = () => {
// ==================== 工具函数(数据解耦) ====================
console.log('进入格式化,resData全部数据:', resData.value)
// 没有data直接退出
if (!resData.value?.data || !Array.isArray(resData.value.data)) {
console.log('data不存在,终止格式化')
monthList.value = []
return
/**
* 根据基础日期与月份偏移量,计算目标的年、月
*/
const getTargetYearMonth = (baseDate, offsetMonth) => {
const d = new Date(baseDate.getFullYear(), baseDate.getMonth() + offsetMonth, 1)
return {
year: d.getFullYear(),
month: d.getMonth() + 1,
}
const now = new Date()
const currentYear = now.getFullYear()
const currentMonth = now.getMonth() + 1
const today = now.getDate()
const calcMonthArr = [currentMonth, currentMonth + 1, currentMonth + 2]
}
const groupObj = {}
/**
* 计算某月某日是周几,并转换为“以周一为每周第一天”的前置空白格数量
*/
const calcEmptySlots = (year, month, day = 1) => {
const dayOfWeek = new Date(year, month - 1, day).getDay()
const isoWeekDay = dayOfWeek === 0 ? 7 : dayOfWeek // 周日转为7
return isoWeekDay - 1
}
resData.value.data.forEach(item => {
// trainingDate 是 [年, 月, 日] 数组,直接解构
// const [y, m, d] = item.trainingDate
// const name = item.templates?.[0]?.templateName || ''
/**
* 将后端排课接口数组构建成映射 Map,Key: `${year}-${month}`, Value: { [day]: { trainName, DailyTemplateId } }
*/
const buildTrainMap = (rawList = []) => {
const map = {}
if (!Array.isArray(rawList)) return map
// const key = `${y}-${m}`
// if (!groupObj[key]) groupObj[key] = {}
// groupObj[key][d] = name
rawList.forEach((item) => {
if (!item?.trainingDate || !Array.isArray(item.trainingDate)) return
const [y, m, d] = item.trainingDate
const template = item.templates?.[0]
const name = template?.templateName || ''
const DailyTemplateId = template?.id || null
const key = `${y}-${m}`
if (!groupObj[key]) groupObj[key] = {}
groupObj[key][d] = {
trainName: name,
DailyTemplateId: DailyTemplateId
if (!map[key]) map[key] = {}
map[key][d] = {
trainName: template?.templateName || '',
DailyTemplateId: template?.id || null,
}
})
return map
}
/**
* 生成多月份日历完整渲染结构
*/
const generateCalendarList = (apiData) => {
const now = new Date()
const today = now.getDate()
const trainMap = buildTrainMap(apiData)
const result = []
calcMonthArr.forEach((month, idx) => {
const year = currentYear + Math.floor((month - 1) / 12)
const realMonth = month > 12 ? month - 12 : month
const totalDay = new Date(year, realMonth, 0).getDate()
let firstWeek = new Date(`${year}-${realMonth}-01`).getDay()
firstWeek = firstWeek === 0 ? 7 : firstWeek
let emptyCount = firstWeek - 1
const dayList = []
const startDay = idx === 0 ? today : 1
for (let d = startDay; d <= totalDay; d++) {
// const key = `${year}-${realMonth}`
// const trainName = groupObj[key]?.[d] || ''
// dayList.push({ day: d, trainName })
const key = `${year}-${realMonth}`
const trainInfo = groupObj[key]?.[d] || {}
for (let i = 0; i <= MONTHS_AHEAD; i++) {
const { year, month } = getTargetYearMonth(now, i)
const isCurrentMonth = i === 0
const startDay = isCurrentMonth ? today : 1
const totalDaysInMonth = new Date(year, month, 0).getDate()
const mapKey = `${year}-${month}`
const dayList = []
for (let d = startDay; d <= totalDaysInMonth; d++) {
const scheduleInfo = trainMap[mapKey]?.[d] || {}
dayList.push({
year: year, // 年
month: realMonth, // 月
year,
month,
day: d,
trainName: trainInfo.trainName || '',
// templateId: trainInfo.templateId || null
DailyTemplateId: trainInfo.DailyTemplateId || null
trainName: scheduleInfo.trainName || '',
DailyTemplateId: scheduleInfo.DailyTemplateId || null,
})
}
if (idx === 0) {
const firstDayWeek = new Date(`${year}-${realMonth}-${startDay}`).getDay()
const currentWeek = firstDayWeek === 0 ? 7 : firstDayWeek
emptyCount = currentWeek - 1
}
result.push({
month: realMonth,
emptyCount,
dayList
})
year,
month,
emptyCount: calcEmptySlots(year, month, startDay),
dayList,
})
}
monthList.value = result
return result
}
// 返回上一页
const goBack = () => uni.navigateBack()
// 胶囊导航信息
const menuButtonInfo = ref({
top: 44,
height: 32
});
// ==================== 接口交互 & 事件处理 ====================
/** 加载排课数据 */
const fetchPlanCalendar = async () => {
if (!planId.value) {
loading.value = false
return
}
// 获取排课日历数据
const loadPlanCalendar = async () => {
if (!planId.value) return
loading.value = true
try {
const apiRes = await QueryPlanApi.getPlanArrangeCalendar(planId.value)
console.log('接口返回的计划日历数据:', apiRes.value);
resData.value = apiRes
// 渲染日历
formatCalendar()
const res = await QueryPlanApi.getPlanArrangeCalendar(planId.value)
// 根据后端统一响应结构兜底处理
const data = res?.data || res
monthList.value = generateCalendarList(Array.isArray(data) ? data : [])
} catch (err) {
console.error('日历接口报错', err)
console.error('[PlanCalendar] 获取排课日历失败:', err)
uni.showToast({ title: '加载排课失败', icon: 'none' })
} finally {
loading.value = false
}
}
//查看模板详情
const openTemplateDatil = (DailyTemplateId, year, month, day) => {
console.log('每日模板ID=', DailyTemplateId);
console.log('完整日期=', year, month, day);
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
/** 日期卡片点击事件 */
const handleDateClick = (dayItem) => {
const { DailyTemplateId, year, month, day } = dayItem
if (!DailyTemplateId) return
uni.navigateTo({
url: `/pages4/pages/xunji/xunji-moban-xiangqing?id=${DailyTemplateId}&date=${dateStr}&isOffice=false&isMyPlan=true&isDailytemplateId=true`,
});
};
onMounted(async () => {
// #ifdef MP-WEIXIN
try {
const rect = uni.getMenuButtonBoundingClientRect();
if (rect) {
menuButtonInfo.value = {
top: rect.top,
height: rect.height
};
}
} catch (e) { }
// #endif
// #ifndef MP-WEIXIN
try {
const systemInfo = uni.getSystemInfoSync();
const statusBarHeight = systemInfo.statusBarHeight || 20;
menuButtonInfo.value = {
top: statusBarHeight,
height: 44
};
} catch (e) { }
// #endif
// 加载排课信息
loadPlanCalendar()
// formatCalendar()
});
const formattedMonth = String(month).padStart(2, '0')
const formattedDay = String(day).padStart(2, '0')
const dateStr = `${year}-${formattedMonth}-${formattedDay}`
uni.navigateTo({
url: `/pages4/pages/xunji/xunji-moban-xiangqing?id=${DailyTemplateId}&date=${dateStr}&isOffice=false&isMyPlan=true&isDailytemplateId=true`,
})
}
</script>
<style scoped lang="scss">
$bg-color: #121212;
$text-white: #fff;
$text-gray: #2f2f2f;
$train-yellow: #e9ee50;
$cell-h: 110rpx;
.paike-wrap {
background: $bg-color;
min-height: 100vh;
padding-bottom: env(safe-area-inset-bottom);
overflow: hidden;
}
// ==================== 主题色系与变量 ====================
$bg-main: #121212;
$bg-card: #1e1e1e;
$bg-cell: #262626;
.page-header {
width: 100%;
flex-shrink: 0;
background-color: $bg-color;
}
$text-primary: #ffffff;
$text-secondary: #8c8c8c;
.nav-bar {
display: flex;
align-items: center;
justify-content: space-between;
background-color: $bg-color;
padding-left: 16rpx;
padding-right: 16rpx;
box-sizing: content-box;
}
$theme-yellow: #e9ee50;
$theme-yellow-light: rgba(233, 238, 80, 0.15);
$theme-yellow-border: rgba(233, 238, 80, 0.35);
.nav-left {
width: 60rpx;
display: flex;
align-items: center;
flex-shrink: 0;
}
$cell-height: 116rpx;
.nav-title {
flex: 1;
text-align: center;
font-size: 36rpx;
font-weight: bold;
color: $text-white;
// ==================== 容器根样式 ====================
.paike-container {
min-height: 100vh;
background-color: $bg-main;
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.nav-right {
width: 60rpx;
flex-shrink: 0;
}
// ==================== 星期头吸顶 ====================
.week-header-sticky {
position: sticky;
/* #ifdef H5 */
top: 44px;
/* #endif */
/* #ifndef H5 */
top: 0;
/* #endif */
z-index: 10;
background-color: $bg-main;
padding: 20rpx 16rpx 12rpx;
border-bottom: 1rpx solid rgba(255, 255, 255, 0.06);
.week-head {
margin-top: 10rpx;
.week-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
margin: 20rpx 0;
.week-cell {
text-align: center;
font-size: 25rpx;
color: $text-white;
font-size: 24rpx;
font-weight: 500;
color: $text-secondary;
}
}
}
.month-wrap {
margin-bottom: 30rpx;
background-color: #121212;
// ==================== 状态容器 (Loading/Empty) ====================
.state-box {
display: flex;
align-items: center;
justify-content: center;
min-height: 500rpx;
}
// ==================== 日历主体 ====================
.calendar-content {
padding: 20rpx 16rpx 0;
}
.month-card {
margin-bottom: 32rpx;
background: $bg-card;
border-radius: 24rpx;
padding: 20rpx 12rpx 16rpx;
border: 1rpx solid rgba(255, 255, 255, 0.05);
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.3);
.month-title {
font-size: 25rpx;
color: $text-white;
padding: 15rpx 10rpx;
.month-header {
text-align: center;
padding-bottom: 20rpx;
.month-title {
font-size: 30rpx;
font-weight: 600;
color: $text-primary;
letter-spacing: 1rpx;
}
}
.date-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1rpx;
gap: 8rpx;
}
.date-cell {
height: $cell-h;
background: $bg-color;
border-radius: 8rpx;
padding: 8rpx;
height: $cell-height;
background-color: $bg-cell;
border-radius: 12rpx;
padding: 8rpx 6rpx;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
transition: all 0.2s ease;
.day-num {
font-size: 25rpx;
color: $text-white;
text-align: center;
display: block;
width: 100%;
/* 点击效果 */
&:active {
transform: scale(0.95);
opacity: 0.8;
}
.train-label-box {
min-height: 56rpx;
width: 100%;
/* 空白填补格 */
&.cell-empty {
background-color: transparent !important;
pointer-events: none;
}
.train-label {
background: $train-yellow;
color: #000;
font-size: 22rpx;
padding: 4rpx 3rpx;
border-radius: 2rpx;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
/* 有排课状态 */
&.has-schedule {
background-color: $theme-yellow-light;
border: 1rpx solid $theme-yellow-border;
.day-number {
color: $theme-yellow;
font-weight: 700;
}
}
.day-number {
font-size: 26rpx;
color: $text-primary;
font-weight: 500;
line-height: 1;
}
/* 排课内容展示区 */
.schedule-box {
width: 100%;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
.schedule-label {
width: 100%;
height: 100%;
background-color: $theme-yellow;
color: #000000;
font-size: 20rpx;
font-weight: 600;
border-radius: 6rpx;
padding: 4rpx;
box-sizing: border-box;
text-align: center;
line-height: 1.25;
/* 超出两行省略 */
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-all;
}
.empty-label {
/* 无排课时的精致小点,替代原本突兀的大灰块 */
.schedule-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 56rpx;
background: $text-gray;
border-radius: 2rpx;
height: 100%;
.dot {
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.15);
}
}
}
.empty-cell {
background: transparent !important;
}
}
// ==================== 底部提示 ====================
.bottom-tip {
text-align: center;
font-size: 30rpx;
color: $text-white;
margin-top: 60rpx;
/* #ifdef H5 */
margin-bottom: 20px;
/* #endif */
padding: 24rpx 0 10rpx;
text {
font-size: 24rpx;
color: $text-secondary;
}
}
</style>
\ No newline at end of file
... ...
... ... @@ -4,11 +4,13 @@
<up-navbar placeholder bgColor="transparent" />
<!-- #endif -->
<view class="status-header">
<view class="timer-box" @click="show = true">
<view class="timer-box" @click="show = true" v-if="!isFutureTemplate">
<text class="time">{{ formattedTime.substring(3) }}</text>
<view class="triangle-icon"></view>
</view>
<view class="finish-btn" :class="{ disabled: !allUnitsChecked }" @click="save">完成</view>
<view v-else class="timer-box-placeholder" />
<view class="finish-btn" :class="{ disabled: isFutureTemplate ? !hasAnyAction : !allUnitsChecked }" @click="save">
{{ isFutureTemplate ? '保存模板' : '完成' }}</view>
</view>
<view class="action-list">
... ... @@ -25,18 +27,10 @@
<!-- 3. 模板训练 type=3 循环 -->
<template v-if="actionDetail?.units?.length">
<view v-for="(unit, index) in actionDetail.units" :key="unit.unitId">
<dongzuo
:id="unit.unitId"
:type="unit.unitType"
:actionDetail="trainingStore.convertUnitToActionDetail(unit)"
:ref="(el) => setActionRef(el, index)"
:unitIndex="index"
:is-daily-templates="false"
:isUnitActive="unitActiveStates[index]"
@startRestTimer="startRestCountdown"
@cancelRestTimer="onCancelRestTimer"
@updateUnitActive="(val) => onUnitActiveChange(index, val)"
/>
<dongzuo :id="unit.unitId" :type="unit.unitType" :actionDetail="trainingStore.convertUnitToActionDetail(unit)"
:ref="(el) => setActionRef(el, index)" :unitIndex="index" :is-daily-templates="false"
:isUnitActive="unitActiveStates[index]" @startRestTimer="startRestCountdown"
@cancelRestTimer="onCancelRestTimer" @updateUnitActive="(val) => onUnitActiveChange(index, val)" />
</view>
</template>
... ... @@ -47,7 +41,7 @@
</view>
</view>
<view v-if="!actionDetail?.units?.length" class="tip-bubble">
<view v-if="!actionDetail?.units?.length && !addActionsShow" class="tip-bubble">
<text>挑选想要的动作开始训练吧</text>
</view>
... ... @@ -75,25 +69,14 @@
</view>
</view>
<!-- 组间休息倒计时 -->
<view
class="rest-countdown"
v-if="restCountdownSeconds > 0"
@click="clearRestCountdown()"
>
<view class="rest-countdown" v-if="restCountdownSeconds > 0" @click="clearRestCountdown()">
<view class="countdown-circle">
<!-- H5 端:使用 SVG 进度环 -->
<!-- #ifdef H5 -->
<svg class="ring-svg" viewBox="0 0 100 100">
<circle class="ring-bg" cx="50" cy="50" r="44" />
<circle
class="ring-progress"
cx="50"
cy="50"
r="44"
:stroke-dasharray="CIRCUMFERENCE"
:stroke-dashoffset="CIRCUMFERENCE * (1 - restProgress)"
transform="rotate(-90 50 50)"
/>
<circle class="ring-progress" cx="50" cy="50" r="44" :stroke-dasharray="CIRCUMFERENCE"
:stroke-dashoffset="CIRCUMFERENCE * (1 - restProgress)" transform="rotate(-90 50 50)" />
</svg>
<!-- #endif -->
<!-- 微信小程序端:不显示进度环 -->
... ... @@ -104,14 +87,8 @@
</view>
</view>
<!-- 设置训练时间弹窗 -->
<up-popup
:show="show"
mode="top"
@close="show = false"
:safeAreaInsetBottom="false"
safeAreaInsetTop
bgColor="#2c2c2e"
>
<up-popup :show="show" mode="top" @close="show = false" :safeAreaInsetBottom="false" safeAreaInsetTop
bgColor="#2c2c2e">
<view class="time-pop-container">
<view class="pop-content">
<view class="pop-title">设置训练时间</view>
... ... @@ -144,11 +121,7 @@
</view>
<view class="timer-right">
<view class="action-btn gray-btn" v-if="isPause" @click="resetTimer">重置</view>
<view
class="action-btn green-btn"
:class="{ active: !isPause, bgactve: !isPause }"
@click="toggleTimer"
>
<view class="action-btn green-btn" :class="{ active: !isPause, bgactve: !isPause }" @click="toggleTimer">
{{ isPause ? '继续计时' : '暂停计时' }}
</view>
</view>
... ... @@ -161,79 +134,39 @@
</up-popup>
<!-- 设置训练日期 -->
<up-datetime-picker
:show="datePickerShow"
v-model="dateValue"
mode="date"
closeOnClickOverlay
@confirm="confirmDate"
@cancel="datePickerShow = false"
@close="datePickerShow = false"
>
<up-datetime-picker :show="datePickerShow" v-model="dateValue" mode="date" closeOnClickOverlay
@confirm="confirmDate" @cancel="datePickerShow = false" @close="datePickerShow = false">
</up-datetime-picker>
<!-- 设置起始时间 -->
<up-datetime-picker
:show="timePickerShow"
v-model="timeValue"
mode="time"
closeOnClickOverlay
@confirm="confirmTime"
@cancel="timePickerShow = false"
@close="timePickerShow = false"
>
<up-datetime-picker :show="timePickerShow" v-model="timeValue" mode="time" closeOnClickOverlay
@confirm="confirmTime" @cancel="timePickerShow = false" @close="timePickerShow = false">
</up-datetime-picker>
<!-- 设置训练时长计时 -->
<up-picker
:show="showPicker"
:columns="timeColumns"
:defaultIndex="defaultTimeIndex"
title="修改训练时长"
@confirm="onTimeConfirm"
@cancel="showPicker = false"
closeOnClickOverlay
>
<up-picker :show="showPicker" :columns="timeColumns" :defaultIndex="defaultTimeIndex" title="修改训练时长"
@confirm="onTimeConfirm" @cancel="showPicker = false" closeOnClickOverlay>
</up-picker>
<!-- 写总结弹窗 -->
<up-popup
:show="summaryShow"
@close="summaryShow = false"
mode="bottom"
:safeAreaInsetBottom="false"
>
<up-popup :show="summaryShow" @close="summaryShow = false" mode="bottom" :safeAreaInsetBottom="false">
<view class="summaryPopup">
<view class="popup-header">
<text class="popup-title">本次训练总结</text>
<view class="save-btn" @click="saveSummary">保存</view>
</view>
<textarea
class="summary-textarea"
v-model="summaryContent"
placeholder="记录本次训练感悟"
/>
<textarea class="summary-textarea" v-model="summaryContent" placeholder="记录本次训练感悟" />
</view>
</up-popup>
<!-- 命名弹窗 -->
<up-popup
:show="inputNameShow"
@close="inputNameShow = false"
mode="bottom"
:safeAreaInsetBottom="false"
>
<up-popup :show="inputNameShow" @close="inputNameShow = false" mode="bottom" :safeAreaInsetBottom="false">
<view class="namePopup">
<view class="popup-header">
<text class="popup-title">训练名称</text>
<view class="save-btn" @click="saveTrainingName">保存</view>
</view>
<textarea
v-model="trainingStore.trainingName"
class="name-input"
placeholder="请输入训练名称"
placeholder-color="#8e8e93"
color="#fff"
/>
<textarea v-model="trainingStore.trainingName" class="name-input" placeholder="请输入训练名称"
placeholder-color="#8e8e93" color="#fff" />
</view>
</up-popup>
<!-- 动作新增组件 -->
... ... @@ -243,75 +176,86 @@
</template>
<script setup>
import { ref, computed, nextTick, onUnmounted } from 'vue';
import dayjs from 'dayjs';
import { onLoad } from '@dcloudio/uni-app';
import TrainingApi from '@/sheep/api/Training/traininghistory';
import dailytemplateApi from '@/sheep/api/Template/Dailytemplate';
import { useTrainingStore } from '@/sheep/store/trainingStore';
import addActions from '@/pages/xunji/components/dongzuo-lianxi/add-actions.vue';
import ActionSort from '@/pages/xunji/components/dongzuo-lianxi/dongzuo-paixu.vue';
import dongzuo from '@/pages/xunji/components/dongzuo-lianxi/dongzuo.vue';
import { trainingSnapshot } from '@/sheep/store/trainingSnapshot';
const trainingStore = useTrainingStore();
const addActionsShow = ref(false);
const hasConverted = ref(false);
const summaryShow = ref(false);
const summaryContent = ref('');
const inputNameShow = ref(false);
const actionSortRef = ref(null);
const show = ref(false);
const datePickerShow = ref(false);
const timePickerShow = ref(false);
const dateValue = ref(Number(new Date()));
const timeValue = ref('14:13');
const trainingDateText = ref('');
const totalSeconds = computed(() => trainingStore.totalSeconds || 0);
const isPause = computed(() => trainingStore.isPause);
const showPicker = computed(() => trainingStore.showPicker);
const defaultTimeIndex = computed(() => trainingStore.defaultTimeIndex);
const trainingName = computed(() => trainingStore.trainingName);
const trainingTimeText = computed(() => trainingStore.trainingTimeText);
const actionDetail = computed(() => trainingStore.actionDetail || {});
// ===================== 组间休息倒计时 =====================
const restCountdownSeconds = ref(0);
const restCountdownTotal = ref(0);
let restCountdownInterval = null;
const restFormattedTime = computed(() => {
import { ref, computed, nextTick, onUnmounted } from 'vue';
import dayjs from 'dayjs';
import { onLoad } from '@dcloudio/uni-app';
import TrainingApi from '@/sheep/api/Training/traininghistory';
import dailytemplateApi from '@/sheep/api/Template/Dailytemplate';
import TemplatesApi from '@/sheep/api/Template/Templates';
import QueryPlanApi from '@/sheep/api/plan/queryplan';
import { useTrainingStore } from '@/sheep/store/trainingStore';
import addActions from '@/pages/xunji/components/dongzuo-lianxi/add-actions.vue';
import ActionSort from '@/pages/xunji/components/dongzuo-lianxi/dongzuo-paixu.vue';
import dongzuo from '@/pages/xunji/components/dongzuo-lianxi/dongzuo.vue';
import { trainingSnapshot } from '@/sheep/store/trainingSnapshot';
const trainingStore = useTrainingStore();
const addActionsShow = ref(false);
const hasConverted = ref(false);
const summaryShow = ref(false);
const summaryContent = ref('');
const inputNameShow = ref(false);
const actionSortRef = ref(null);
const show = ref(false);
const datePickerShow = ref(false);
const timePickerShow = ref(false);
const dateValue = ref(Number(new Date()));
const timeValue = ref('14:13');
const trainingDateText = ref('');
// 未来日期模板模式
const isFutureTemplate = ref(false);
const targetDate = ref('');
const totalSeconds = computed(() => trainingStore.totalSeconds || 0);
const isPause = computed(() => trainingStore.isPause);
const showPicker = computed(() => trainingStore.showPicker);
const defaultTimeIndex = computed(() => trainingStore.defaultTimeIndex);
const trainingName = computed(() => trainingStore.trainingName);
const trainingTimeText = computed(() => trainingStore.trainingTimeText);
const actionDetail = computed(() => trainingStore.actionDetail || {});
// 未来模板模式:是否有任何动作
const hasAnyAction = computed(() => (actionDetail.value?.units || []).length > 0);
/** 可靠的训练日期:优先使用传入的目标日期,兜底从显示文本提取 */
const reliableTrainingDate = computed(() => {
if (targetDate.value) return targetDate.value;
if (trainingDateText.value) return trainingDateText.value.split(' ')[0];
return dayjs().format('YYYY-MM-DD');
});
// ===================== 组间休息倒计时 =====================
const restCountdownSeconds = ref(0);
const restCountdownTotal = ref(0);
let restCountdownInterval = null;
const restFormattedTime = computed(() => {
const min = Math.floor(restCountdownSeconds.value / 60);
const sec = restCountdownSeconds.value % 60;
return `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
});
});
const restProgress = computed(() => {
const restProgress = computed(() => {
if (restCountdownTotal.value <= 0) return 0;
return 1 - restCountdownSeconds.value / restCountdownTotal.value;
});
});
// H5 SVG 圆环周长(用于 stroke-dasharray)
const CIRCUMFERENCE = 2 * Math.PI * 44;
// H5 SVG 圆环周长(用于 stroke-dasharray)
const CIRCUMFERENCE = 2 * Math.PI * 44;
const clearRestCountdown = () => {
const clearRestCountdown = () => {
if (restCountdownInterval) {
clearInterval(restCountdownInterval);
restCountdownInterval = null;
}
restCountdownSeconds.value = 0;
restCountdownTotal.value = 0;
};
};
const startRestCountdown = (seconds) => {
const startRestCountdown = (seconds) => {
if (!seconds || seconds <= 0) return;
// 叠加倒计时:如果已经在倒计时中则累加,否则开始新的倒计时
if (restCountdownInterval) {
restCountdownSeconds.value += seconds;
restCountdownTotal.value += seconds;
} else {
// 每次打勾用最新时间重新开始计时(替换当前倒计时,不叠加)
clearRestCountdown();
restCountdownTotal.value = seconds;
restCountdownSeconds.value = seconds;
restCountdownInterval = setInterval(() => {
... ... @@ -321,34 +265,37 @@
// 倒计时结束:不取消勾选状态,不恢复主计时器(主计时器始终在运行)
}
}, 1000);
}
// 不暂停主计时器,训练计时器始终继续
};
};
function onCancelRestTimer(seconds) {
if (!restCountdownInterval) return;
// 取消勾选时减去对应秒数
restCountdownSeconds.value -= (seconds || 0);
restCountdownTotal.value -= (seconds || 0);
if (restCountdownSeconds.value <= 0) {
function onCancelRestTimer() {
clearRestCountdown();
}
}
}
onUnmounted(() => {
onUnmounted(() => {
clearRestCountdown();
});
});
const openActionSort = () => {
const openActionSort = () => {
actionSortRef.value.openActionSort();
};
};
onLoad(async (options) => {
onLoad(async (options) => {
const id = Number(options.id);
const type = Number(options.type);
const isTraining = options.isTraining === 'true';
trainingStore.isTraining = isTraining;
// 未来日期模板模式
isFutureTemplate.value = options.isFutureTemplate === 'true';
targetDate.value = options.targetDate || '';
// 初始化日期选择器为目标日期,防止打开时间设置弹窗时默认显示今天导致日期被意外覆盖
if (targetDate.value) {
dateValue.value = dayjs(targetDate.value).valueOf();
}
if (isFutureTemplate.value) {
trainingStore.clearTimer();
}
const dailyTemplateId = options.dailyTemplateId ? Number(options.dailyTemplateId) : null;
trainingStore.dailyTemplateId = dailyTemplateId;
// 直接让 store 去请求 + 保存数据
... ... @@ -365,121 +312,142 @@
}
initTrainingName();
trainingDateText.value = dayjs().format('YYYY-MM-DD dd');
// 编辑模式:默认勾选所有动作的全部组 + 激活整个unit背景
const isEdit = options.isEdit === 'true';
if (isEdit) {
const records = trainingStore.unitRecords;
Object.keys(records).forEach((unitIdx) => {
const unitRecord = records[unitIdx];
Object.keys(unitRecord.records).forEach((exId) => {
(unitRecord.records[exId] || []).forEach((set) => {
set.isActive = true;
});
});
});
// 激活所有 unit 的整体背景
const totalUnits = actionDetail.value?.units?.length || 0;
unitActiveStates.value = Array.from({ length: totalUnits }, () => true);
}
// 未来模板模式:展示目标日期;训练模式有 targetDate 则用它,否则今天
trainingDateText.value = isFutureTemplate.value
? dayjs(targetDate.value).format('YYYY-MM-DD dd')
: (targetDate.value ? dayjs(targetDate.value).format('YYYY-MM-DD dd') : dayjs().format('YYYY-MM-DD dd'));
if (!trainingStore.trainingTimeText) {
trainingStore.setTrainingTimeText(dayjs().format('HH:mm'));
}
if (isTraining) {
trainingStore.toggleTimer();
}
});
});
const initTrainingName = () => {
const initTrainingName = () => {
trainingStore.setTrainingName(actionDetail.value?.templateName || '训练');
};
};
const totalSets = computed(() => {
const totalSets = computed(() => {
let count = 0;
dongzuoRefs.value.forEach((ref) => {
if (ref?.totalSetCount) count += ref.totalSetCount;
});
return count;
});
});
const totalCheckedWeight = computed(() => {
const totalCheckedWeight = computed(() => {
let sum = 0;
dongzuoRefs.value.forEach((ref) => {
if (ref?.checkedWeight) sum += ref.checkedWeight;
});
return sum;
});
});
const allTotalWeight = computed(() => {
const allTotalWeight = computed(() => {
let sum = 0;
dongzuoRefs.value.forEach((ref) => {
if (ref?.totalWeight) sum += ref.totalWeight;
});
return sum;
});
});
// 获得子组件模板的数据
const dongzuoRefs = ref([]);
const setActionRef = (el, index) => {
// 获得子组件模板的数据
const dongzuoRefs = ref([]);
const setActionRef = (el, index) => {
if (el) dongzuoRefs.value[index] = el;
};
};
// 每个 unit 是否有勾选的组
const unitActiveStates = ref([])
const onUnitActiveChange = (index, val) => {
// 每个 unit 是否有勾选的组
const unitActiveStates = ref([])
const onUnitActiveChange = (index, val) => {
unitActiveStates.value[index] = val
unitActiveStates.value = [...unitActiveStates.value]
}
}
// 全部 unit 已勾选后才能提交
const allUnitsChecked = computed(() => {
// 全部 unit 已勾选后才能提交
const allUnitsChecked = computed(() => {
const list = actionDetail.value?.units || []
if (list.length === 0) return false
if (unitActiveStates.value.length !== list.length) return false
return unitActiveStates.value.every((v) => v === true)
})
})
const openSummaryPopup = () => {
const openSummaryPopup = () => {
summaryShow.value = true;
summaryContent.value = '';
};
};
const saveSummary = () => {
const saveSummary = () => {
summaryShow.value = false;
summaryContent.value = '';
};
};
const openTrainingNamePopup = () => {
const openTrainingNamePopup = () => {
inputNameShow.value = true;
};
};
const saveTrainingName = () => {
const saveTrainingName = () => {
trainingStore.setTrainingName(trainingStore.trainingName);
if (actionDetail.value) {
trainingStore.actionDetail.templateName = trainingStore.trainingName;
}
inputNameShow.value = false;
};
};
const formattedTime = computed(() =>
const formattedTime = computed(() =>
dayjs.duration(totalSeconds.value, 'seconds').format('HH:mm:ss'),
);
const toggleTimer = () => trainingStore.toggleTimer();
const resetTimer = () => trainingStore.resetTimer();
);
const toggleTimer = () => trainingStore.toggleTimer();
const resetTimer = () => trainingStore.resetTimer();
const timeColumns = ref([
const timeColumns = ref([
Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0') + '时'),
Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0') + '分'),
Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0') + '秒'),
]);
]);
const handleTimerClick = () => {
const handleTimerClick = () => {
if (isPause.value) {
trainingStore.openTimePicker();
}
};
};
const onTimeConfirm = (e) => {
const onTimeConfirm = (e) => {
const rawValues = e.value.map((val) => parseInt(String(val).replace(/[时分秒]/g, '')));
const seconds = rawValues[0] * 3600 + rawValues[1] * 60 + rawValues[2];
trainingStore.setTotalSeconds(seconds);
trainingStore.closeTimePicker();
};
const confirmDate = (e) => {
};
const confirmDate = (e) => {
trainingDateText.value = dayjs(e.value).format('YYYY-MM-DD dd');
datePickerShow.value = false;
};
};
const confirmTime = (e) => {
const confirmTime = (e) => {
trainingStore.setTrainingTimeText(e.value);
timePickerShow.value = false;
};
};
const mapSets = (recordItems, exerciseType) =>
const mapSets = (recordItems, exerciseType) =>
recordItems.map((item, index) => {
const isInterval = exerciseType === 6;
const duration = isInterval
... ... @@ -498,8 +466,16 @@
};
});
const save = async () => {
// 前置校验:所有 unit 必须已勾选(顶部 ✓)才能提交
const save = async () => {
// 前置校验
if (isFutureTemplate.value) {
// 未来模板模式:必须至少有一个动作
if (!hasAnyAction.value) {
uni.showToast({ title: '请先添加训练动作', icon: 'none' });
return;
}
} else {
// 训练模式:所有 unit 必须已勾选(顶部 ✓)才能提交
if (!allUnitsChecked.value) {
uni.showModal({
title: '提示',
... ... @@ -509,13 +485,66 @@
});
return;
}
}
await nextTick();
const units = [];
try {
// ========== 未来日期模板模式:直接从模板结构构建 units,创建模板 → 添加到日历 ==========
if (isFutureTemplate.value && targetDate.value) {
const unitList = actionDetail.value?.units || [];
for (let i = 0; i < unitList.length; i++) {
const unit = unitList[i];
// 直接从模板 actionDetail.units 提取,不依赖子组件 refs 的 recordList/superRecordMap
const templateUnits = unitList.map((unit, i) => ({
id: null,
name: i === 0 ? trainingName.value : (unit.unitName || ''),
unitType: unit.unitType,
sortOrder: i + 1,
exercises: unit.exercises,
}));
const templateRes = await TemplatesApi.createCustTemplate({
templateName: trainingName.value,
scene: 0,
templateCover: '',
templateIntroduction: '',
units: templateUnits,
});
if (templateRes.code == 0) {
// 兼容 templateRes.data 为数字(旧版)或对象(新版)的场景
const templateId = typeof templateRes.data === 'object'
? (templateRes.data?.templateId || templateRes.data?.id || templateRes.data)
: templateRes.data;
try {
await QueryPlanApi.addPlanToCalendar({
id: templateId,
trainDateList: [targetDate.value],
});
uni.$emit('calendarDataRefresh');
uni.showToast({ title: '已添加到训练日历', icon: 'success' });
} catch {
// 添加到日历失败,但模板已保存成功,单独提示
uni.showToast({ title: '模板已创建,但添加到日历失败', icon: 'none' });
}
} else {
uni.showToast({ title: templateRes.msg || '添加失败', icon: 'none' });
}
setTimeout(() => {
uni.navigateBack();
trainingStore.clearTrainingStore();
}, 1500);
return;
}
// ========== 训练/编辑模式:从子组件 refs 提取训练数据 ==========
const units = [];
const unitListForTrain = actionDetail.value?.units || [];
for (let i = 0; i < unitListForTrain.length; i++) {
const unit = unitListForTrain[i];
const child = dongzuoRefs.value[i];
if (!child) continue;
... ... @@ -548,7 +577,7 @@
}
}
// 检查是否有打勾(已完成)的动作,只有打勾的动作才会被提交
// 检查是否有有效数据
const hasAnyChecked = units.some(u =>
u.exercises.some(e => e.sets && e.sets.length > 0)
);
... ... @@ -570,10 +599,47 @@
return;
}
try {
if (trainingStore.isTraining) {
await TrainingApi.createTrainHistory({ units, allCompleted: allUnitsChecked.value });
// 自由训练到空日期(有 targetDate 但无 dailyTemplateId):
// 需要先创建模板并添加到目标日期,才能让日历在对应日期上显示该训练
if (targetDate.value && !trainingStore.dailyTemplateId) {
try {
// 1. 基于训练动作结构创建个人模板
const templateUnits = unitListForTrain.map((u, idx) => ({
id: null,
name: idx === 0 ? trainingName.value : (u.unitName || ''),
unitType: u.unitType,
sortOrder: idx + 1,
exercises: u.exercises,
}));
const templateRes = await TemplatesApi.createCustTemplate({
templateName: trainingName.value,
scene: 0,
templateCover: '',
templateIntroduction: '',
units: templateUnits,
});
if (templateRes.code == 0) {
const templateId = typeof templateRes.data === 'object'
? (templateRes.data?.templateId || templateRes.data?.id || templateRes.data)
: templateRes.data;
// 2. 将模板添加到目标日期日历,确保训练显示在所选日期
await QueryPlanApi.addPlanToCalendar({
id: templateId,
trainDateList: [targetDate.value],
});
}
} catch (e) {
console.error('创建自由训练每日模板失败:', e);
}
}
await TrainingApi.createTrainHistory({ units, allCompleted: allUnitsChecked.value, trainingDate: reliableTrainingDate.value });
uni.showToast({ title: '训练提交成功', icon: 'success' });
uni.$emit('calendarDataRefresh');
} else if (trainingStore.type === 3 && trainingStore.dailyTemplateId) {
await dailytemplateApi.updateDailyTemplate({
dailyTemplateId: trainingStore.dailyTemplateId,
... ... @@ -581,9 +647,11 @@
allCompleted: allUnitsChecked.value,
});
uni.showToast({ title: '模板修改成功', icon: 'success' });
uni.$emit('calendarDataRefresh');
} else {
await TrainingApi.createTrainHistory({ units, allCompleted: allUnitsChecked.value });
await TrainingApi.createTrainHistory({ units, allCompleted: allUnitsChecked.value, trainingDate: reliableTrainingDate.value });
uni.showToast({ title: '训练提交成功', icon: 'success' });
uni.$emit('calendarDataRefresh');
}
setTimeout(() => {
uni.navigateBack();
... ... @@ -593,9 +661,9 @@
console.error('保存失败:', err);
uni.showToast({ title: '保存失败', icon: 'none' });
}
};
// 删除训练
const deleteTraining = () => {
};
// 删除训练
const deleteTraining = () => {
uni.showModal({
title: '确认删除',
content: '确定要删除本次训练记录吗?删除后无法恢复',
... ... @@ -612,13 +680,13 @@
}
},
});
};
};
const addActionsPopup = () => {
const addActionsPopup = () => {
addActionsShow.value = true;
};
};
const openMin = () => {
const openMin = () => {
trainingStore.min = true;
trainingSnapshot.data = JSON.parse(
JSON.stringify({
... ... @@ -650,17 +718,17 @@
}
uni.navigateBack();
};
};
</script>
<style lang="scss" scoped>
.container {
.container {
min-height: 100vh;
background-color: #242424;
padding-bottom: 120rpx;
}
}
.status-header {
.status-header {
display: flex;
justify-content: space-between;
align-items: center;
... ... @@ -695,6 +763,11 @@
}
}
.timer-box-placeholder {
width: 120rpx;
flex-shrink: 0;
}
.finish-btn {
background-color: #fbdf09;
color: #000;
... ... @@ -711,9 +784,9 @@
pointer-events: none;
}
}
}
}
.action-list {
.action-list {
padding: 0 0 20rpx;
.header {
... ... @@ -739,9 +812,9 @@
font-size: 24rpx;
}
}
}
}
.empty-state {
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
... ... @@ -758,11 +831,15 @@
.empty-subtext {
font-size: 28rpx;
}
}
}
.tip-bubble {
.tip-bubble {
position: fixed;
bottom: 230rpx;
bottom: 200rpx;
// #ifdef H5
bottom: 160rpx;
// #endif
left: 52%;
transform: translateX(-50%);
background-color: #000;
... ... @@ -782,9 +859,9 @@
border-right: 10rpx solid transparent;
border-top: 10rpx solid #000;
}
}
}
.footer-bar {
.footer-bar {
position: fixed;
bottom: 0;
left: 0;
... ... @@ -838,9 +915,9 @@
position: relative;
z-index: 99999;
}
}
}
.time-pop-container {
.time-pop-container {
padding: 40rpx 30rpx;
color: #fff;
... ... @@ -956,9 +1033,9 @@
border-radius: 4rpx;
margin: 40rpx auto 0;
}
}
}
.summaryPopup {
.summaryPopup {
width: 100vw;
height: 75vh;
background-color: #2c2c2e;
... ... @@ -1004,9 +1081,9 @@
color: #8e8e93;
}
}
}
}
.namePopup {
.namePopup {
width: 100%;
height: 75vh;
background-color: #2c2c2e;
... ... @@ -1051,18 +1128,18 @@
color: #8e8e93;
}
}
}
}
// ===================== 组间休息倒计时 =====================
.rest-countdown {
// ===================== 组间休息倒计时 =====================
.rest-countdown {
position: fixed;
bottom: 180rpx;
right: 30rpx;
z-index: 100;
animation: countdown-enter 0.3s ease-out;
}
}
@keyframes countdown-enter {
@keyframes countdown-enter {
from {
opacity: 0;
transform: scale(0.5);
... ... @@ -1072,9 +1149,9 @@
opacity: 1;
transform: scale(1);
}
}
}
.countdown-circle {
.countdown-circle {
position: relative;
width: 160rpx;
height: 160rpx;
... ... @@ -1127,5 +1204,5 @@
color: #8e8e93;
margin-top: 4rpx;
}
}
}
</style>
... ...
... ... @@ -71,7 +71,7 @@
<view class="name">{{ unit.unitName || `超级组 ${unitIndex + 1}` }}</view>
<view class="PartTool">
<template v-if="unit.unitType === 1">
{{ unit.exercises[0]?.categoryDescription }} {{ unit.exercises[0]?.equipmentDescription }}
{{ getUnitDesc(unit) }}
</template>
<template v-else>超级组</template>
</view>
... ... @@ -102,7 +102,7 @@
<!-- 超级组内动作名称 -->
<view class="super-actionName">
<view v-for="(ex, exIdx) in unit.exercises" :key="ex.exerciseId" class="name-item">
<text class="letter">{{ String.fromCharCode(65 + exIdx) }} </text>
<text class="letter">{{ getLabelChar(exIdx) }} </text>
<text class="action-name">{{ ex.exerciseName || '动作名称' }}</text>
</view>
</view>
... ... @@ -114,7 +114,7 @@
<view class="set-column">
<view v-for="(ex, exIdx) in unit.exercises" :key="ex.exerciseId" class="action-in-set">
<template v-if="ex.sets[idx - 1]">
<text class="action-letter">{{ idx }}{{ String.fromCharCode(65 + exIdx) }}</text>
<text class="action-letter">{{ idx }}{{ getLabelChar(exIdx) }}</text>
<view class="set-content">
<text>{{ formatSetText(ex.exerciseType, ex.sets[idx - 1]) }}</text>
<text v-if="ex.sets[idx - 1].restTime && ex.exerciseType !== 6" class="rest">{{ ex.sets[idx -
... ... @@ -136,8 +136,15 @@
<!-- ==================== 底部操作栏 ==================== -->
<view class="bottom-actions">
<!-- 模板添加模式 -->
<template v-if="isFromTemplateAdd">
<button class="add-calendar-btn" @click="confirmAddToCalendar">
模板添加到日程({{ addDateText }})
</button>
</template>
<!-- 我的计划模式 -->
<template v-if="isMyPlan">
<template v-else-if="isMyPlan">
<button class="start-btn" :class="{ locked: !isUnlocked }" :disabled="!isUnlocked"
@click="startDaiTemplateTraining">
{{ isUnlocked ? '立即开始训练' : '训练在当日解锁' }}
... ... @@ -212,7 +219,7 @@ import dongzuoXianqing from '@/pages/xunji/components/dongzuo-xianqing.vue';
// ==================== 常量 ====================
const DELETE_DELAY = 800;
const NAVIGATE_DELAY = 1500;
const MUSCLE_IMG = 'https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260424/muscle_back_1777018976173.png';
const MUSCLE_IMG = 'https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260724/训练部位_1784878616826.jpg';
const SUPER_GROUP_IMG = 'https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260507/超级组_1778117889451.png';
const LOST_IMAGE = 'https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260316/order-empty_1773628059920.png';
... ... @@ -240,8 +247,10 @@ const showColorPopup = ref(false);
const isOffice = ref(false);
const isMyPlan = ref(false);
const isDailytemplateId = ref(false);
const isFromTemplateAdd = ref(false);
const isMove = ref(false);
const date = ref('');
const selectedAddDate = ref('');
const templateId = ref('');
const calendarId = ref(0);
... ... @@ -263,106 +272,99 @@ const isUnlocked = computed(() => {
if (!templateDetail.value?.trainingDate) return false;
const [year, month, day] = templateDetail.value.trainingDate;
const unlockDate = dayjs(`${year}-${month}-${day}`).startOf('day');
return dayjs().startOf('day').isSameOrAfter(unlockDate);
return dayjs().startOf('day').diff(unlockDate, 'day') >= 0;
});
/** 模板添加模式:格式化日期显示 */
const addDateText = computed(() => {
if (!selectedAddDate.value) return '';
const d = dayjs(selectedAddDate.value);
const weekMap = ['日', '一', '二', '三', '四', '五', '六'];
return `${d.format('MM/DD')} ${weekMap[d.day()]}`;
});
// ==================== 工具函数 ====================
/** 纯函数:索引→字母 A/B/C... */
const getLabelChar = (idx) => String.fromCharCode(65 + idx);
/** 纯函数:unit 的描述文本 */
const getUnitDesc = (unit) =>
unit?.exercises?.[0] ? `${unit.exercises[0].categoryDescription ?? ''} ${unit.exercises[0].equipmentDescription ?? ''}` : '';
/** 秒数 → HH:mm:ss */
function formatSeconds(seconds) {
const formatSeconds = (seconds) => {
if (!seconds || isNaN(seconds)) return '00:00:00';
return dayjs.duration(seconds, 'seconds').format('HH:mm:ss');
}
};
/** 根据动作类型格式化组次文本 */
function formatSetText(exerciseType, detail) {
const fn = SET_TEXT_FORMATTERS[exerciseType];
return fn ? fn(detail) : '';
}
const formatSetText = (exerciseType, detail) => SET_TEXT_FORMATTERS[exerciseType]?.(detail) ?? '';
/** 获取 unit 封面图 */
function getUnitCover(unit) {
if (unit.unitType === 1) {
return unit.exercises?.[0]?.exerciseCover || LOST_IMAGE;
}
return SUPER_GROUP_IMG;
}
const getUnitCover = (unit) =>
unit?.unitType === 1
? (unit.exercises?.[0]?.exerciseCover || LOST_IMAGE)
: SUPER_GROUP_IMG;
/** 单个 unit 内所有动作的总组数 */
function getUnitSetCount(unit) {
if (!unit?.exercises?.length) return 0;
let count = 0;
unit.exercises.forEach((item) => {
if (item.sets?.length) count += item.sets.length;
});
return count;
}
const getUnitSetCount = (unit) =>
unit?.exercises?.reduce((total, item) => total + (item.sets?.length || 0), 0) ?? 0;
/** 超级组内最大组数 */
function getSuperSetMaxGroupCount(exercises) {
if (!Array.isArray(exercises) || exercises.length === 0) return 0;
return exercises.reduce((max, ex) => {
const len = Array.isArray(ex.sets) ? ex.sets.length : 0;
return Math.max(max, len);
}, 0);
}
const getSuperSetMaxGroupCount = (exercises) =>
exercises?.reduce((max, ex) => Math.max(max, ex.sets?.length || 0), 0) ?? 0;
// ==================== 数据加载 ====================
async function loadTemplateDetail() {
const loadTemplateDetail = async () => {
try {
let res;
if (isOffice.value) {
res = await TemplatesApi.getTemplateDetail(templateId.value);
} else if (isDailytemplateId.value) {
res = await QueryPlanApi.getDailyTemplateDetail(templateId.value);
} else {
res = await TemplatesApi.getCustTemplateDetail(templateId.value);
}
const res = isOffice.value
? await TemplatesApi.getTemplateDetail(templateId.value)
: isDailytemplateId.value
? await QueryPlanApi.getDailyTemplateDetail(templateId.value)
: await TemplatesApi.getCustTemplateDetail(templateId.value);
templateDetail.value = res.data;
templateUnits.value = res.data.units || [];
} catch (err) {
console.error('❌ 模板详情加载失败:', err);
}
}
};
// ==================== 日历颜色 ====================
function openColorPopup() {
const openColorPopup = () => {
if (!templateDetail.value.id) {
uni.showToast({ title: '未找到模板ID', icon: 'none' });
return;
}
showColorPopup.value = true;
}
};
// ==================== 添加到/移动到日历 ====================
function handleCalendarSuccess() {
const handleCalendarSuccess = () => {
loadTemplateDetail();
}
uni.$emit('calendarDataRefresh');
};
function openCalendarPopup() {
const openCalendarPopup = () => {
calendarId.value = templateDetail.value.id;
calendarPopupRef.value?.open();
}
};
function openMovePopup() {
const openMovePopup = () => {
calendarId.value = templateDetail.value.dailyTemplateId;
isMove.value = true;
calendarPopupRef.value?.open();
}
};
// ==================== 动作详情弹窗 ====================
function handleUnitClick(unit) {
const handleUnitClick = (unit) => {
if (unit.unitType === 1) {
const id = unit.exercises[0].exerciseId;
nextTick(() => actionDetailRef.value?.open(id, 1));
nextTick(() => actionDetailRef.value?.open(unit.exercises[0].exerciseId, 1));
} else if (unit.unitType === 2) {
const id = unit.supersetId;
nextTick(() => actionDetailRef.value?.open(id, 2));
nextTick(() => actionDetailRef.value?.open(unit.supersetId, 2));
}
}
};
// ==================== 开始训练 ====================
function startTraining() {
const startTraining = () => {
if (trainingStore.isTraining) {
uni.showToast({ title: '当前已有正在进行的训练', icon: 'none' });
return;
... ... @@ -372,9 +374,9 @@ function startTraining() {
uni.navigateTo({
url: `/pages4/pages/xunji/xunji-dongzuo-lianxi?id=${templateDetail.value.id}&type=3&isTraining=true`,
});
}
};
function startDaiTemplateTraining() {
const startDaiTemplateTraining = () => {
if (trainingStore.isTraining) {
uni.showToast({ title: '当前已有正在进行的训练', icon: 'none' });
return;
... ... @@ -385,20 +387,45 @@ function startDaiTemplateTraining() {
uni.navigateTo({
url: `/pages4/pages/xunji/xunji-dongzuo-lianxi?id=${templateDetail.value.id}&type=3&dailyTemplateId=${templateDetail.value.dailyTemplateId}&isTraining=true`,
});
}
};
// ==================== 模板添加模式:直接添加到日程 ====================
const confirmAddToCalendar = async () => {
if (!templateDetail.value?.id) {
uni.showToast({ title: '未找到模板', icon: 'none' });
return;
}
const targetDate = selectedAddDate.value || dayjs().format('YYYY-MM-DD');
try {
const res = await QueryPlanApi.addPlanToCalendar({
id: templateDetail.value.id,
trainDateList: [targetDate],
});
if (res.code === 0) {
uni.$emit('calendarDataRefresh');
uni.showToast({ title: '添加成功', icon: 'success' });
setTimeout(() => uni.navigateBack(), 800);
} else {
uni.showToast({ title: res.msg || '添加失败', icon: 'none' });
}
} catch (err) {
console.error('模板添加到日程失败:', err);
uni.showToast({ title: '网络异常,请重试', icon: 'none' });
}
};
// ==================== 编辑模板 ====================
function templateEdit() {
const templateEdit = () => {
trainingStore.isSystem = templateDetail.value.isSystem;
trainingStore.loadDailyTemplateForEdit(templateDetail.value);
trainingStore.initDailyTemplateRecords();
uni.navigateTo({
url: `/pages4/pages/xunji/xunji-dongzuo-lianxi?id=${templateDetail.value.id}&type=3&dailyTemplateId=${templateDetail.value.dailyTemplateId}`,
});
}
};
// ==================== 删除操作 ====================
async function handleDeleteTemplate(dailyTemplateId) {
const handleDeleteTemplate = async (dailyTemplateId) => {
if (!dailyTemplateId) {
uni.showToast({ title: '未找到每日模板ID', icon: 'none' });
return;
... ... @@ -420,9 +447,9 @@ async function handleDeleteTemplate(dailyTemplateId) {
console.error('删除失败', err);
uni.showToast({ title: '删除失败,请重试', icon: 'none' });
}
}
};
async function confirmDelete(planId) {
const confirmDelete = async (planId) => {
try {
const res = await QueryPlanApi.deletePlan(planId);
if (res.code === 0 && res.data) {
... ... @@ -436,15 +463,11 @@ async function confirmDelete(planId) {
} finally {
setTimeout(() => {
const pages = getCurrentPages();
const backNum = pages.length - 2;
if (backNum > 0) {
uni.navigateBack({ delta: backNum });
} else {
uni.navigateTo({ url: '/pages4/pages/xunji/xunji-wode-jihua' });
}
const delta = pages.length - 2;
delta > 0 ? uni.navigateBack({ delta }) : uni.navigateTo({ url: '/pages4/pages/xunji/xunji-wode-jihua' });
}, NAVIGATE_DELAY);
}
}
};
// ==================== 生命周期 ====================
onLoad((options) => {
... ... @@ -452,7 +475,9 @@ onLoad((options) => {
isOffice.value = options.isOffice === 'true';
isMyPlan.value = options.isMyPlan === 'true';
isDailytemplateId.value = options.isDailytemplateId === 'true';
isFromTemplateAdd.value = options.from === 'addFromTemplate';
date.value = options.date || '';
selectedAddDate.value = options.date || '';
loadTemplateDetail();
});
... ... @@ -773,6 +798,18 @@ $radius-circle: 50%;
}
}
.add-calendar-btn {
flex: 1;
height: 80rpx;
background-color: $accent;
color: #000;
font-size: $font-md;
font-weight: bold;
border-radius: $radius-round;
border: none;
outline: none;
}
.move-to {
display: flex;
justify-content: center;
... ...
... ... @@ -98,6 +98,7 @@ const templateList = ref({})
const isBigTemOffice = ref(false)
const folderList = ref([])
const templateMenuRef = ref(null)
const selectedDate = ref('') // 从 xunji-rili-tianjia-moban 透传的日期
// ==================== 筛选 ====================
const filteredTemplates = ref([])
... ... @@ -216,9 +217,12 @@ const showTemplateMenu = (item) => {
}
const godetail = (items) => {
uni.navigateTo({
url: `/pages4/pages/xunji/xunji-moban-xiangqing?id=${items.id}&isOffice=true`,
})
let url = `/pages4/pages/xunji/xunji-moban-xiangqing?id=${items.id}&isOffice=true`;
// 只有从添加日程流(带日期参数)进入时,才标记为模板添加模式
if (selectedDate.value) {
url += `&date=${selectedDate.value}&from=addFromTemplate`;
}
uni.navigateTo({ url });
}
const handleRefreshTemplate = () => {
... ... @@ -229,6 +233,9 @@ const handleRefreshTemplate = () => {
onLoad((options) => {
id.value = options.id
isBigTemOffice.value = options.isBigTemOffice === 'true'
if (options.date) {
selectedDate.value = options.date
}
console.log('接收的文件夹列表:', options.foldList)
if (options.foldList) {
... ...
... ... @@ -108,6 +108,7 @@
<script setup>
import { onMounted, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import TemplatesApi from '@/sheep/api/Template/Templates';
// 获取模板列表
... ... @@ -117,6 +118,8 @@ const part = ref(false); // 部位是否选中
const scene = ref(false); // 场景是否选中
const isFiltering = ref(false);
const selectedDate = ref(''); // 从添加入口传过来的日期
const activePartId = ref('0');
const activePart = ref('不限');
const activeSceneId = ref('0');
... ... @@ -255,16 +258,21 @@ const goBack = () => {
const lostImage = "https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260316/order-empty_1773628059920.png";
// 个人模板点击跳转(直接进详情)
const goToPersonalDetail = (item) => {
uni.navigateTo({
url: `/pages4/pages/xunji/xunji-moban-xiangqing?id=${item.id}`
});
// 一旦进入过此页(来自 add-train-popup 入口),所有个人模板点击都视为模板添加流
let url = `/pages4/pages/xunji/xunji-moban-xiangqing?id=${item.id}&from=addFromTemplate`;
if (selectedDate.value) {
url += `&date=${selectedDate.value}`;
}
uni.navigateTo({ url });
};
// 跳转到模板详情页
const goToTemplate = (item) => {
uni.navigateTo({
url: `/pages4/pages/xunji/xunji-moban?id=${item.id}&isBigTemOffice=true`
});
let url = `/pages4/pages/xunji/xunji-moban?id=${item.id}&isBigTemOffice=true`;
if (selectedDate.value) {
url += `&date=${selectedDate.value}`;
}
uni.navigateTo({ url });
};
// 获取胶囊按钮信息,用于动态适配导航栏高度
... ... @@ -273,6 +281,13 @@ const menuButtonInfo = ref({
height: 32
});
// 接收从 add-train-popup 透传的日期
onLoad((options) => {
if (options?.date) {
selectedDate.value = options.date;
}
});
onMounted(() => {
getTemplatesList();
getPartCategories();
... ...
... ... @@ -327,18 +327,16 @@ const selectScene = (item) => {
activeScene.value = item.title;
activeSceneId.value = item.id;
showSceneDropdown.value = false;
console.log(activeSceneId.value,"======================");
doFilter(); // 筛选
};
const doFilter = async () => {
const muscleId = activePartId.value;
const sceneId = activeSceneId.value;
console.log('muscleId=', muscleId);
console.log('sceneId', sceneId);
try {
const res = await TemplatesApi.queryCustTemplate(muscleId, sceneId);
const res = await TemplatesApi.queryCustTemplate(null,activePartId.value, activeSceneId.value);
console.log('筛选之后的自定义模板列表:', res);
templateList.value = res.data || [];
... ...
<template>
<view class="container">
<view class="safe-area-top" :style="{ paddingTop: topSafeArea + 'px' }" @click="goBack">
<uni-nav-bar left-icon="left" backgroundColor="transparent" :border="false" fixed dark />
<!-- 1. 自定义悬浮顶部导航栏(带安全区) -->
<view class="header-nav" :style="{ paddingTop: topSafeArea + 'px' }">
<view class="nav-back-btn" @click="goBack">
<uni-icons type="left" size="20" color="#ffffff" />
</view>
<!-- 背景图片 -->
<view class="backgroundImage">
<image
:src="
backgroundImage
? backgroundImage
: 'https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260316/order-empty_1773628059920.png'
"
class="img"
mode="aspectFill"
></image>
</view>
<!-- 内容 -->
<view class="content">
<view class="info">
<view class="left">
<view class="avatar">
<image
:src="
avatar
? avatar
: 'https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260526/默认头像_1779779926983.png'
"
class="img"
mode="aspectFill"
></image>
<!-- 2. 顶部背景大图 -->
<view class="banner-wrapper">
<image :src="backgroundImage || DEFAULT_BG_IMAGE" class="banner-img" mode="aspectFill"></image>
<view class="banner-mask"></view>
</view>
<view class="user-info">
<view class="nickname">{{ nickname }}</view>
<view class="user-tip"> 运动爱好者 </view>
<!-- 3. 主体内容区域(自然滚动流) -->
<view class="main-card">
<!-- 个人信息头部 -->
<view class="profile-header">
<view class="avatar-box">
<image :src="avatar || DEFAULT_AVATAR" class="avatar-img" mode="aspectFill"></image>
</view>
<view class="user-meta">
<text class="nickname">{{ nickname || '运动达人' }}</text>
<view class="badge-tag">
<uni-icons type="fire-filled" size="12" color="#ff6600" />
<text class="badge-text">运动爱好者</text>
</view>
<view class="right">
<view class="edit-btn" @click="openPopup">编辑</view>
</view>
<view class="edit-btn" @click="openPopup">
<uni-icons type="compose" size="14" color="#ffffff" />
<text class="btn-text">编辑</text>
</view>
<view class="signature" @click="goToEditSignature">
{{ signature ? signature : '点击这里,填写签名' }}
</view>
<view class="medal" v-if="medalList.length > 0">
<view class="title"> 勋章 </view>
<view class="medal-list">
<view class="medal-item" v-for="(item, index) in medalList" :key="index">
<image :src="item.image" class="img" mode="aspectFill"></image>
<!-- 个性签名 -->
<view class="signature-wrapper" @click="goToEditSignature">
<text class="signature-text">{{ signature || '点击这里,填写你的专属签名...' }}</text>
<uni-icons type="right" size="12" color="#c0c4cc" />
</view>
<uni-icons
type="right"
size="24"
color="#999"
@click="uni.navigateTo({ url: '/pages5/pages/user/wode-xunzhang' })"
></uni-icons>
<!-- 核心运动数据统计看板 -->
<view class="stats-card">
<view class="stat-item">
<view class="stat-num-box">
<text class="stat-num">{{ exerciseMinuteCount || 0 }}</text>
<text class="stat-unit">min</text>
</view>
<text class="stat-label">运动时长</text>
</view>
<view class="trajectory">
<view class="header">
<view class="title"> 运动轨迹 </view>
<view class="data">
在鸿星运动<text class="time">{{ exerciseMinuteCount }}</text
>min,消耗<text class="energy">{{ calorieCount }}</text
>kcal
</view>
<!-- 运动轨迹打卡阵列 -->
<view class="trajectory-section">
<view class="section-header">
<text class="section-title">运动轨迹</text>
<text class="section-sub">记录坚持的每一个脚印</text>
</view>
<view class="graph">
<!-- 点阵区域(基于motionMatrix渲染) -->
<view class="track-grid">
<!-- 星期标签(固定“一、三、五、日”) -->
<view class="week-labels">
<text
class="week-item"
v-for="(week, idx) in ['一', '二', '三', '四', '五', '六', '日']"
:key="idx"
>
{{ week !== '二' && week !== '六' && week !== '四' ? week : '' }}
<view class="track-grid-container">
<!-- 星期侧栏 -->
<view class="week-column">
<text class="week-label" v-for="(week, idx) in WEEK_LABELS" :key="idx">
{{ VISIBLE_WEEK_DAYS.includes(week) ? week : '' }}
</text>
</view>
<view class="month-dots">
<!-- 月份标签(改用后端返回的motionXAxis) -->
<view class="month-tabs">
<text class="month-item" v-for="(month, idx) in motionXAxis" :key="idx">
<!-- 右侧点阵与月份标题 -->
<view class="dots-area">
<!-- 月份横轴 -->
<view class="month-row">
<text class="month-label" v-for="(month, idx) in motionXAxis" :key="idx">
{{ month }}
</text>
</view>
<!-- 点阵(遍历motionMatrix渲染,false为灰色,可扩展true为彩色) -->
<view class="grid-dots">
<!-- 7行打卡点阵 -->
<view class="grid-matrix">
<view class="dot-row" v-for="(row, rowIdx) in motionMatrix" :key="rowIdx">
<view
class="dot"
v-for="(isActive, colIdx) in row"
:key="colIdx"
:class="{ active: isActive }"
></view>
<view class="dot-item" v-for="(isActive, colIdx) in row" :key="colIdx" :class="{ active: isActive }">
</view>
</view>
</view>
... ... @@ -102,319 +86,438 @@
</view>
</view>
</view>
<!-- 弹框 -->
<uni-popup ref="popup" background-color="#fff" type="bottom">
<view class="action-popup">
<view class="popup-item" @click="goToEditProfile">编辑资料</view>
<view class="popup-item" @click="handleChangeBg">更换背景图</view>
<view class="popup-item cancel" @click="handleCancel">取消</view>
<!-- 底部操作弹窗 -->
<uni-popup ref="popup" background-color="transparent" type="bottom">
<view class="action-sheet">
<view class="sheet-group">
<view class="sheet-item" @click="goToEditProfile">编辑资料</view>
<view class="sheet-item" @click="handleChangeBg">更换背景图</view>
</view>
<view class="sheet-group cancel-group">
<view class="sheet-item cancel-item" @click="handleCancel">取消</view>
</view>
</view>
</uni-popup>
</view>
</template>
<script setup>
import { ref } from 'vue';
import UserApi from '@/sheep/api/member/user.js';
import { onShow } from '@dcloudio/uni-app';
import { getTopSafeArea } from '@/utils/safeArea';
import { ref } from 'vue';
import UserApi from '@/sheep/api/member/user.js';
import FileApi from '@/sheep/api/infra/file.js';
import { onShow } from '@dcloudio/uni-app';
import { getTopSafeArea } from '@/utils/safeArea';
let topSafeArea = 0;
// #ifdef MP-WEIXIN
topSafeArea = getTopSafeArea();
// #endif
const popup = ref(null); // 定义弹窗引用
// ==================== 常量定义 ====================
const DEFAULT_AVATAR =
'https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260526/默认头像_1779779926983.png';
const DEFAULT_BG_IMAGE =
'https://fitness-hcxtec-bucket.oss-cn-shenzhen.aliyuncs.com/20260724/健身模板_1784871730506.png';
const WEEK_LABELS = ['一', '二', '三', '四', '五', '六', '日'];
const VISIBLE_WEEK_DAYS = ['一', '三', '五', '日']; // 运动轨迹仅显示一三五日
const backgroundImage = ref(''); // 背景图片
let topSafeArea = 0;
// #ifdef MP-WEIXIN
topSafeArea = getTopSafeArea();
// #endif
const nickname = ref(''); // 昵称
// ==================== 响应式状态 ====================
const popup = ref(null);
const backgroundImage = ref('');
const nickname = ref('');
const avatar = ref('');
const signature = ref('');
const exerciseMinuteCount = ref(0);
const calorieCount = ref(0);
const motionXAxis = ref([]);
const motionMatrix = ref([]);
const medalList = ref([]);
const avatar = ref(''); // 头像
const signature = ref(''); // 签名
const exerciseMinuteCount = ref(0); // 运动分钟数
const calorieCount = ref(0); // 消耗卡路里
const motionXAxis = ref([]); // 运动轨迹X轴
const motionMatrix = ref([]); // 运动轨迹矩阵
const medalList = ref([]); // 奖牌列表
onShow(() => {
getMotionData();
});
// 返回上一页
const goBack = () => {
// ==================== 生命周期 ====================
onShow(() => {
fetchUserProfile();
});
// ==================== 数据获取 ====================
const fetchUserProfile = async () => {
try {
const { data } = await UserApi.getTrajectory();
nickname.value = data.nickname;
avatar.value = data.avatar;
signature.value = data.signature;
exerciseMinuteCount.value = data.exerciseMinuteCount;
calorieCount.value = data.calorieCount;
motionXAxis.value = data.motionXAxis || [];
motionMatrix.value = data.motionMatrix || [];
backgroundImage.value = data.backgroundImage;
medalList.value = data.medalList || [];
} catch (err) {
uni.showToast({ title: '数据加载失败', icon: 'none' });
}
};
// ==================== 页面导航 ====================
const goBack = () => {
const pages = getCurrentPages();
if (pages.length > 1) {
uni.navigateBack();
};
// 打开弹窗
const openPopup = () => {
popup.value.open();
};
// 关闭弹窗
const handleCancel = () => {
} else {
uni.reSwitch({ url: '/pages/index/index' });
}
};
const goToEditProfile = () => {
popup.value.close();
};
// 获取运动轨迹数据
const getMotionData = async () => {
const res = await UserApi.getTrajectory();
nickname.value = res.data.nickname;
avatar.value = res.data.avatar;
signature.value = res.data.signature;
exerciseMinuteCount.value = res.data.exerciseMinuteCount;
calorieCount.value = res.data.calorieCount;
motionXAxis.value = res.data.motionXAxis;
motionMatrix.value = res.data.motionMatrix;
backgroundImage.value = res.data.backgroundImage;
medalList.value = res.data.medalList || [];
};
// 更换背景图
const handleChangeBg = () => {
uni.navigateTo({ url: '/pages5/pages/user/wode-geren-ziliao' });
};
const goToEditSignature = () => {
uni.navigateTo({
url: `/pages4/pages/xunji/xunji-wode-qianming?signature=${encodeURIComponent(signature.value || '')}`,
});
};
// ==================== 弹窗控制 ====================
const openPopup = () => {
popup.value.open();
};
const handleCancel = () => {
popup.value.close();
// 调用选择图片接口
};
// ==================== 图片更换交互 ====================
const handleChangeBg = async () => {
let tempFilePath = '';
try {
// #ifdef MP-WEIXIN
const wxRes = await new Promise((resolve, reject) => {
uni.chooseMedia({
count: 1, // 仅选1张背景图
mediaType: ['image'], // 只选图片
success: (res) => {
// 选择成功,res.tempFiles是选中的文件数组
UserApi.updateBgImage({
data: res.tempFiles[0].tempFilePath,
}).then(() => {
getMotionData();
count: 1,
mediaType: ['image'],
success: resolve,
fail: reject,
});
},
fail: (err) => {
// 选择失败(如用户取消、权限不足)
uni.showToast({
title: '选择图片失败',
icon: 'none',
});
},
tempFilePath = wxRes.tempFiles[0].tempFilePath;
// #endif
// #ifndef MP-WEIXIN
const h5Res = await new Promise((resolve, reject) => {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: resolve,
fail: reject,
});
});
};
//
const goToEditProfile = () => {
tempFilePath = h5Res.tempFilePaths[0];
// #endif
popup.value.close();
uni.navigateTo({ url: '/pages5/pages/user/wode-geren-ziliao' });
};
// 跳转编辑签名
const goToEditSignature = () => {
uni.navigateTo({ url: '/pages4/pages/xunji/xunji-wode-qianming?signature=' + signature.value });
};
uni.showLoading({ title: '上传中...' });
const uploadRes = await FileApi.uploadFile(tempFilePath);
uni.hideLoading();
if (!uploadRes || !uploadRes.data) {
uni.showToast({ title: '上传失败', icon: 'none' });
return;
}
await UserApi.updateBgImage({ data: uploadRes.data });
await fetchUserProfile();
uni.showToast({ title: '背景更换成功', icon: 'success' });
} catch (err) {
popup.value.close();
uni.hideLoading();
if (err?.errMsg?.includes('cancel')) return;
uni.showToast({ title: '更换背景失败', icon: 'none' });
}
};
</script>
<style lang="scss" scoped>
.container {
width: 100%;
height: 100%;
.container {
min-height: 100vh;
background-color: #f7f8fa;
position: relative;
}
.backgroundImage {
width: 100%;
height: 350rpx;
position: absolute;
/* 顶部固定导航按钮 */
.header-nav {
position: fixed;
top: 0;
background-color: bisque;
.img {
left: 0;
right: 0;
z-index: 100;
padding-left: 24rpx;
.nav-back-btn {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
margin-top: 12rpx;
}
}
/* 顶部背景图容器 */
.banner-wrapper {
width: 100%;
height: 420rpx;
position: relative;
.banner-img {
width: 100%;
height: 100%;
}
}
.content {
.banner-mask {
position: absolute;
top: 320rpx;
border-top-left-radius: 15px;
border-top-right-radius: 15px;
padding: 0 20rpx;
background-color: #fff;
width: 100%;
box-sizing: border-box;
height: calc(100% - 320rpx);
.info {
display: flex;
justify-content: space-between;
align-items: center;
bottom: 0;
left: 0;
right: 0;
height: 120rpx;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.3));
}
}
/* 主体内容卡片(自然流向上覆盖) */
.main-card {
position: relative;
margin-bottom: 130rpx;
.left {
height: 140rpx;
margin-top: -60rpx;
background-color: #ffffff;
border-top-left-radius: 36rpx;
border-top-right-radius: 36rpx;
padding: 32rpx 28rpx 60rpx;
box-shadow: 0 -8rpx 24rpx rgba(0, 0, 0, 0.04);
/* 个人信息 */
.profile-header {
display: flex;
box-sizing: border-box;
align-items: center;
position: absolute;
top: -20rpx;
align-items: flex-end;
margin-bottom: 24rpx;
.avatar {
width: 140rpx;
height: 140rpx;
.avatar-box {
width: 130rpx;
height: 130rpx;
border-radius: 50%;
border: 6rpx solid #ffffff;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
margin-top: -70rpx;
margin-right: 20rpx;
flex-shrink: 0;
overflow: hidden;
background: #fff;
padding: 8rpx;
box-sizing: border-box;
margin-right: 15rpx;
.img {
background-color: #ffffff;
.avatar-img {
width: 100%;
height: 100%;
border-radius: 50%;
}
}
.user-info {
.user-meta {
flex: 1;
.nickname {
font-size: 28rpx;
font-weight: 500;
color: #333333;
font-size: 32rpx;
font-weight: 700;
color: #1d2129;
line-height: 44rpx;
display: block;
margin-bottom: 8rpx;
}
.user-tip {
background-color: #f7c29f;
padding: 5rpx 8rpx;
border-radius: 5rpx;
.badge-tag {
display: inline-flex;
align-items: center;
background-color: #fff0e6;
padding: 4rpx 12rpx;
border-radius: 20rpx;
.badge-text {
font-size: 20rpx;
font-weight: 400;
color: #a15624;
font-weight: 600;
color: #ff6600;
margin-left: 4rpx;
}
}
}
.right {
height: 140rpx;
.edit-btn {
display: flex;
box-sizing: border-box;
align-items: center;
position: absolute;
top: -20rpx;
right: 0;
.edit-btn {
background-color: #e68f55;
padding: 10rpx 20rpx;
border-radius: 5rpx;
background: linear-gradient(135deg, #ff8533 0%, #ff6600 100%);
padding: 12rpx 24rpx;
border-radius: 30rpx;
box-shadow: 0 4rpx 12rpx rgba(255, 102, 0, 0.25);
.btn-text {
font-size: 24rpx;
font-weight: 400;
color: #fff;
}
color: #ffffff;
font-weight: 500;
margin-left: 4rpx;
}
}
.medal {
width: 100%;
margin-bottom: 40rpx;
.title {
font-size: 30rpx;
font-weight: 550;
color: #333333;
margin-bottom: 20rpx;
}
.medal-list {
/* 签名框 */
.signature-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
justify-content: space-between;
background-color: #f7f8fa;
padding: 16rpx 20rpx;
border-radius: 12rpx;
margin-bottom: 32rpx;
.medal-item {
width: 90%;
gap: 30rpx;
.signature-text {
font-size: 24rpx;
color: #86909c;
flex: 1;
margin-right: 12rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
background: #fff;
padding: 8rpx;
box-sizing: border-box;
/* 运动统计看板 */
.stats-card {
display: flex;
align-items: center;
.img {
width: 100rpx;
height: 100rpx;
border-radius: 50%;
background: linear-gradient(135deg, #2b303d 0%, #1d2129 100%);
border-radius: 20rpx;
padding: 32rpx 24rpx;
margin-bottom: 40rpx;
box-shadow: 0 8rpx 20rpx rgba(29, 33, 41, 0.12);
.stat-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
.stat-num-box {
display: flex;
align-items: baseline;
.stat-num {
font-size: 44rpx;
font-weight: 800;
color: #ffffff;
font-family: 'DIN Alternate', sans-serif;
}
.stat-unit {
font-size: 22rpx;
color: #ff9e59;
margin-left: 6rpx;
font-weight: 600;
}
}
.stat-label {
font-size: 22rpx;
color: #a4a9b3;
margin-top: 6rpx;
}
.signature {
font-size: 20rpx;
font-weight: 400;
color: #999999;
margin-top: 20rpx;
margin-bottom: 80rpx;
}
.trajectory {
.header {
display: flex;
justify-content: space-between;
align-items: center;
.title {
font-size: 28rpx;
font-weight: 500;
color: #333333;
.stat-divider {
width: 2rpx;
height: 48rpx;
background-color: rgba(255, 255, 255, 0.15);
}
.data {
font-size: 20rpx;
font-weight: 400;
color: #999999;
.time,
.energy {
margin: 0 5rpx;
font-size: 24rpx;
font-weight: 500;
color: rgb(84, 198, 233);
}
/* 运动轨迹区块 */
.trajectory-section {
background-color: #ffffff;
.section-header {
margin-bottom: 24rpx;
.section-title {
font-size: 30rpx;
font-weight: 700;
color: #1d2129;
margin-right: 12rpx;
}
.section-sub {
font-size: 22rpx;
color: #86909c;
}
}
.graph {
padding: 10rpx 5rpx;
// 星期标签和点阵容器
.track-grid {
/* 点阵网格系统 */
.track-grid-container {
display: flex;
align-items: flex-start;
padding: 0 8rpx;
.week-labels {
background-color: #fafafa;
border-radius: 16rpx;
padding: 20rpx 16rpx;
border: 1rpx solid #f2f3f5;
/* 星期标签列 */
.week-column {
display: flex;
flex-direction: column;
margin-right: 18rpx;
padding-top: 48rpx;
.week-item {
font-size: 20rpx;
color: #999999;
height: 40rpx;
width: 20rpx;
justify-content: space-between;
margin-right: 16rpx;
padding-top: 36rpx; // 避开月份行的高度
height: 180rpx; // 匹配 7 行点阵的高
.week-label {
font-size: 18rpx;
color: #c0c4cc;
height: 20rpx;
line-height: 20rpx;
text-align: center;
}
}
.month-dots {
// 月份标签样式
.month-tabs {
/* 点阵与月份区 */
.dots-area {
flex: 1;
.month-row {
display: flex;
justify-content: space-between;
margin-bottom: 12rpx;
margin-bottom: 20rpx;
.month-item {
width: calc(100% / 6);
font-size: 22rpx;
color: #999999;
text-align: start;
line-height: 30rpx;
height: 30rpx;
}
}
.month-label {
font-size: 20rpx;
color: #86909c;
}
}
// 点阵样式
.grid-dots {
flex: 1;
.grid-matrix {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8rpx;
.dot-row {
display: flex;
margin-bottom: 11rpx;
align-items: center;
height: 30rpx;
.dot {
// 原无运动样式
width: 15rpx;
height: 15rpx;
border-radius: 50%;
background: #f8f8f8; // 无运动:浅灰
margin-right: 11rpx;
border: 1rpx solid #e5e5e5;
box-sizing: border-box;
justify-content: space-between;
.dot-item {
width: 18rpx;
height: 18rpx;
border-radius: 4rpx;
background-color: #ebedf0;
transition: all 0.2s ease;
// 有运动样式(可自定义颜色)
&.active {
background: #409eff; // 有运动:蓝色(示例)
border: none;
background-color: #ff6600;
box-shadow: 0 2rpx 8rpx rgba(255, 102, 0, 0.35);
}
}
}
... ... @@ -422,25 +525,42 @@
}
}
}
// 底部操作弹窗
.action-popup {
background: #fff;
border-top-left-radius: 24rpx;
border-top-right-radius: 24rpx;
padding: 24rpx 0;
}
.popup-item {
font-size: 32rpx;
color: #333;
/* 底部操作弹窗 ActionSheet */
.action-sheet {
padding: 0 20rpx 40rpx;
.sheet-group {
background-color: #ffffff;
border-radius: 24rpx;
overflow: hidden;
margin-bottom: 16rpx;
.sheet-item {
font-size: 30rpx;
color: #1d2129;
text-align: center;
padding: 32rpx 0;
border-bottom: 1px solid #f5f5f5;
padding: 30rpx 0;
border-bottom: 1rpx solid #f2f3f5;
&.cancel {
color: #ff4d4f;
&:last-child {
border-bottom: none;
}
&:active {
background-color: #f7f8fa;
}
}
&.cancel-group {
margin-bottom: 0;
.cancel-item {
color: #ff4d4f;
font-weight: 600;
}
}
}
}
</style>
\ No newline at end of file
... ...
... ... @@ -4,17 +4,31 @@
<uni-nav-bar :title="plandetail.name" left-icon="left" @click-left="goBack" :fixed="true" :status-bar="true" />
<!-- Banner 区域 -->
<view class="banner-section">
<image :src="plandetail.urlCover" class="banner-img">
<image :src="plandetail.urlCover" class="banner-img" />
<!-- 收藏 -->
<uni-icons :type="isFavorite ? 'heart-filled' : 'heart'" size="24" color="#fff" class="collect-btn"
@click="toggleAddPlan">
</uni-icons>
<!-- 分享按钮 -->
<button class="share-btn" open-type="share">
<uni-icons type="paperplane" size="24" color="#fff"></uni-icons>
<view
class="banner-btn collect-btn"
:class="{ 'is-fav': isFavorite }"
@click="toggleAddPlan"
>
<up-icon
:name="isFavorite ? 'heart-fill' : 'heart'"
size="22"
:color="isFavorite ? '#ffd700' : '#fff'"
/>
</view>
<!-- 分享按钮:微信端用 open-type="share",H5 端用普通按钮 -->
<!-- #ifdef MP-WEIXIN -->
<button class="banner-btn share-btn" open-type="share">
<up-icon name="share" size="22" color="#fff" />
</button>
</image>
<!-- <view class="hot-tag">火爆</view> -->
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<view class="banner-btn share-btn" @click="handleShare">
<up-icon name="share" size="22" color="#fff" />
</view>
<!-- #endif -->
</view>
<!-- 计划信息卡片 -->
<view class="info-cards">
... ... @@ -45,40 +59,44 @@
<!-- 器械标签 -->
<view class="equipment-tags">
<up-icon name="file-text" color="#999" size="25"></up-icon>
<view class="equipment-label">
<up-icon name="tags" color="#999" size="22"></up-icon>
<text>器械</text>
<view v-for="items in plandetail.equipmentsSummaryNames" :key="items">
<view class="tag-item">
<text>{{ items }}</text>
</view>
<view class="tag-item" v-for="items in plandetail.equipmentsSummaryNames" :key="items">
<text>{{ items }}</text>
</view>
</view>
<!-- ==========新增:未来7天训练日日历模块 start========== -->
<!-- 未来7天训练日日历模块 -->
<view class="calendar-section" v-if="isMyPlan === true">
<view class="cal-header">
<view class="cal-title-row">
<up-icon name="calendar" size="22" color="#e9ee50" />
<text class="cal-title">训练日</text>
<button class="cal-set-btn" @click="openArrageClass">排课设置</button>
</view>
<!-- 星期日期头部 -->
<view class="cal-week-row">
<view v-for="(item, idx) in calWeekList" :key="idx" class="cal-day-item">
<text class="day-top">{{ item.topText }}</text>
<text class="day-num">{{ item.num }}</text>
<view class="cal-set-btn" @click="openArrageClass">排课设置</view>
</view>
<!-- 7天日历 -->
<view class="cal-grid">
<view v-for="(item, idx) in calWeekList" :key="idx" class="cal-col" :class="{ 'is-today': idx === 0 }">
<!-- 星期 + 日期 -->
<view class="cal-date">
<text class="day-week">{{ item.topText }}</text>
<text class="day-num">{{ item.num }}</text>
</view>
<!-- 下方课程/休息 -->
<view class="cal-content-row">
<view v-for="(item, idx) in calWeekList" :key="idx" class="cal-day-item">
<!-- 有训练就展示黄色课程,没有就是休 -->
<view v-if="item.trainName" class="train-tag" @click="openTemplateDatil(item.dailytemplateId, item.date)">
<!-- 课程/休息标签 -->
<view
v-if="item.trainName"
class="train-tag"
@click="openTemplateDatil(item.dailytemplateId, item.date)"
>
<text class="tag-text">{{ item.trainName }}</text>
</view>
<view v-else class="rest-tag">休</view>
</view>
</view>
</view>
<!-- ==========新增:未来7天训练日日历模块 end========== -->
<!-- 计划课程(模板) -->
<view class="course-section">
... ... @@ -95,20 +113,17 @@
<text class="meta-item">{{ item.totalSets }} 组</text>
<text class="meta-item">{{ item.totalWeight }}kg</text>
</view>
<uni-icons uni-icons type="right" size="10" color="#fff"></uni-icons>
<up-icon name="arrow-right" size="10" color="#fff"></up-icon>
</view>
<text v-for="(items, index) in item.primaryMuscleNames" :key="index">
<text class="course-parts">{{ items }}</text>
</text>
<view class="course-actions">
<button type="default" size="mini" class="btn"
style="background-color: #333; color: white; border-radius: 20rpx"
<button type="default" size="mini" class="btn btn-dark"
@click.stop="openCalendarPopup(plandetail.id, item.id)">添加到日历</button>
<button type="primary" size="mini" class="btn"
style="background-color: #ffd700; color: black; border-radius: 20rpx"
@click.stop="startTraining(item.id)">
<button type="primary" size="mini" class="btn btn-gold" @click.stop="startTraining(item.id)">
<text>去训练</text>
<view class="go-text">GO</view>
</button>
... ... @@ -128,38 +143,32 @@
<view class="button-group">
<template v-if="!isPlanAdded && !isMyPlan">
<!-- 官方计划的底部按钮(未添加) -->
<button type="default" @click="openTrainingPopup" class="btn" size="large"
style="background-color: white; flex: 1">
<button type="default" @click="openTrainingPopup" class="btn end" size="large">
单节课程训练
</button>
<button type="default" @click="openChooseWeekPopup" class="btn" size="large"
style="background-color: #ffd700; flex: 1.5">
<button type="default" @click="openChooseWeekPopup" class="btn ones" size="large">
加入计划
</button>
</template>
<!-- 官方计划已添加 -->
<template v-else-if="isPlanAdded && !isMyPlan">
<button type="default" @click.stop="confirmDelete(plandetail.id)" class="btn end" size="large"
style="background-color: white; flex: 1">
<button type="default" @click.stop="confirmDelete(plandetail.id)" class="btn end" size="large">
结束计划
</button>
<button type="default" @click="openTrainingPopup" class="btn ones" size="large"
style="background-color: #ffd700; flex: 1.5">
<button type="default" @click="openTrainingPopup" class="btn ones" size="large">
马上开练
</button>
</template>
<!-- 个人计划 -->
<template v-else>
<button type="default" class="btn end" @click="openTrainingPopup" size="large"
style="background-color: white; flex: 1">
<button type="default" class="btn end" @click="openTrainingPopup" size="large">
马上开练
</button>
<button type="default" @click="openArrageClass" class="btn ones" size="large"
style="background-color: #ffd700; flex: 1.5">
<button type="default" @click="openArrageClass" class="btn ones" size="large">
更改排课
</button>
<view class="Icon" @click="showMorePopup = true">
<uni-icons type="more-filled" size="22" color="#fff" />
<up-icon name="more-dot-fill" size="22" color="#fff" />
更多
</view>
<!-- 更多弹窗(原生 view 实现,精准定位) -->
... ... @@ -167,12 +176,12 @@
<!-- 弹窗本体:精准定位在「更多」按钮上方 -->
<view class="more-popup" @click.stop>
<view class="more-item" @click="restartPlan">
<uni-icons type="refresh" size="22" color="#fff"></uni-icons>
<up-icon name="reload" size="22" color="#fff"></up-icon>
<text class="item-text">重新开始计划</text>
</view>
<!-- 注意这里结束整个计划,用的每日模板的删除个人计划接口,传递的是官方计划planId -->
<view class="more-item" @click.stop="confirmDelete(plandetail.planId)">
<uni-icons type="close" size="22" color="#fff"></uni-icons>
<up-icon name="close" size="22" color="#fff"></up-icon>
<text class="item-text">结束整个计划</text>
</view>
</view>
... ... @@ -180,11 +189,11 @@
</template>
</view>
<!-- 加入计划/重新开始计划的弹窗 -->
<WeekSelectPopup v-model:visible="showWeekPopup" :max-count="plandetail.frequencyPerWeek" :plan-detail="plandetail"
<WeekSelectPopup v-model:visible="showWeekPopup" :max-count="plandetail.frequencyPerWeek || 7" :plan-detail="plandetail"
@success="loadDetailUpdate" :is-update="isUpdate" />
<!-- 添加到日历弹窗子组件 -->
<AddToCalendarPopup ref="calendarPopupRef" :plan-id="plandetail.id" :template-id="currentTemplateId"
<AddToCalendarPopup ref="calendarPopupRef" :plan-id="plandetail.id || ''" :template-id="currentTemplateId"
@success="handleCalendarSuccess" />
<!-- 去训练弹窗,显示当前计划的所有模板,可以直接跳转到训练页面,区分个人计划和官方计划(传递的是官方的计划id) -->
... ... @@ -197,23 +206,18 @@
</template>
<script setup>
import { onMounted, ref } from 'vue';
import { getBottomSafeArea } from '@/utils/safeArea.js';
import { onMounted, onUnmounted, ref } from 'vue';
import QueryPlanApi from '@/sheep/api/plan/queryplan';
import { onLoad, onShareAppMessage } from '@dcloudio/uni-app';
import AddToCalendarPopup from '@/pages/xunji/components/tianjia-dao-rili.vue';
import WodeJihuaTianjiaTancuang from '@/pages/xunji/components/wode-jihua-tianjia-tancuang.vue'
import WeekSelectPopup from '@/pages4/components/week-select-popup.vue'
import { useTrainingStore } from '@/sheep/store/trainingStore'
import dayjs from 'dayjs';
const trainingStore = useTrainingStore()
// 获取页面参数
const route = defineProps(['planid']); // Vue 3 + uni-app 支持
// 或者使用 getCurrentPages()(兼容性更好)
const pages = getCurrentPages();
const currentPage = pages[pages.length - 1];
const planIdFromUrl = currentPage.options?.planid;
// const plandetail = ref([]);
const plandetail = ref({});
... ... @@ -224,17 +228,8 @@ const difficultyText = { 1: '初阶', 2: '中阶', 3: '高阶' };
const showWeekPopup = ref(false)
// 用户选择的星期:[1,3] 代表周一、周三
// const selectedWeekList = ref([])
// 星期列表(用于弹窗显示)
// 星期列表(匹配例图:周一/二/三 + 下方“休”字)
const weekOptions = [
{ shortName: '一', fullName: '周一', value: 1 },
{ shortName: '二', fullName: '周二', value: 2 },
{ shortName: '三', fullName: '周三', value: 3 },
{ shortName: '四', fullName: '周四', value: 4 },
{ shortName: '五', fullName: '周五', value: 5 },
{ shortName: '六', fullName: '周六', value: 6 },
{ shortName: '日', fullName: '周日', value: 7 },
]
// 星期映射(dayjs day() → 中文星期)
const WEEK_DAY_MAP = ['日', '一', '二', '三', '四', '五', '六'];
const isUpdate = ref(false)
... ... @@ -243,36 +238,24 @@ const calWeekList = ref([])
// 「添加到日历」弹窗实例
const calendarPopupRef = ref(null);
// 当前要添加到日历的课程模板ID
const currentTemplateId = ref(null);
const currentTemplateId = ref('');
const showPlanPopup = ref(false)
// 更多弹窗显隐
const showMorePopup = ref(false)
// 接口weeklySchedule转日历渲染数据(修复版:星期根据真实日期计算,100%对齐num)
/** 接口 weeklySchedule 转日历渲染数据(使用 dayjs 计算星期) */
const formatWeekData = (weekArr) => {
// JS星期:0=日,1=一,2=二,3=三,4=四,5=五,6=六
const weekMap = ['日', '一', '二', '三', '四', '五', '六'];
return weekArr.map((item, index) => {
// 日期数字
const dayNum = item.date[2];
// 核心修复:用真实日期计算星期几,不再用dayIndex!
const [y, m, d] = item.date;
const realDate = new Date(y, m - 1, d);
const realWeekText = weekMap[realDate.getDay()];
// 第一天显示「今」,其他显示真实星期
let topStr = index === 0 ? '今' : realWeekText;
// 训练名称
const trainStr = item.scheduled ? item.templateName : '';
const dateObj = dayjs(`${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`);
const realWeekText = WEEK_DAY_MAP[dateObj.day()];
return {
topText: topStr,
num: String(dayNum),
trainName: trainStr,
topText: index === 0 ? '今' : realWeekText,
num: String(d),
trainName: item.scheduled ? item.templateName : '',
templateId: item.templateId,
dailytemplateId: item.dailytemplateId,
date: item.date,
... ... @@ -303,7 +286,6 @@ const openTrainingPopup = () => {
// 跳转到排课页面
const openArrageClass = () => {
console.log('跳转到排课设置传递的id', plandetail.value.id);
uni.navigateTo({
url: `/pages4/pages/xunji/wode-jihua-paike?planid=${plandetail.value.id}`
})
... ... @@ -329,15 +311,12 @@ const startTraining = (templateId) => {
};
//查看模板详情
/** 查看每日模板详情 */
const openTemplateDatil = (templateId, dateArr) => {
console.log('templateId=', templateId);
console.log('dateArr=', dateArr);
if (!templateId) return
if (!templateId) return;
const [y, m, d] = dateArr;
const dateStr = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
console.log('日期:', dateStr);
const dateStr = dayjs(`${y}-${m}-${d}`).format('YYYY-MM-DD');
uni.navigateTo({
url: `/pages4/pages/xunji/xunji-moban-xiangqing?id=${templateId}&date=${dateStr}&isOffice=false&isMyPlan=true&isDailytemplateId=true`,
});
... ... @@ -410,18 +389,30 @@ const goToTemplateDetail = (id) => {
url: `/pages4/pages/xunji/xunji-moban-xiangqing?id=${id}`
});
};
const handleRightClick = (index) => {
if (index === 0) {
// 收藏功能(保留原提示逻辑)
uni.showToast({ title: '已收藏', icon: 'success' });
} else if (index === 1) {
// 分享功能(保留原 showShareMenu 配置)
uni.showShareMenu({
withCredentials: true,
/** H5 端分享(兼容微信端外浏览器) */
const handleShare = () => {
// #ifdef H5
const url = window.location.href;
const title = plandetail.value?.name || '健身计划分享';
if (navigator.share) {
navigator.share({
title,
url,
}).catch(() => {});
} else {
// 兜底:复制链接
uni.setClipboardData({
data: url,
success: () => {
uni.showToast({ title: '链接已复制', icon: 'success' });
},
});
}
// #endif
};
//获取计划详情
const loadDetail = async () => {
if (isMyPlan.value === true) {
... ... @@ -551,12 +542,14 @@ onLoad((options) => {
});
onMounted(() => {
loadDetail();
uni.$on('calendarDataRefresh', loadDetail);
});
onUnmounted(() => {
uni.$off('calendarDataRefresh', loadDetail);
});
</script>
<style scoped lang="scss">
$base-font: 22rpx;
.fitness-plan-page {
width: 100%;
min-height: 100vh;
... ... @@ -565,36 +558,57 @@ $base-font: 22rpx;
padding-bottom: 70rpx;
}
/* 收藏按钮 */
.collect-btn {
width: 60rpx;
height: 60rpx;
/* Banner 悬浮按钮通用样式 */
.banner-btn {
position: absolute;
display: flex;
top: 20rpx;
right: 90rpx;
z-index: 11;
display: flex;
align-items: center;
justify-content: center;
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(8rpx);
z-index: 11;
transition: transform 0.2s, background 0.2s;
}
.banner-btn:active {
transform: scale(0.88);
}
/* 收藏按钮 */
.collect-btn {
right: 100rpx;
}
.collect-btn.is-fav {
background: rgba(255, 215, 0, 0.15);
border: 2rpx solid rgba(255, 215, 0, 0.5);
animation: favPop 0.35s ease;
}
@keyframes favPop {
0% { transform: scale(1); }
40% { transform: scale(1.25); }
100% { transform: scale(1); }
}
/* 分享按钮 */
.share-btn {
position: absolute;
display: flex;
top: 20rpx;
/* 距离顶部距离,可按需调整 */
right: 20rpx;
/* 距离右侧距离 */
background: transparent;
background: rgba(0, 0, 0, 0.45);
border: none;
padding: 0;
margin: 0;
z-index: 10;
/* 确保按钮可点击区域足够 */
width: 60rpx;
height: 60rpx;
align-items: center;
justify-content: center;
line-height: 1;
box-sizing: border-box;
}
/* 清除 button 默认样式(微信端 open-type=share 的 button) */
.share-btn::after {
border: none;
}
/* Banner 区域 */
... ... @@ -658,211 +672,170 @@ $base-font: 22rpx;
margin-bottom: 10rpx;
}
.info-card .icon-clock,
.info-card .icon-wave,
.info-card .icon-up {
width: 32rpx;
height: 32rpx;
margin: 0 auto 10rpx;
opacity: 0.8;
}
.info-card .desc {
font-size: 24rpx;
color: #999;
}
.number-unit {
display: flex;
align-items: baseline;
justify-content: center;
gap: 4rpx;
margin-bottom: 10rpx;
}
/* 器械标签 */
.equipment-tags {
display: flex;
flex-wrap: wrap;
align-items: center;
padding: 20rpx;
// gap: 6rpx;
gap: 12rpx;
}
.equipment-label {
display: flex;
align-items: center;
gap: 6rpx;
color: #999;
font-size: 26rpx;
margin-right: 4rpx;
}
.tag-item {
padding: 6rpx 12rpx;
border-radius: 4rpx;
font-size: 28rpx;
padding: 6rpx 16rpx;
border-radius: 8rpx;
font-size: 24rpx;
color: #ccc;
background-color: rgba(255, 255, 255, 0.08);
border: 1rpx solid rgba(255, 255, 255, 0.1);
}
// 训练日日历模块样式 1:1还原截图
// ==================== 训练日日历模块 ====================
.calendar-section {
margin: 40rpx 20rpx;
padding: 0;
border-top: 1rpx solid rgba(255, 255, 255, 0.15);
padding-top: 32rpx;
margin: 20rpx 24rpx;
padding: 28rpx 24rpx;
background: rgba(255, 255, 255, 0.04);
border-radius: 16rpx;
border: 1rpx solid rgba(255, 255, 255, 0.06);
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.2);
.cal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32rpx;
margin-bottom: 24rpx;
.cal-title-row {
display: flex;
align-items: center;
gap: 10rpx;
.cal-title {
font-size: 32rpx;
color: #ffffff;
font-weight: 500;
font-size: 30rpx;
color: #fff;
font-weight: 600;
}
}
.cal-set-btn {
color: #fff;
background: transparent;
font-size: 22rpx;
padding: 3rpx 13rpx;
padding: 6rpx 18rpx;
border-radius: 999rpx;
border: 1rpx solid #fff;
border: 1rpx solid rgba(255, 255, 255, 0.3);
line-height: 1.4;
}
}
// 7 列日历网格
.cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 8rpx;
}
/* 顶部 今/六/日 星期+数字 */
.cal-week-row {
.cal-col {
display: flex;
justify-content: space-between;
margin-bottom: 12rpx;
flex-direction: column;
align-items: center;
.cal-day-item {
width: 14.28%;
.cal-date {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 10rpx;
.day-top {
font-size: 30rpx;
color: #cccccc;
.day-week {
font-size: 22rpx;
color: #999;
margin-bottom: 4rpx;
}
.day-num {
font-size: 32rpx;
font-size: 30rpx;
color: #fff;
margin-top: 6rpx;
}
font-weight: 500;
}
}
/* 下方黄色标签/休 */
// .cal-content-row {
// display: flex;
// justify-content: space-between;
// .cal-day-item {
// display: flex;
// height: 80rpx;
// align-items: center;
// justify-content: center;
// width: 12.5%;
// text-align: center;
// // .train-tag {
// // background: #e9ee50;
// // color: #000000;
// // font-size: 22rpx;
// // padding: 10rpx 3rpx;
// // border-radius: 6rpx;
// // line-height: 1.3;
// // }
// // .rest-tag {
// // background: rgba(255, 255, 255, 0.08);
// // color: #999999;
// // font-size: 28rpx;
// // padding: 12rpx 0;
// // border-radius: 6rpx;
// // }
// /* 统一:训练标签 + 休息标签 样式 */
// .train-tag,
// .rest-tag {
// width: 90% !important;
// /* 固定宽度 */
// height: 60rpx !important;
// /* 固定高度 */
// line-height: 60rpx !important;
// /* 文字垂直居中 */
// font-size: 22rpx !important;
// /* 固定文字大小 */
// border-radius: 6rpx !important;
// display: flex !important;
// align-items: center !important;
// justify-content: center !important;
// box-sizing: border-box !important;
// overflow: hidden;
// /* 文字超长隐藏,不撑开盒子 */
// white-space: nowrap;
// /* 不换行 */
// }
// .train-tag {
// background: #e9ee50 !important;
// color: #000000 !important;
// }
// /* 休息标签颜色 */
// .rest-tag {
// background: rgba(255, 255, 255, 0.08) !important;
// color: #999999 !important;
// }
// }
// }
/* 下方黄色标签/休整行 */
.cal-content-row {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 0rpx;
/* 单日容器:7等分宽度,垂直居中放标签 */
.cal-day-item {
width: 14.28% !important;
/* 100/7=14.28%,精准7列均分,代替12.5%,之前宽度错导致不齐!!重点 */
display: flex;
justify-content: center;
align-items: center;
min-height: 72rpx;
/* 固定容器高度 */
padding: 0 4rpx;
// 今日高亮
&.is-today {
.day-week {
color: #e9ee50;
font-weight: 600;
}
.day-num {
color: #e9ee50;
font-weight: 700;
}
}
}
/* 两个标签统一尺寸 */
// 训练 / 休息标签统一尺寸
.train-tag,
.rest-tag {
width: 100%;
height: 62rpx;
border-radius: 6rpx;
height: 58rpx;
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
font-size: 20rpx;
box-sizing: border-box;
line-height: 1.2;
}
.train-tag {
background: #e9ee50;
color: #000;
font-weight: 600;
box-shadow: 0 2rpx 6rpx rgba(233, 238, 80, 0.25);
&:active {
transform: scale(0.94);
transition: transform 0.12s;
}
.tag-text {
width: 100%;
font-size: 22rpx;
font-size: 20rpx;
color: #000;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
padding: 0 4rpx;
}
.train-tag {
background: #e9ee50;
color: #000;
}
.rest-tag {
background: rgba(255, 255, 255, 0.08);
color: #999;
}
}
background: rgba(255, 255, 255, 0.05);
color: #666;
font-size: 20rpx;
}
padding-bottom:32rpx;
border-bottom:1rpx solid rgba(255, 255, 255, 0.15);
}
/* 计划课程 */
... ... @@ -941,7 +914,6 @@ $base-font: 22rpx;
margin-top: 10rpx;
.btn {
margin: 0;
font-size: 22rpx;
... ... @@ -956,17 +928,27 @@ $base-font: 22rpx;
margin-left: 8rpx;
}
}
}
.arrow-right {
width: 24rpx;
height: 24rpx;
opacity: 0.7;
transition: opacity 0.3s ease;
}
.btn-dark {
background-color: #333;
color: white;
border-radius: 20rpx;
margin: 0;
font-size: 22rpx;
}
.btn-gold {
background-color: #ffd700;
color: black;
border-radius: 20rpx;
margin: 0;
font-size: 22rpx;
.course-card:hover .arrow-right {
opacity: 1;
.go-text {
background-color: #121212;
color: #ffd700;
}
}
}
/* 按钮区域 */
... ... @@ -993,6 +975,16 @@ $base-font: 22rpx;
text-align: center;
}
.end {
background-color: white;
flex: 1;
}
.ones {
background-color: #ffd700;
flex: 1.5;
}
.Icon {
display: flex;
flex-direction: column;
... ... @@ -1044,174 +1036,9 @@ $base-font: 22rpx;
width: 100%;
padding: 20rpx;
box-sizing: border-box;
background-color: #121212; // 匹配原图深色背景
background-color: #121212;
color: #fff;
font-size: $base-font;
font-size: 24rpx;
padding-bottom: 100rpx;
}
// 标题样式
.plan-title {
width: 100%;
text-align: start;
font-size: 32rpx;
font-weight: bold;
color: #fff;
}
// 计划描述
.plan-desc {
margin-bottom: 5vw;
opacity: 0.9;
}
// 分块区域
.plan-section {
margin-bottom: 5vw;
// 分块标题
.section-title {
font-size: 5.333vw;
font-weight: 600;
margin-bottom: 2vw;
color: #f0f0f0;
}
// 列表项
.section-list {
.list-item {
margin-bottom: 1.5vw;
opacity: 0.85;
}
}
// 内容块
.section-content {
opacity: 0.85;
}
}
/* 底部弹窗遮罩 */
.popup-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 9999;
display: flex;
align-items: flex-end;
/* 关键:底部对齐 */
}
/* 底部弹窗主体 */
.popup-bottom {
width: 100%;
background: #1e1e1e;
border-radius: 24rpx 24rpx 0 0;
padding: 30rpx;
box-sizing: border-box;
}
/* 顶部拖动条 */
.popup-indicator {
width: 80rpx;
height: 8rpx;
background: #444;
border-radius: 4rpx;
margin: 0 auto 30rpx;
}
/* 弹窗标题 */
.popup-title {
font-size: 34rpx;
color: #fff;
text-align: center;
font-weight: bold;
display: block;
margin-bottom: 30rpx;
}
/* 顶部说明 + 计数 */
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30rpx;
.tip-left {
font-size: 28rpx;
color: #fff;
}
.tip-right {
font-size: 28rpx;
color: #ffd700;
}
}
/* 星期选择网格(7列) */
.week-grid {
display: flex;
justify-content: space-between;
margin-bottom: 40rpx;
}
.week-day {
width: 80rpx;
height: 100rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #333;
border-radius: 12rpx;
color: #fff;
font-size: 28rpx;
.rest-text {
font-size: 24rpx;
color: #999;
margin-top: 8rpx;
}
}
/* 选中状态样式(和例图一致) */
.week-day.selected {
background: #ffd700;
color: #000;
.rest-text {
color: #000;
}
}
/* 底部提示文案 */
.popup-desc {
font-size: 26rpx;
color: #999;
line-height: 1.6;
margin-bottom: 40rpx;
}
/* 确认按钮 */
.confirm-btn {
width: 100%;
height: 80rpx;
background: #ffd700;
color: #000;
border-radius: 40rpx;
font-size: 30rpx;
font-weight: bold;
border: none;
}
/* 按钮禁用状态(和例图一致:灰色、不可点) */
.confirm-btn.disabled {
background: #444;
color: #666;
pointer-events: none;
}
</style>
... ...
... ... @@ -477,15 +477,13 @@ const saveProfile = async () => {
await UserApi.updateUser(payload);
if (userStore.getUserInfo) {
await userStore.getUserInfo();
}
// 刷新 Store 中的用户信息(Pinia persist 插件会自动同步到 localStorage)
await userStore.getInfo();
// 同步表单数据,避免 navigateBack 时 onBackPress 误判为"未保存"
formData.value = JSON.parse(JSON.stringify(userStore.userInfo));
uni.showToast({ title: '保存成功', icon: 'success' });
// 注意:updateUser API 已配置 showSuccess: true,拦截器会自动弹出"保存成功"提示
setTimeout(() => {
uni.navigateBack();
}, 1200);
... ...
... ... @@ -17,8 +17,8 @@
<view class="about-card">
<view class="about-title">品牌理念</view>
<view class="about-content">
红星健身致力于为每一位健身爱好者提供<span
class="highlight">科学、高效、个性化</span>的训练解决方案。我们相信,健身不仅是改变体型的手段,更是一种积极向上的生活方式。无论你是健身新手还是资深训练者,红星健身都将陪伴你完成每一次突破,见证你的每一点进步。
自己练健身致力于为每一位健身爱好者提供<span
class="highlight">科学、高效、个性化</span>的训练解决方案。我们相信,健身不仅是改变体型的手段,更是一种积极向上的生活方式。无论你是健身新手还是资深训练者,自己练健身都将陪伴你完成每一次突破,见证你的每一点进步。
</view>
</view>
... ... @@ -35,23 +35,6 @@
</view>
</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>
... ... @@ -68,19 +51,15 @@
</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: [
<script setup>
import { ref } from 'vue';
const features = ref([
{
name: '个性化训练计划',
desc: '根据你的目标和水平,智能生成专属训练方案',
... ... @@ -97,21 +76,22 @@ export default {
name: '训练日历管理',
desc: '灵活安排训练日程,养成规律健身习惯',
},
],
};
},
methods: {
goBack() {
]);
const goBack = () => {
uni.navigateBack();
},
openAgreement(type) {
const titles = {
service: '服务协议',
privacy: '隐私政策',
};
uni.showToast({ title: titles[type] || '功能开发中', icon: 'none' });
},
},
};
const openAgreement = (type) => {
if (type == 'service') {
uni.navigateTo({
url: '/pages5/pages/user/yonhu-xieyi'
})
} else {
uni.navigateTo({
url: '/pages5/pages/user/yinsi-xieyi'
})
}
};
</script>
... ...
... ... @@ -234,9 +234,25 @@ import { ref, reactive, toRaw } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import UserApi from '@/sheep/api/member/user';
import dayjs from 'dayjs';
import {
formOptions,
GOAL_LABEL_MAP,
REWARD_LABEL_MAP,
SCENE_LABEL_MAP,
lookupLabel,
} from '../../../sheep/helper/health-form-options';
// ========================================================
// 响应式单态核心数据集
// 常量定义
// ========================================================
/** 身高/体重输入上限阈值 */
const MAX_NUMERIC_INPUT = 300;
/** 保存成功后跳转延迟(ms) */
const SAVE_REDIRECT_DELAY = 1500;
// ========================================================
// 响应式状态
// ========================================================
const Dateshow = ref(false);
... ... @@ -245,11 +261,11 @@ const inputPopup = ref(false);
const checkPopup = ref(false);
const isSaving = ref(false);
// 状态机寄存指针管理
// 弹窗上下文:记录当前操作字段和标题
const currentKey = ref('');
const currentTitle = ref('');
const bufferValue = ref(null); // 纯量缓冲区
const bufferArray = ref([]); // 数组队列多选缓冲区
const bufferValue = ref(null); // 单选/输入弹窗缓冲值
const bufferArray = ref([]); // 多选弹窗缓冲数组
const datePickerValue = ref(Date.now());
const pdata = reactive({
... ... @@ -270,72 +286,23 @@ const pdata = reactive({
fitnessScene: 0,
});
// 数据字典隔离配置
const formOptions = {
gender: [
{ label: '男', value: 1 },
{ label: '女', value: 2 },
],
hasFitnessFoundation: [
{ label: '有', value: 1 },
{ label: '无', value: 2 },
],
acceptableTrainingFrequency: [
{ label: '1练/2练/3练', value: 1 },
{ label: '4练/5练/6练', value: 2 },
],
targetMuscleParts: [
{ label: '肩颈', value: '肩颈' },
{ label: '斜方肌', value: '斜方肌' },
{ label: '手臂', value: '手臂' },
{ label: '胸部', value: '胸部' },
{ label: '背部', value: '背部' },
{ label: '腹部', value: '腹部' },
{ label: '臀部', value: '臀部' },
{ label: '腿部', value: '腿部' },
],
trainingGoal: [
{ label: '减脂', value: 1 },
{ label: '增肌', value: 2 },
{ label: '塑形', value: 3 },
{ label: '拉伸/体态调整', value: 4 },
],
painAreas: [
{ label: '肩颈', value: '肩颈' },
{ label: '手腕', value: '手腕' },
{ label: '腰部', value: '腰部' },
{ label: '脚踝', value: '脚踝' },
{ label: '膝盖', value: '膝盖' },
],
hasDisease: [
{ label: '有', value: 1 },
{ label: '无', value: 2 },
],
isTakingMedication: [
{ label: '是', value: 1 },
{ label: '否', value: 2 },
],
rewardMethod: [
{ label: '买件新衣服/装备', value: 1 },
{ label: '去旅行', value: 2 },
{ label: '和朋友聚会', value: 3 },
{ label: '其他', value: 4 },
],
fitnessScene: [
{ label: '健身房', value: 1 },
{ label: '家', value: 2 },
{ label: '宿舍', value: 3 },
],
};
// ========================================================
// 文本高阶转义映射器
// 文本格式化
// ========================================================
/** 格式化日期为 YYYY-MM-DD */
const formatDate = (val) => (val ? dayjs(val).format('YYYY-MM-DD') : '');
const formatGoal = (val) => ({ 1: '减脂', 2: '增肌', 3: '塑形', 4: '拉伸/体态调整' }[val] || '');
const formatReward = (val) =>
({ 1: '买件新衣服/装备', 2: '去旅行', 3: '和朋友聚会', 4: '其他' }[val] || '');
const formatScene = (val) => ({ 1: '健身房', 2: '家', 3: '宿舍' }[val] || '');
/** 格式化训练目标 */
const formatGoal = (val) => lookupLabel(val, GOAL_LABEL_MAP);
/** 格式化奖励方式 */
const formatReward = (val) => lookupLabel(val, REWARD_LABEL_MAP);
/** 格式化健身场景 */
const formatScene = (val) => lookupLabel(val, SCENE_LABEL_MAP);
/**
* 将后端布尔字段(true/false)转为前端选项值(1:是 2:否 0:未填)
*/
const boolToOption = (val) => (val === true ? 1 : val === false ? 2 : 0);
// ========================================================
// 核心弹窗调度器(精细化防污染管控)
... ... @@ -372,8 +339,7 @@ const closeInputPopup = () => {
const submitInput = () => {
const num = parseFloat(bufferValue.value);
// 安全阈值边界拦截校验
if (isNaN(num) || num <= 0 || num > 300) {
if (isNaN(num) || num <= 0 || num > MAX_NUMERIC_INPUT) {
uni.showToast({ title: '请输入合理区间值', icon: 'none' });
return;
}
... ... @@ -422,11 +388,14 @@ const getHealthData = async () => {
Object.keys(pdata).forEach((key) => {
if (key === 'hasDisease' || key === 'isTakingMedication') {
pdata[key] = data[key] === true ? 1 : data[key] === false ? 2 : 0;
// 后端布尔值 → 前端选项值(1:是 2:否 0:未填)
pdata[key] = boolToOption(data[key]);
} else if (key === 'targetMuscleParts' || key === 'painAreas') {
// 数组字段确保为有效数组
pdata[key] = Array.isArray(data[key]) ? data[key] : [];
} else {
pdata[key] = data[key] !== undefined && data[key] !== null ? data[key] : '';
// 其余字段直接映射,null/undefined 兜底为空字符串
pdata[key] = data[key] != null ? data[key] : '';
}
});
... ... @@ -434,12 +403,17 @@ const getHealthData = async () => {
datePickerValue.value = dayjs(pdata.birthday).valueOf();
}
} catch (error) {
console.error('拉取档案失败:', error);
console.error('获取健康资料失败:', error);
}
};
/**
* 将前端选项值转为布尔(供后端接口使用)
*/
const optionToBool = (val) => val === 1;
const saveHealthInfo = async () => {
// 基础必填拦截防空检测
// 必填校验
if (!pdata.gender || !pdata.birthday || !pdata.height || !pdata.weight) {
uni.showToast({ title: '请完整填写真实核心资料', icon: 'none' });
return;
... ... @@ -451,8 +425,9 @@ const saveHealthInfo = async () => {
try {
const payload = {
...toRaw(pdata),
hasDisease: pdata.hasDisease === 1,
isTakingMedication: pdata.isTakingMedication === 1,
// 前端选项值(1/2)→ 后端布尔值(true/false)
hasDisease: optionToBool(pdata.hasDisease),
isTakingMedication: optionToBool(pdata.isTakingMedication),
};
if (pdata.id) {
... ... @@ -464,9 +439,9 @@ const saveHealthInfo = async () => {
uni.showToast({ title: '专属计划已生成', icon: 'success' });
setTimeout(() => {
uni.navigateBack();
}, 1500);
}, SAVE_REDIRECT_DELAY);
} catch (error) {
console.error('提交健康资料异常:', error);
console.error('保存健康资料失败:', error);
uni.showToast({ title: '保存失败,请重试', icon: 'none' });
} finally {
isSaving.value = false;
... ...
<template>
<view class="privacy-page">
<!-- 导航栏 -->
<uni-nav-bar title="隐私协议" left-icon="left" @click-left="goBack" :fixed="true" :status-bar="true" />
<!-- 协议内容区 -->
<view class="content-section">
<!-- 引言 -->
<view class="privacy-card">
<view class="card-title">引言</view>
<view class="card-body">
<view class="paragraph">
自己练健身(以下简称"我们")深知个人信息对您的重要性,我们将严格遵守法律法规要求,采取相应的安全保护措施,尽力保护您的个人信息安全可控。鉴于此,我们制定本《隐私政策》(以下简称"本政策"),并提醒您:
</view>
<view class="paragraph">
本政策适用于自己练健身提供的所有产品和服务。如我们及关联公司的产品或服务中使用了自己练健身提供的产品或服务但未设独立隐私政策的,则本政策同样适用于该部分产品或服务。
</view>
<view class="paragraph">
<text
class="text-bold">请您在使用我们的产品或服务前,仔细阅读并了解本政策,</text>以确保您充分理解后再做出适当选择。您使用或继续使用我们的服务,即表示您同意我们按照本政策收集、使用、储存和分享您的相关信息。
</view>
</view>
</view>
<!-- 第一章 -->
<view class="privacy-card">
<view class="card-title">一、我们收集的信息</view>
<view class="card-body">
<view class="paragraph">
在您使用自己练健身服务的过程中,我们会按照如下方式收集您在使用服务时主动提供或因为使用服务而产生的信息,用以向您提供服务、优化我们的服务以及保障您的账户安全:
</view>
<view class="section-title">1.1 您主动提供的信息</view>
<view class="list-item">
<text class="list-dot">•</text>
<text class="list-text"><text
class="text-bold">注册与个人资料:</text>当您注册自己练健身账号时,您需要提供手机号码、昵称、性别、出生日期、身高、体重等基本信息,以便我们为您生成个性化的训练方案。</text>
</view>
<view class="list-item">
<text class="list-dot">•</text>
<text class="list-text"><text
class="text-bold">健康资料:</text>在设定健身目标时,您可以选择填写训练水平、目标肌群、健康状况等信息,帮助我们精准匹配训练内容。</text>
</view>
<view class="list-item">
<text class="list-dot">•</text>
<text class="list-text"><text
class="text-bold">反馈与客服:</text>当您联系我们的客服或提交意见反馈时,我们会收集您的联系方式和沟通内容,以便解决您的问题。</text>
</view>
<view class="section-title">1.2 使用服务时自动收集的信息</view>
<view class="list-item">
<text class="list-dot">•</text>
<text class="list-text"><text
class="text-bold">设备信息:</text>包括设备型号、操作系统版本、唯一设备标识符、IP地址、网络类型等,用于保障服务稳定运行和安全防护。</text>
</view>
<view class="list-item">
<text class="list-dot">•</text>
<text class="list-text"><text
class="text-bold">训练数据:</text>当您使用训练记录功能时,我们会收集您的训练动作、组数、次数、重量、训练时长等数据,用于生成您的训练报告和进度分析。</text>
</view>
<view class="list-item">
<text class="list-dot">•</text>
<text class="list-text"><text
class="text-bold">使用日志:</text>包括您访问的页面、点击的操作、使用时长、崩溃日志等,帮助我们优化产品体验。</text>
</view>
<view class="list-item">
<text class="list-dot">•</text>
<text class="list-text"><text
class="text-bold">位置信息:</text>当您使用附近健身房或运动场地推荐功能时,我们会申请获取您的地理位置权限。您可以随时在设备设置中关闭位置权限。</text>
</view>
</view>
</view>
<!-- 第二章 -->
<view class="privacy-card">
<view class="card-title">二、信息的使用方式</view>
<view class="card-body">
<view class="paragraph">我们将收集的信息用于以下目的:</view>
<view class="list-item">
<text class="list-dot">•</text>
<text class="list-text"><text
class="text-bold">提供核心服务:</text>基于您的个人资料和训练数据,为您生成个性化训练计划、提供动作示范与讲解、展示训练进度和数据分析。</text>
</view>
<view class="list-item">
<text class="list-dot">•</text>
<text class="list-text"><text
class="text-bold">产品优化:</text>分析用户的使用习惯和偏好,改进我们的功能设计,提升用户体验。</text>
</view>
<view class="list-item">
<text class="list-dot">•</text>
<text class="list-text"><text class="text-bold">安全保障:</text>检测和防范安全风险、欺诈行为,保护您和我们的合法权益。</text>
</view>
<view class="list-item">
<text class="list-dot">•</text>
<text class="list-text"><text class="text-bold">客户服务:</text>处理您的咨询、投诉和反馈,为您提供及时有效的帮助。</text>
</view>
<view class="list-item">
<text class="list-dot">•</text>
<text class="list-text"><text
class="text-bold">推荐与通知:</text>基于您的训练偏好,向您推送相关训练课程资讯和活动通知。您可以随时在"我的→设置→隐私设置"中关闭推送。</text>
</view>
</view>
</view>
<!-- 第三章 -->
<view class="privacy-card">
<view class="card-title">三、信息的存储与安全</view>
<view class="card-body">
<view class="section-title">3.1 存储地点</view>
<view class="paragraph">
我们会将您的个人信息存储于中华人民共和国境内。如需跨境传输,我们将严格按照法律法规要求进行安全评估,并取得您的单独同意。
</view>
<view class="section-title">3.2 存储期限</view>
<view class="paragraph">
我们仅在实现本政策所述目的所必需的期限内保留您的个人信息,除非法律法规有强制性的留存要求。当您的账号被注销后,我们将在合理期限内对您的个人信息进行删除或匿名化处理。
</view>
<view class="section-title">3.3 安全措施</view>
<view class="paragraph">
我们已采用符合业界标准的安全防护措施,包括但不限于数据加密传输(SSL/TLS)、访问控制、防火墙、数据备份等技术手段,保护您的个人信息免遭未经授权的访问、使用、修改或泄露。同时建立了数据安全管理制度和应急响应机制,以应对信息安全事件。
</view>
</view>
</view>
<!-- 第四章 -->
<view class="privacy-card">
<view class="card-title">四、信息的共享与披露</view>
<view class="card-body">
<view class="paragraph">
我们不会与第三方公司、组织和个人共享您的个人信息,但以下情况除外:
</view>
<view class="list-item">
<text class="list-dot">•</text>
<text class="list-text">
<text class="text-bold">获得您的明确授权:</text>在获得您的明确同意后,我们会向第三方共享您授权的信息。
</text>
</view>
<view class="list-item">
<text class="list-dot">•</text>
<text class="list-text">
<text class="text-bold">法律法规要求:</text>根据法律法规规定、诉讼或行政、司法机关要求,我们可能对外提供您的个人信息。
</text>
</view>
<view class="list-item">
<text class="list-dot">•</text>
<text class="list-text">
<text
class="text-bold">与关联公司共享:</text>我们可能与关联公司共享必要的个人信息,且受本政策约束。关联公司如要改变个人信息的处理目的,将再次征求您的授权同意。
</text>
</view>
<view class="list-item">
<text class="list-dot">•</text>
<text class="list-text">
<text
class="text-bold">与服务提供商共享:</text>为向您提供更好的服务,我们可能委托第三方服务提供商(如云存储服务商、推送服务商)处理您的个人信息。我们会与供应商签署严格的保密协议,要求其按照我们的指示和本政策采取保护措施。
</text>
</view>
</view>
</view>
<!-- 第五章 -->
<view class="privacy-card">
<view class="card-title">五、您的权利</view>
<view class="card-body">
<view class="paragraph">按照相关法律法规,我们保障您对自己的个人信息行使以下权利:</view>
<view class="list-item">
<text class="list-dot">•</text>
<text class="list-text">
<text class="text-bold">查阅与更正:</text>您可以在"我的→个人资料"中查阅和修改您的基本个人信息。如您发现我们收集、处理的个人信息有误,您有权要求更正。
</text>
</view>
<view class="list-item">
<text class="list-dot">•</text>
<text class="list-text">
<text
class="text-bold">删除个人信息:</text>在以下情形中,您可以向我们提出删除个人信息的请求:(1)处理目的已实现或无法实现;(2)我们停止提供产品或服务;(3)您撤回同意;(4)我们违反法律法规处理您的信息。
</text>
</view>
<view class="list-item">
<text class="list-dot">•</text>
<text class="list-text">
<text
class="text-bold">撤回同意:</text>您可以在"我的→设置→隐私设置"中撤回您对推送通知、个性化推荐等功能的授权。撤回同意不影响撤回前基于授权已进行的个人信息处理活动的效力。
</text>
</view>
<view class="list-item">
<text class="list-dot">•</text>
<text class="list-text">
<text
class="text-bold">注销账号:</text>您可以在"我的→设置"中申请注销账号。账号注销后,我们将停止为您提供产品或服务,并依法删除或匿名化处理您的个人信息。
</text>
</view>
</view>
</view>
<!-- 第六章 -->
<view class="privacy-card">
<view class="card-title">六、未成年人保护</view>
<view class="card-body">
<view class="paragraph">
我们高度重视未成年人个人信息的保护。如果您是<text
class="text-highlight">未满18周岁的未成年人</text>,在使用我们的服务前,应事先取得您父母或法定监护人的书面同意。对于经父母或监护人同意而收集的未成年人个人信息,我们只会在法律法规允许、父母或监护人明确同意或者保护未成年人所必要的情况下使用或披露。
</view>
<view class="paragraph">
如果监护人发现我们在未获其同意的情况下收集了未成年人的个人信息,请及时联系我们,我们将尽快核实并删除相关数据。
</view>
</view>
</view>
<!-- 第七章 -->
<view class="privacy-card">
<view class="card-title">七、隐私政策的更新</view>
<view class="card-body">
<view class="paragraph">
我们可能适时修订本政策。未经您明确同意,我们不会削减您按照本政策所应享有的权利。对于重大变更,我们会通过应用内弹窗通知、推送通知或其他显著方式告知您。
</view>
<view class="paragraph">
<text class="text-bold">重大变更包括但不限于:</text>
</view>
<view class="list-item">
<text class="list-dot">•</text>
<text class="list-text">我们的服务模式发生重大变化,如处理个人信息的目的、类型、方式等。</text>
</view>
<view class="list-item">
<text class="list-dot">•</text>
<text class="list-text">我们在控制权等方面发生重大变化,如并购重组等引起的所有者变更。</text>
</view>
<view class="list-item">
<text class="list-dot">•</text>
<text class="list-text">您参与个人信息处理方面的权利及其行使方式发生重大变化。</text>
</view>
</view>
</view>
<!-- 第八章 -->
<!-- <view class="privacy-card">
<view class="card-title">八、联系我们</view>
<view class="card-body">
<view class="paragraph">
如您对本政策或您的个人信息相关事宜有任何疑问、意见或投诉,请通过以下方式与我们联系:
</view>
<view class="contact-list">
<view class="contact-item">
<text class="contact-label">应用内反馈:</text>
<text class="contact-value">我的 → 联系客服 → 意见反馈</text>
</view>
<view class="contact-item">
<text class="contact-label">邮箱:</text>
<text class="contact-value">privacy@hongxingfitness.com</text>
</view>
</view>
<view class="paragraph" style="margin-top: 24rpx;">
我们将在收到您的反馈后<text class="text-highlight">15个工作日内</text>予以回复和处理。如您对我们的处理结果不满意,您还可以向相关监管部门进行投诉。
</view>
</view>
</view> -->
</view>
<!-- 底部信息 -->
<view class="footer-info">
<text class="footer-text">自己练健身 · 隐私政策</text>
<text class="footer-copyright">Copyright © 2026 自己练健身 All Rights Reserved</text>
</view>
<!-- 底部安全区占位 -->
<u-safe-bottom />
</view>
</template>
<script setup>
/**
* 隐私协议静态页面
* 基于芋道商城(yudao-mall-uniapp)框架开发
* 使用 uview-plus 组件库
*/
// 返回上一页
const goBack = () => {
uni.navigateBack({
delta: 1,
fail: () => {
// 如果是从外部直接打开(无上一页),跳转到首页
uni.switchTab({
url: '/pages/xunji/xunji',
});
},
});
};
</script>
<style lang="scss" scoped>
.privacy-page {
position: relative;
width: 100%;
min-height: 100vh;
background-color: #1a1a1a;
color: #fff;
padding-bottom: 60rpx;
}
/* ========== 页面标题 ========== */
.page-header {
padding: 40rpx 30rpx 30rpx;
background: linear-gradient(135deg, #1e1e1e 0%, #252525 100%);
border-bottom: 1rpx solid rgba(255, 255, 255, 0.06);
text-align: center;
}
.header-title {
font-size: 40rpx;
font-weight: bold;
color: #fff;
margin-bottom: 12rpx;
}
.header-subtitle {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.45);
line-height: 1.6;
}
/* ========== 协议内容区 ========== */
.content-section {
padding: 24rpx 24rpx 0;
}
.privacy-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);
}
.card-title {
font-size: 30rpx;
font-weight: bold;
margin-bottom: 22rpx;
padding-left: 14rpx;
border-left: 5rpx solid #ff6b00;
color: #fff;
}
.card-body {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.section-title {
font-size: 27rpx;
font-weight: bold;
color: rgba(255, 255, 255, 0.9);
margin-top: 8rpx;
margin-bottom: 4rpx;
}
.paragraph {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.75);
line-height: 1.8;
text-align: justify;
}
.text-bold {
font-weight: bold;
color: rgba(255, 255, 255, 0.95);
}
.text-highlight {
color: #ff6b00;
font-weight: bold;
}
/* ========== 列表项 ========== */
.list-item {
display: flex;
align-items: flex-start;
gap: 12rpx;
padding-left: 4rpx;
}
.list-dot {
font-size: 26rpx;
color: #ff6b00;
line-height: 1.8;
flex-shrink: 0;
}
.list-text {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.75);
line-height: 1.8;
flex: 1;
text-align: justify;
}
/* ========== 联系方式 ========== */
.contact-list {
display: flex;
flex-direction: column;
gap: 12rpx;
padding-left: 4rpx;
}
.contact-item {
display: flex;
align-items: flex-start;
gap: 8rpx;
}
.contact-label {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.6);
flex-shrink: 0;
}
.contact-value {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.85);
line-height: 1.6;
}
/* ========== 底部信息 ========== */
.footer-info {
text-align: center;
padding: 40rpx 0 20rpx;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.footer-text {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.35);
}
.footer-copyright {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.25);
}
</style>
... ...
<template>
<view class="agreement-page">
<!-- 导航栏 -->
<uni-nav-bar title="用户协议" left-icon="left" @click-left="goBack" :fixed="true" :status-bar="true" />
<!-- 协议内容区域 -->
<scroll-view scroll-y class="agreement-scroll" :style="{ height: scrollHeight + 'px' }">
<view class="agreement-content">
<!-- 导言 -->
<view class="agreement-section">
<view class="section-body">
<text class="section-text">
欢迎使用自己练健身(以下简称"本应用"或"我们")提供的服务。请您在使用本应用前仔细阅读本《用户服务协议》(以下简称"本协议")。<text class="text-bold">您通过点击同意、注册登录或实际使用本应用的行为,即表示您已充分阅读、理解并接受本协议的全部内容</text>。如您不同意本协议的任何条款,请立即停止注册或使用本应用。
</text>
<text class="section-text">
本协议是您与自己练健身之间就您使用本应用服务所订立的有效协议。我们有权根据法律法规及运营需要适时修订本协议,修订后的协议将在本应用内公示。若您在修订后继续使用本应用,即视为您已接受修订后的协议。
</text>
</view>
</view>
<!-- 一、服务说明 -->
<view class="agreement-section">
<view class="section-title">
<text class="title-text">一、服务说明</text>
</view>
<view class="section-body">
<text class="section-text">
1.1 自己练健身是一款面向健身爱好者的训练管理与记录工具,提供包括但不限于以下服务:训练计划制定与管理、动作库浏览与学习、训练数据记录与分析、训练日历管理等。
</text>
<text class="section-text">
1.2 本应用提供的健身建议、训练方案等内容仅供参考,不构成专业的医疗建议。<text class="text-bold">在开始任何训练计划前,您应当咨询专业医生或健康顾问,确认自身身体状况适合进行相应的体育活动。</text>
</text>
<text class="section-text">
1.3 我们保留根据业务发展需要,随时增加、调整或终止部分或全部服务的权利,且无需事先通知您。服务变更将在应用内公示后生效。
</text>
</view>
</view>
<!-- 二、账号注册与管理 -->
<view class="agreement-section">
<view class="section-title">
<text class="title-text">二、账号注册与管理</text>
</view>
<view class="section-body">
<text class="section-text">
2.1 您在使用本应用前需要注册账号。注册时,您应当提供真实、准确、完整的个人信息,并在信息变更时及时更新。因您提供的信息不真实、不准确或不完整所导致的一切后果由您自行承担。
</text>
<text class="section-text">
2.2 您注册的账号仅限于您本人使用,不得以任何形式转让、出借或授权他人使用。您应当妥善保管账号及密码信息,对通过您的账号所进行的一切活动承担全部责任。
</text>
<text class="section-text">
2.3 如发现您的账号存在异常登录或被他人非法使用的情形,您应当立即通知我们。因您未及时通知而导致的一切损失,由您自行承担。
</text>
<text class="section-text">
2.4 您有权随时注销您的账号。账号注销后,您的个人信息将按照本应用隐私政策的规定进行处理。账号一旦注销,将无法恢复。
</text>
</view>
</view>
<!-- 三、用户行为规范 -->
<view class="agreement-section">
<view class="section-title">
<text class="title-text">三、用户行为规范</text>
</view>
<view class="section-body">
<text class="section-text">
3.1 您在使用本应用过程中,应当遵守中华人民共和国的法律法规,不得利用本应用从事违法违规活动,包括但不限于:
</text>
<text class="section-text indent-text">
(1)发布、传播危害国家安全、破坏国家统一、损害国家荣誉和利益的内容;
</text>
<text class="section-text indent-text">
(2)发布、传播煽动民族仇恨、民族歧视、破坏民族团结的内容;
</text>
<text class="section-text indent-text">
(3)发布、传播淫秽、色情、赌博、暴力、凶杀、恐怖或者教唆犯罪的内容;
</text>
<text class="section-text indent-text">
(4)发布、传播侮辱、诽谤他人,侵害他人合法权益的内容;
</text>
<text class="section-text indent-text">
(5)发布、传播虚假信息,扰乱社会秩序、破坏社会稳定的内容;
</text>
<text class="section-text indent-text">
(6)利用技术手段恶意攻击本应用服务器,破坏本应用的正常运行;
</text>
<text class="section-text indent-text">
(7)利用本应用从事任何可能影响其他用户正常使用的行为。
</text>
<text class="section-text">
3.2 您应尊重他人的知识产权,不得在本应用上发布、分享任何侵犯他人著作权、商标权、专利权等知识产权的内容。
</text>
<text class="section-text">
3.3 如您违反上述规定,我们有权采取包括但不限于以下措施:警告、限制功能使用、暂停或终止服务、注销账号,并保留追究法律责任的权利。
</text>
</view>
</view>
<!-- 四、服务变更与终止 -->
<view class="agreement-section">
<view class="section-title">
<text class="title-text">四、服务变更、中断与终止</text>
</view>
<view class="section-body">
<text class="section-text">
4.1 我们可能会根据业务发展需要,对服务的全部或部分内容进行调整、变更或终止,并在合理期限内通过应用公告、推送通知等方式告知您。
</text>
<text class="section-text">
4.2 在下列情形下,我们有权不经通知即中断或终止向您提供服务:
</text>
<text class="section-text indent-text">
(1)您违反本协议约定,我们根据违约情况决定终止服务的;
</text>
<text class="section-text indent-text">
(2)您注册时提供的信息不真实、不准确,或未及时更新的;
</text>
<text class="section-text indent-text">
(3)根据法律法规规定或政府部门的要求;
</text>
<text class="section-text indent-text">
(4)为维护社会公共利益或保护其他用户合法权益所必需的。
</text>
<text class="section-text">
4.3 因系统维护升级、网络故障、技术调整等原因可能导致服务中断,我们将尽量提前通知您并尽快恢复服务。对于计划内的维护,我们将至少提前24小时通过应用公告通知。
</text>
</view>
</view>
<!-- 五、知识产权 -->
<view class="agreement-section">
<view class="section-title">
<text class="title-text">五、知识产权</text>
</view>
<view class="section-body">
<text class="section-text">
5.1 本应用的所有内容,包括但不限于文字、图片、视频、音频、图标、界面设计、软件代码、数据分析模型等,其知识产权均归自己练健身或其权利人所有,受中华人民共和国著作权法、商标法、专利法等相关法律法规的保护。
</text>
<text class="section-text">
5.2 未经我们或相关权利人的书面许可,您不得以任何方式(包括但不限于复制、修改、传播、展示、镜像、上载、下载)使用本应用的任何内容,也不得对本应用进行反向工程、反编译或反汇编。
</text>
<text class="section-text">
5.3 您在使用本应用过程中上传、发布的内容,您保留对该内容的所有权。但您授予我们全球范围内免费、非独家、可再许可的使用权,以便我们运营和改进本应用服务。
</text>
</view>
</view>
<!-- 六、免责声明 -->
<view class="agreement-section">
<view class="section-title">
<text class="title-text">六、免责声明</text>
</view>
<view class="section-body">
<text class="section-text">
6.1 <text class="text-bold">健康风险提示:</text>本应用提供的训练计划、动作指导、营养建议等内容仅供一般性参考,不构成专业的医疗、健身或营养建议。您在使用本应用过程中自愿承担因参与体育活动可能产生的一切风险,包括但不限于运动损伤、身体不适等。我们强烈建议您在开始任何新的训练计划前咨询专业医生。
</text>
<text class="section-text">
6.2 <text class="text-bold">数据准确性:</text>本应用中的训练数据(如消耗卡路里、训练量等)为基于算法估算的结果,可能与实际情况存在偏差,不应用于精确的健康监测或医疗诊断。
</text>
<text class="section-text">
6.3 <text class="text-bold">不可抗力:</text>因自然灾害、战争、政府行为、法律法规变化、黑客攻击、电信运营商故障、第三方服务故障等不可抗力或我们不能预见、不能控制的原因导致本应用服务中断或出现其他不利影响的,我们不承担责任。
</text>
<text class="section-text">
6.4 本应用可能包含指向第三方网站或服务的链接。这些链接仅为方便您使用而提供,我们对该等第三方网站或服务的内容、隐私政策或行为不承担任何责任。
</text>
</view>
</view>
<!-- 七、责任限制 -->
<view class="agreement-section">
<view class="section-title">
<text class="title-text">七、责任限制</text>
</view>
<view class="section-body">
<text class="section-text">
7.1 在法律允许的最大范围内,我们对因使用或无法使用本应用而产生的任何直接、间接、附带、特殊、惩罚性或结果性损失(包括但不限于人身伤害、数据丢失、业务中断、利润损失等)不承担责任,无论该等损失是否基于合同、侵权或其他法律理论,即使我们已被告知发生该等损失的可能性。
</text>
<text class="section-text">
7.2 如我们因任何原因需对您承担责任,则在法律允许的最大范围内,我们的累计责任总额不超过您在事件发生前十二(12)个月内向我们已支付的费用总额。
</text>
</view>
</view>
<!-- 八、隐私保护 -->
<view class="agreement-section">
<view class="section-title">
<text class="title-text">八、隐私保护</text>
</view>
<view class="section-body">
<text class="section-text">
8.1 保护您的个人信息是我们的一项重要原则。我们将按照《自己练健身隐私政策》的规定,收集、使用、存储和保护您的个人信息。您可以通过本应用"设置-隐私政策"页面查阅完整的隐私政策内容。
</text>
<text class="section-text">
8.2 我们承诺不会将您的个人信息出售或非法提供给任何第三方,但以下情形除外:
</text>
<text class="section-text indent-text">
(1)事先获得您的明确授权同意;
</text>
<text class="section-text indent-text">
(2)根据法律法规规定或政府部门、司法机关的强制性要求;
</text>
<text class="section-text indent-text">
(3)为维护我们的合法权益,如查找、预防、处理欺诈或安全问题;
</text>
<text class="section-text indent-text">
(4)为向您提供服务之目的,与我们的合作伙伴共享必要信息(如云服务提供商等)。
</text>
<text class="section-text">
8.3 我们采用业界通行的安全技术和措施来保护您的个人信息安全,防止信息被未经授权的访问、使用或泄露。但由于互联网环境的特殊性,我们无法保证信息传输和存储的绝对安全。
</text>
</view>
</view>
<!-- 九、争议解决 -->
<view class="agreement-section">
<view class="section-title">
<text class="title-text">九、争议解决</text>
</view>
<view class="section-body">
<text class="section-text">
9.1 本协议的订立、执行、解释及争议解决均适用中华人民共和国法律(为本协议之目的,不包括香港特别行政区、澳门特别行政区和台湾地区的法律)。
</text>
<text class="section-text">
9.2 因本协议引起的或与本协议有关的任何争议,双方应首先通过友好协商解决。协商不成的,任何一方均有权将争议提交至我们有管辖权的人民法院通过诉讼解决。
</text>
</view>
</view>
<!-- 十、其他条款 -->
<view class="agreement-section">
<view class="section-title">
<text class="title-text">十、其他条款</text>
</view>
<view class="section-body">
<text class="section-text">
10.1 本协议中部分条款被认定为无效或不可执行的,不影响其他条款的效力,其余条款继续有效。
</text>
<text class="section-text">
10.2 我们未行使或延迟行使本协议项下的任何权利,不视为对该权利的放弃。任何权利的放弃应以书面形式作出。
</text>
<text class="section-text">
10.3 本协议条款的标题仅为阅读方便而设,不影响条款本身的含义和解释。
</text>
<text class="section-text">
10.4 如您对本协议有任何疑问、意见或建议,可通过以下方式联系我们:
</text>
<text class="section-text indent-text">
应用内:通过"我的-联系客服"页面提交反馈
</text>
</view>
</view>
<!-- 底部间距 -->
<view class="agreement-footer">
<text class="footer-text">自己练健身团队</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue';
// 滚动区域高度
const scrollHeight = ref(0);
// 计算滚动区域高度(屏幕高度 - 导航栏高度 - 状态栏高度)
const calcScrollHeight = () => {
const systemInfo = uni.getSystemInfoSync();
// 导航栏高度:状态栏高度 + 胶囊按钮高度(约44px + statusBarHeight)
const statusBarHeight = systemInfo.statusBarHeight || 0;
// uni-nav-bar 默认高度约 44px(不含状态栏,因 status-bar=true 状态栏由组件处理)
const navBarHeight = 44;
// 将 px 转换为实际可使用高度
scrollHeight.value = systemInfo.windowHeight - statusBarHeight - navBarHeight;
};
onMounted(() => {
calcScrollHeight();
});
// 返回上一页
const goBack = () => {
uni.navigateBack({
delta: 1,
fail: () => {
// 如果无法返回(比如直接打开该页面),跳转到首页
uni.switchTab({ url: '/pages/xunji/xunji' });
},
});
};
</script>
<style lang="scss" scoped>
// 颜色变量
$bg-color: #1a1a1a;
$card-bg: rgba(255, 255, 255, 0.05);
$border-color: rgba(255, 255, 255, 0.08);
$text-primary: #ffffff;
$text-secondary: rgba(255, 255, 255, 0.75);
$text-tertiary: rgba(255, 255, 255, 0.45);
$highlight-color: #ff6b00;
$highlight-dim: rgba(255, 107, 0, 0.15);
.agreement-page {
width: 100%;
height: 100vh;
background-color: $bg-color;
color: $text-primary;
display: flex;
flex-direction: column;
}
.agreement-scroll {
flex: 1;
}
.agreement-content {
padding: 24rpx 30rpx 60rpx;
}
// 协议头部
.agreement-header {
display: flex;
flex-direction: column;
align-items: center;
padding: 40rpx 0 30rpx;
border-bottom: 2rpx solid $border-color;
margin-bottom: 30rpx;
.agreement-title {
font-size: 40rpx;
font-weight: bold;
color: $text-primary;
letter-spacing: 2rpx;
margin-bottom: 16rpx;
}
.agreement-date {
font-size: 24rpx;
color: $text-tertiary;
line-height: 1.6;
}
}
// 协议章节
.agreement-section {
margin-bottom: 36rpx;
.section-title {
margin-bottom: 18rpx;
padding-left: 14rpx;
border-left: 5rpx solid $highlight-color;
.title-text {
font-size: 32rpx;
font-weight: bold;
color: $text-primary;
letter-spacing: 1rpx;
}
}
.section-body {
padding-left: 8rpx;
}
.section-text {
display: block;
font-size: 28rpx;
color: $text-secondary;
line-height: 1.85;
margin-bottom: 14rpx;
text-align: justify;
&.indent-text {
padding-left: 30rpx;
}
.text-bold {
font-weight: bold;
color: $text-primary;
}
}
}
// 底部区域
.agreement-footer {
text-align: center;
padding: 40rpx 0 20rpx;
.footer-text {
font-size: 26rpx;
color: $text-tertiary;
}
}
</style>
... ...
... ... @@ -2,11 +2,7 @@
<view class="login-wrapper">
<!-- 1. Logo 及品牌信息 -->
<view class="logo-section">
<view class="logo-box">
<!-- 优化:为了兼容微信小程序等跨端环境,原生 SVG 转换为 Base64 嵌入标准 image 标签中,确保完美渲染 -->
<image class="logo-img" :src="logoSvgBase64" mode="aspectFit" />
</view>
<view class="brand-title">自己练</view>
<image class="logo-img" :src="appLogo" mode="aspectFit" />
<view class="brand-slogan">健康极简 · 开启你的蜕变时刻</view>
</view>
... ... @@ -55,7 +51,7 @@
<text class="form-label">登录密码</text>
<view class="input-box">
<u-icon name="lock" size="20" color="#a1a8b3" class="input-icon" />
<input type="password" v-model="password" placeholder="请输入您的密码" placeholder-style="color: #a1a8b3"
<input v-model="password" placeholder="请输入您的密码" placeholder-style="color: #a1a8b3"
class="native-input" :password="!passwordShow" />
<!-- -->
<u-icon :name="passwordShow ? 'eye' : 'eye-off'" size="20" color="#a1a8b3" class="eye-icon"
... ... @@ -88,14 +84,13 @@
</template>
<script setup>
import { ref, onUnmounted } from 'vue';
import { ref, } from 'vue';
import sheep from '@/sheep';
import AuthUtil from '@/sheep/api/member/auth';
import { onHide } from '@dcloudio/uni-app';
import {appLogo} from '@/sheep/config/index'
// 用于跨端兼容渲染的 Base64 编码火焰 SVG (绿渐变)
const logoSvgBase64 =
'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs><linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="%238fc31f" /><stop offset="100%" stop-color="%2300b074" /></linearGradient></defs><path d="M12 2C12 2 6 7.5 6 13C6 16.8 9.1 20 12 20C14.9 20 18 16.8 18 13C18 7.5 12 2 12 2ZM12 16C10.3 16 9 14.7 9 13C9 10.5 12 7.5 12 7.5C12 7.5 15 10.5 15 13C15 14.7 13.7 16 12 16Z" fill="url(%23g)" /></svg>';
const activeTab = ref('sms'); // sms: 免密, password: 密码
const phoneNumber = ref('');
... ... @@ -267,19 +262,10 @@ const goToForgetPassword = () => {
const goToAgreement = (type) => {
const url =
type === 'service'
? '/pages/public/richtext?id=service'
: '/pages/public/richtext?id=privacy';
uni.navigateTo({
url,
fail: () => {
uni.showToast({
title: `跳转至${type === 'service' ? '用户协议' : '隐私政策'}`,
icon: 'none',
});
},
});
};
? '/pages5/pages/user/yonhu-xieyi'
: '/pages5/pages/user/yinsi-xieyi';
uni.navigateTo({ url });
}
// 页面卸载生命周期:清除定时器,规避潜在的内存泄露
onHide(() => {
if (timer) {
... ... @@ -304,38 +290,19 @@ onHide(() => {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 70rpx;
margin-bottom: 60rpx;
margin-top: 40rpx;
.logo-box {
width: 140rpx;
height: 140rpx;
border-radius: 40rpx;
background: #ffffff;
border: 3rpx solid #d2ee9e;
box-shadow: 0 16rpx 40rpx rgba(111, 214, 32, 0.12);
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 30rpx;
.logo-img {
width: 76rpx;
height: 76rpx;
}
}
.brand-title {
font-size: 46rpx;
font-weight: bold;
color: #0a1931;
letter-spacing: 2rpx;
width: 320rpx;
height: 320rpx;
}
.brand-slogan {
font-size: 26rpx;
color: #9097a3;
margin-top: 12rpx;
margin-top: 20rpx;
letter-spacing: 2rpx;
}
}
... ...
<template>
<view class="login-wrapper">
<!-- 主表单区域:复用与登录页相同的 form-container 设计语言 -->
<!-- 主表单区域 -->
<view class="form-container">
<!-- 1. 手机号码 -->
<view class="form-item-wrapper">
<text class="form-label">手机号码</text>
<view class="input-box">
<u-input v-model="formData.phone" type="number" maxlength="11" placeholder="请输入您的手机号"
placeholder-style="color: #a1a8b3" border="none" class="custom-u-input">
<template #prefix>
<u-icon name="phone" size="20" color="#a1a8b3" class="input-icon" />
<input type="number" v-model="phone" placeholder="请输入您的手机号" placeholder-style="color: #a1a8b3" maxlength="11"
class="native-input" />
</template>
</u-input>
</view>
</view>
<!-- 2. 短信验证码:复用登录页免密模式的双列布局 -->
<!-- 2. 短信验证码 -->
<view class="form-item-wrapper">
<text class="form-label">验证码</text>
<view class="code-row">
<view class="input-box flex-1">
<u-input v-model="formData.code" type="number" maxlength="6" placeholder="6位验证码"
placeholder-style="color: #a1a8b3" border="none" class="custom-u-input">
<template #prefix>
<u-icon name="chat" size="20" color="#a1a8b3" class="input-icon" />
<input type="number" v-model="code" placeholder="6位验证码" placeholder-style="color: #a1a8b3" maxlength="6"
class="native-input" />
</template>
</u-input>
</view>
<!-- 获取验证码按钮 -->
<view class="code-btn" :class="{ disabled: countdown > 0 || isSending }" @click="handleSendCode">
<button class="code-btn" :class="{ disabled: isCodeBtnDisabled }" :disabled="isCodeBtnDisabled"
@click="handleSendCode">
<text>{{ countdown > 0 ? `${countdown}s 后重试` : '获取验证码' }}</text>
</view>
</button>
</view>
</view>
... ... @@ -32,11 +40,12 @@
<view class="form-item-wrapper">
<text class="form-label">设置新密码</text>
<view class="input-box">
<u-input v-model="formData.password" type="password" placeholder="请设置您的新密码" placeholder-style="color: #a1a8b3"
border="none" class="custom-u-input">
<template #prefix>
<u-icon name="lock" size="20" color="#a1a8b3" class="input-icon" />
<input :type="showPassword ? 'text' : 'password'" v-model="password" placeholder="请设置您的新密码"
placeholder-style="color: #a1a8b3" class="native-input" />
<u-icon :name="showPassword ? 'eye-fill' : 'eye'" size="20" color="#a1a8b3" class="eye-icon"
@click="showPassword = !showPassword" />
</template>
</u-input>
</view>
<text class="input-hint">请设置8~16位包含数字、大小写字母、特殊字符组合作为密码</text>
</view>
... ... @@ -45,16 +54,17 @@
<view class="form-item-wrapper">
<text class="form-label">确认密码</text>
<view class="input-box">
<u-input v-model="formData.confirmPassword" type="password" placeholder="请再次输入新密码"
placeholder-style="color: #a1a8b3" border="none" class="custom-u-input">
<template #prefix>
<u-icon name="lock" size="20" color="#a1a8b3" class="input-icon" />
<input :type="showConfirmPassword ? 'text' : 'password'" v-model="confirmPassword" placeholder="请再次输入新密码"
placeholder-style="color: #a1a8b3" class="native-input" />
<u-icon :name="showConfirmPassword ? 'eye-fill' : 'eye'" size="20" color="#a1a8b3" class="eye-icon"
@click="showConfirmPassword = !showConfirmPassword" />
</template>
</u-input>
</view>
</view>
</view>
<!-- 5. 提交按钮:复用登录页高质感渐变色圆角按钮 -->
<!-- 5. 提交按钮 -->
<view class="submit-btn" :class="{ 'disabled-btn': isSubmitting }" @click="handleSubmit">
<text>{{ isSubmitting ? '保存中...' : '确认修改' }}</text>
</view>
... ... @@ -62,95 +72,107 @@
</template>
<script setup>
import { ref } from 'vue';
import { ref, reactive, computed, onBeforeUnmount } from 'vue';
import { onLoad, onHide, onUnload } from '@dcloudio/uni-app';
import AuthUtil from '@/sheep/api/member/auth';
import { onHide, onLoad } from '@dcloudio/uni-app';
const phone = ref('');
const code = ref('');
const password = ref('');
const confirmPassword = ref('');
// 密码显示隐藏控制
const showPassword = ref(false);
const showConfirmPassword = ref(false);
// 1. 表单响应式数据归类
const formData = reactive({
phone: '',
code: '',
password: '',
confirmPassword: '',
});
// 防抖及倒计时状态
// 2. 交互与状态控制
const isSending = ref(false);
const isSubmitting = ref(false);
const countdown = ref(0);
const isModify = ref(false);
let timer = null;
// 验证手机号码格式
const validatePhone = (num) => {
return /^1[3-9]\d{9}$/.test(num);
// 计算属性:验证码按钮禁用状态
const isCodeBtnDisabled = computed(() => countdown.value > 0 || isSending.value);
// 3. 正则表达式配置
const REGEX = {
PHONE: /^1[3-9]\d{9}$/,
STRONG_PWD: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&._#^&+=*!~-])[A-Za-z\d@$!%*?&._#^&+=*!~-]{8,16}$/,
};
/** Toast 提示封装 */
const showToast = (title, icon = 'none') => {
uni.showToast({ title, icon });
};
// 强密码复杂度检测:8~16位,大小写、数字、特殊字符
const validatePasswordStrength = (pwd) => {
const reg =
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&._#^&+=*!~-])[A-Za-z\d@$!%*?&._#^&+=*!~-]{8,16}$/;
return reg.test(pwd);
/** 清除倒计时定时器 */
const clearCountdownTimer = () => {
if (timer) {
clearInterval(timer);
timer = null;
}
};
// 发送短信验证码
/** 开启倒计时 */
const startCountdown = (seconds = 60) => {
clearCountdownTimer();
countdown.value = seconds;
timer = setInterval(() => {
countdown.value--;
if (countdown.value <= 0) {
clearCountdownTimer();
}
}, 1000);
};
// 4. 事件处理:发送短信验证码
const handleSendCode = async () => {
if (countdown.value > 0 || isSending.value) return;
if (isCodeBtnDisabled.value) return;
if (!phone.value) {
uni.showToast({ title: '请输入手机号码', icon: 'none' });
return;
if (!formData.phone) {
return showToast('请输入手机号码');
}
if (!validatePhone(phone.value)) {
uni.showToast({ title: '请输入正确的11位手机号码', icon: 'none' });
return;
if (!REGEX.PHONE.test(formData.phone)) {
return showToast('请输入正确的11位手机号码');
}
isSending.value = true;
try {
const res = await AuthUtil.sendSmsCode({ phone: phone.value });
const res = await AuthUtil.sendSmsCode({ phone: formData.phone });
if (res && (res.code === 0 || res.data === true)) {
uni.showToast({ title: '验证码已发送', icon: 'success' });
countdown.value = 60;
timer = setInterval(() => {
countdown.value--;
if (countdown.value <= 0) {
clearInterval(timer);
timer = null;
}
}, 1000);
showToast('验证码已发送', 'success');
startCountdown(60);
} else {
uni.showToast({ title: res.msg || '获取验证码失败', icon: 'none' });
showToast(res?.msg || '获取验证码失败');
}
} catch (err) {
console.error('发送验证码失败异常:', err);
showToast('网络开小差了,请稍后再试');
} finally {
isSending.value = false;
}
};
// 保存并提交重置密码
// 5. 事件处理:提交修改密码
const handleSubmit = async () => {
if (isSubmitting.value) return;
if (!phone.value || !validatePhone(phone.value)) {
uni.showToast({ title: '请输入正确的11位手机号码', icon: 'none' });
return;
// 表单完整性校验
if (!formData.phone || !REGEX.PHONE.test(formData.phone)) {
return showToast('请输入正确的11位手机号码');
}
if (!code.value || code.value.length < 4) {
uni.showToast({ title: '请输入短信验证码', icon: 'none' });
return;
if (!formData.code || formData.code.length < 4) {
return showToast('请输入正确的短信验证码');
}
if (!password.value) {
uni.showToast({ title: '请输入您的新密码', icon: 'none' });
return;
if (!formData.password) {
return showToast('请输入您的新密码');
}
// if (!validatePasswordStrength(password.value)) {
// uni.showToast({ title: '密码须为8~16位并包含大小写字母、数字及特殊字符', icon: 'none' });
// return;
// 如需强密码校验,取消下一行注释:
// if (!REGEX.STRONG_PWD.test(formData.password)) {
// return showToast('密码须为8~16位并包含大小写字母、数字及特殊字符');
// }
if (password.value !== confirmPassword.value) {
uni.showToast({ title: '两次输入的密码不一致', icon: 'none' });
return;
if (formData.password !== formData.confirmPassword) {
return showToast('两次输入的密码不一致');
}
isSubmitting.value = true;
... ... @@ -158,48 +180,41 @@ const handleSubmit = async () => {
try {
const res = await AuthUtil.setPassword({
phone: phone.value,
code: code.value,
password: password.value,
phone: formData.phone,
code: formData.code,
password: formData.password,
});
if (res && res.code === 0) {
showToast('密码修改成功', 'success');
setTimeout(() => {
uni.redirectTo({
url: '/pages7/pages/index/login',
});
}, 1500);
} else {
showToast(res?.msg || '修改失败,请重试');
}
} catch (err) {
console.error('重置密码运行异常:', err);
showToast('网络繁忙,请稍后再试');
} finally {
isSubmitting.value = false;
uni.hideLoading();
}
};
// 生命周期管理:清除定时器
onHide(() => {
if (timer) {
clearInterval(timer);
timer = null;
}
});
const isForgetPassword = ref(false)
const isModify = ref(false);
// 6. 生命周期管理
onLoad((options) => {
// 路由参数是字符串,需要转布尔判断
isModify.value = options.isModify === 'true';
console.log('isModify.value=', isModify.value);
if (isModify.value) {
// 修改密码场景
uni.setNavigationBarTitle({ title: '修改密码' });
} else {
// 忘记密码场景
uni.setNavigationBarTitle({ title: '找回密码' });
}
isModify.value = options?.isModify === 'true';
const pageTitle = isModify.value ? '修改密码' : '找回密码';
uni.setNavigationBarTitle({ title: pageTitle });
});
// 清理定时器,防止泄漏
onHide(clearCountdownTimer);
onUnload(clearCountdownTimer);
onBeforeUnmount(clearCountdownTimer);
</script>
<style lang="scss" scoped>
... ... @@ -209,10 +224,9 @@ onLoad((options) => {
height: calc(100vh - 44px);
// #endif
background-color: #ffffff;
padding: 40rpx 50rpx; // 移除顶部大 Logo 后,缩减顶部 Padding 保持呼吸感
padding: 40rpx 50rpx;
box-sizing: border-box;
// 1. 表单容器
.form-container {
margin-top: 20rpx;
... ... @@ -227,7 +241,6 @@ onLoad((options) => {
margin-bottom: 16rpx;
}
// 输入框提示语
.input-hint {
display: block;
font-size: 24rpx;
... ... @@ -244,26 +257,21 @@ onLoad((options) => {
border-radius: 24rpx;
display: flex;
align-items: center;
padding: 0 30rpx;
padding: 0 20rpx;
box-sizing: border-box;
.input-icon {
margin-right: 16rpx;
}
.eye-icon {
padding: 10rpx;
}
.native-input {
flex: 1;
.custom-u-input {
width: 100%;
height: 100%;
font-size: 28rpx;
color: #0a1931;
}
.input-icon {
margin-right: 12rpx;
}
}
// 验证码双列布局
/* 双列布局:验证码 */
.code-row {
display: flex;
align-items: center;
... ... @@ -285,8 +293,14 @@ onLoad((options) => {
font-size: 26rpx;
color: #8fc31f;
font-weight: bold;
padding: 0;
line-height: normal;
transition: all 0.2s ease;
&::after {
border: none;
}
&:active {
opacity: 0.8;
}
... ... @@ -302,7 +316,6 @@ onLoad((options) => {
}
}
// 2. 确认修改按钮 (登录页同款高级渐变微立体阴影)
.submit-btn {
height: 104rpx;
border-radius: 24rpx;
... ... @@ -327,10 +340,6 @@ onLoad((options) => {
opacity: 0.75;
pointer-events: none;
}
.btn-arrow {
margin-left: 10rpx;
}
}
}
</style>
\ No newline at end of file
... ...
... ... @@ -356,7 +356,7 @@ const UserApi = {
// 运动轨迹
getTrajectory: () => {
return request({
url: '/app/student/motion',
url: '/app/user/motion',
method: 'GET',
custom: {
showLoading: false,
... ... @@ -367,7 +367,7 @@ const UserApi = {
// 更换背景图
updateBgImage: (data) => {
return request({
url: '/app/student/updateBackgroundImage',
url: '/app/user/updateBackgroundImage',
method: 'POST',
data,
custom: {
... ... @@ -380,7 +380,7 @@ const UserApi = {
// 保存个性签名
updateSignature: (data) => {
return request({
url: '/app/student/updateSignature',
url: '/app/user/updateSignature',
method: 'POST',
data,
custom: {
... ...
import request from '@/sheep/request';
const ExercisesApi = {
// // 获取动作列表
// // 根据分类获取动作列表(简化版本)
// getExercisesByCategory: (categoriesId, subCategoriesId = null) => {
// return ExercisesApi.getExercises({
// categoriesId,
// subCategoriesId
// });
// },
// // 根据器械获取动作列表
// getExercisesByEquipment: (categoriesId, equipmentsId) => {
// return ExercisesApi.getExercises({
// categoriesId,
// equipmentsId
// });
// },
// 获得动作列表(部位 → 器械 → 动作 三级分组)
getExercisesByCategory: (params) => {
return request({
url: '/app/motion/exercises/get-by-category',
method: 'GET',
params,
});
},
// 1.获取动作锻炼部位列表
getMotionPart: (id) => {
... ...
... ... @@ -159,6 +159,9 @@ const QueryPlanApi = {
params: {
Id,
},
custom:{
showLoading:false,
}
});
},
... ...
... ... @@ -17,6 +17,7 @@ export const tenantId = import.meta.env.SHOPRO_TENANT_ID;
export const websocketPath = import.meta.env.SHOPRO_WEBSOCKET_PATH;
export const h5Url = import.meta.env.SHOPRO_H5_URL;
export const defaultAvatar = import.meta.env.DEFAULT_AVATAR;
export const appLogo = import.meta.env.APP_LOGO;
export default {
baseUrl,
apiPath,
... ... @@ -25,4 +26,5 @@ export default {
websocketPath,
h5Url,
defaultAvatar,
appLogo,
};
... ...
/**
* 健康资料表单数据字典
* 集中管理所有选项配置和文本映射,便于复用和维护
*/
// ==================== 选项配置 ====================
export const GENDER_OPTIONS = [
{ label: '男', value: 1 },
{ label: '女', value: 2 },
];
export const FITNESS_FOUNDATION_OPTIONS = [
{ label: '有', value: 1 },
{ label: '无', value: 2 },
];
export const TRAINING_FREQUENCY_OPTIONS = [
{ label: '1练/2练/3练', value: 1 },
{ label: '4练/5练/6练', value: 2 },
];
export const TARGET_MUSCLE_OPTIONS = [
{ label: '肩颈', value: '肩颈' },
{ label: '斜方肌', value: '斜方肌' },
{ label: '手臂', value: '手臂' },
{ label: '胸部', value: '胸部' },
{ label: '背部', value: '背部' },
{ label: '腹部', value: '腹部' },
{ label: '臀部', value: '臀部' },
{ label: '腿部', value: '腿部' },
];
export const TRAINING_GOAL_OPTIONS = [
{ label: '减脂', value: 1 },
{ label: '增肌', value: 2 },
{ label: '塑形', value: 3 },
{ label: '拉伸/体态调整', value: 4 },
];
export const PAIN_AREA_OPTIONS = [
{ label: '肩颈', value: '肩颈' },
{ label: '手腕', value: '手腕' },
{ label: '腰部', value: '腰部' },
{ label: '脚踝', value: '脚踝' },
{ label: '膝盖', value: '膝盖' },
];
export const DISEASE_OPTIONS = [
{ label: '有', value: 1 },
{ label: '无', value: 2 },
];
export const MEDICATION_OPTIONS = [
{ label: '是', value: 1 },
{ label: '否', value: 2 },
];
export const REWARD_OPTIONS = [
{ label: '买件新衣服/装备', value: 1 },
{ label: '去旅行', value: 2 },
{ label: '和朋友聚会', value: 3 },
{ label: '其他', value: 4 },
];
export const FITNESS_SCENE_OPTIONS = [
{ label: '健身房', value: 1 },
{ label: '家', value: 2 },
{ label: '宿舍', value: 3 },
];
// ==================== 汇总映射表 ====================
/** 表单选项字典,按字段 key 索引 */
export const formOptions = {
gender: GENDER_OPTIONS,
hasFitnessFoundation: FITNESS_FOUNDATION_OPTIONS,
acceptableTrainingFrequency: TRAINING_FREQUENCY_OPTIONS,
targetMuscleParts: TARGET_MUSCLE_OPTIONS,
trainingGoal: TRAINING_GOAL_OPTIONS,
painAreas: PAIN_AREA_OPTIONS,
hasDisease: DISEASE_OPTIONS,
isTakingMedication: MEDICATION_OPTIONS,
rewardMethod: REWARD_OPTIONS,
fitnessScene: FITNESS_SCENE_OPTIONS,
};
// ==================== 文本映射表 ====================
/** 训练目标文案映射 */
export const GOAL_LABEL_MAP = {
1: '减脂',
2: '增肌',
3: '塑形',
4: '拉伸/体态调整',
};
/** 奖励方式文案映射 */
export const REWARD_LABEL_MAP = {
1: '买件新衣服/装备',
2: '去旅行',
3: '和朋友聚会',
4: '其他',
};
/** 健身场景文案映射 */
export const SCENE_LABEL_MAP = {
1: '健身房',
2: '家',
3: '宿舍',
};
/**
* 通用查表格式化函数
* @param {*} value 待映射的值
* @param {Object} map 映射表
* @returns {string} 对应的文案,未匹配返回空字符串
*/
export function lookupLabel(value, map) {
return map[value] || '';
}
... ...
... ... @@ -11,7 +11,7 @@ import mpliveMainfestPlugin from './sheep/libs/mplive-manifest-plugin';
export default ({ command, mode }) => {
const env = loadEnv(mode, __dirname, 'SHOPRO_');
return {
envPrefix: 'SHOPRO_',
envPrefix: ['SHOPRO_', 'APP_', 'DEFAULT_'],
plugins: [
uni(),
// viteCompression({
... ...