Authored by Bad

feat(xunji): 优化训练来源展示与提交流程

... ... @@ -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
... ...
... ... @@ -27,15 +27,21 @@
</view>
<template v-if="resdailyData.length > 0">
<!-- 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)">
训记{{ index + 1 }}
{{ item.sourceType === 1 || item.sourceType === 2 ? (item.sourceName || item.name) : (item.name || '训记' + (index + 1)) }}
</button>
</scroll-view>
<!-- 接口获取当天的多个训练计划 → 放进 resdailyData,currentPlan 自动变成 resdailyData[0] 一个模板-->
<!-- 训练内容区域 -->
<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">
... ... @@ -959,6 +965,34 @@ $radius-round: 40rpx;
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; /* 绿色 - 自由 */
}
}
/* 训练计划头部卡片 */
.plan-header-card {
display: flex;
... ...
... ... @@ -247,17 +247,32 @@ const loadPlans = async (monthStr) => {
planMap[dateStr] = planMap[dateStr] || [];
if (Array.isArray(plan.templates)) {
let totalWeightForDay = 0;
// 按 sourceType + sourceName 分组
const sourceMap = new Map();
plan.templates.forEach((template) => {
if (template && template.name) {
planMap[dateStr].push({
name: template.name,
if (!template || !template.name) return;
const sourceKey = template.sourceType
? `src_${template.sourceType}_${template.sourceName || ''}`
: `tpl_${template.dailyTemplateId}`;
if (!sourceMap.has(sourceKey)) {
sourceMap.set(sourceKey, {
name: template.sourceName || template.name,
bg: template.backgroundColor || '#fef08a',
sourceType: template.sourceType || 4,
sourceName: template.sourceName || template.name,
dailyTemplateId: template.dailyTemplateId,
totalWeight: 0,
});
}
const group = sourceMap.get(sourceKey);
if (template && typeof template.totalWeight === 'number') {
group.totalWeight += template.totalWeight;
totalWeightForDay += template.totalWeight;
}
});
sourceMap.forEach((group) => {
planMap[dateStr].push(group);
});
if (totalWeightForDay > 0) {
weightMap[dateStr] = totalWeightForDay;
}
... ...
... ... @@ -392,9 +392,9 @@ const listData = computed(() => {
{ label: '次数', last: d.lastWeekTotalCount, curr: d.totalCount },
];
return fields.map(({ label, last, curr }) => {
const lastVal = last ?? 0;
const currVal = curr ?? 0;
return fields.map(({ label, key, last, curr }) => {
const lastVal = convertUnit(last, key);
const currVal = convertUnit(curr, key);
return {
label,
lastPeriod: lastVal,
... ... @@ -404,6 +404,20 @@ const listData = computed(() => {
});
});
/**
* 字段单位换算:API 返回秒/米等,UI 展示为分/km 等
* duration(秒)→ 分钟
* 其它字段(volume/setCount/distance/count)保持原值
*/
const convertUnit = (val, key) => {
const v = Number(val) || 0;
if (key === 'duration') {
// 秒 → 分钟,保留 1 位小数(如 90s → 1.5)
return Math.round((v / 60) * 10) / 10;
}
return v;
};
/** 肌肉弹窗图表 X 轴 */
const muscleChartCategories = computed(() => {
if (MainCurrentDateType.value === 'week') {
... ...
... ... @@ -8,7 +8,7 @@
<text class="time">{{ formattedTime.substring(3) }}</text>
<view class="triangle-icon"></view>
</view>
<view class="finish-btn" @click="save">完成</view>
<view class="finish-btn" :class="{ disabled: !allUnitsChecked }" @click="save">完成</view>
</view>
<view class="action-list">
... ... @@ -415,6 +415,14 @@
unitActiveStates.value = [...unitActiveStates.value]
}
// 全部 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 = () => {
summaryShow.value = true;
summaryContent.value = '';
... ... @@ -491,6 +499,17 @@
});
const save = async () => {
// 前置校验:所有 unit 必须已勾选(顶部 ✓)才能提交
if (!allUnitsChecked.value) {
uni.showModal({
title: '提示',
content: '还有训练没有完成,请先勾选所有动作后再提交',
showCancel: false,
confirmText: '知道了',
});
return;
}
await nextTick();
const units = [];
const unitList = actionDetail.value?.units || [];
... ... @@ -553,16 +572,17 @@
try {
if (trainingStore.isTraining) {
await TrainingApi.createTrainHistory({ units });
await TrainingApi.createTrainHistory({ units, allCompleted: allUnitsChecked.value });
uni.showToast({ title: '训练提交成功', icon: 'success' });
} else if (trainingStore.type === 3 && trainingStore.dailyTemplateId) {
await dailytemplateApi.updateDailyTemplate({
dailyTemplateId: trainingStore.dailyTemplateId,
units,
allCompleted: allUnitsChecked.value,
});
uni.showToast({ title: '模板修改成功', icon: 'success' });
} else {
await TrainingApi.createTrainHistory({ units });
await TrainingApi.createTrainHistory({ units, allCompleted: allUnitsChecked.value });
uni.showToast({ title: '训练提交成功', icon: 'success' });
}
setTimeout(() => {
... ... @@ -682,6 +702,14 @@
border-radius: 30rpx;
font-weight: bold;
font-size: 28rpx;
transition: opacity 0.2s;
&.disabled {
background-color: #555;
color: #999;
opacity: 0.6;
pointer-events: none;
}
}
}
... ...
... ... @@ -8,6 +8,7 @@ const AuthUtil = {
method: 'POST',
data,
custom: {
isToken: false,
showSuccess: true,
successMsg: '登录成功',
},
... ... @@ -20,6 +21,7 @@ const AuthUtil = {
method: 'POST',
data,
custom: {
isToken: false,
showSuccess: true,
successMsg: '登录成功',
},
... ... @@ -32,6 +34,7 @@ const AuthUtil = {
method: 'POST',
data,
custom: {
isToken: false,
showLoading: false,
},
});
... ... @@ -67,7 +70,9 @@ const AuthUtil = {
url: '/app/auth/setPassword',
method: 'POST',
data,
custom: {
isToken: false,
},
});
},
... ... @@ -113,6 +118,7 @@ const AuthUtil = {
role: 1,
},
custom: {
isToken: false,
showSuccess: true,
loadingMsg: '登录中',
successMsg: '登录成功',
... ... @@ -128,6 +134,7 @@ const AuthUtil = {
code,
},
custom: {
isToken: false,
showSuccess: true,
loadingMsg: '登录中',
successMsg: '登录成功',
... ...
... ... @@ -400,6 +400,14 @@ export const useTrainingStore = defineStore('training', {
this.defaultTimeIndex = [0, 0, 0];
this.showPicker = false;
this.isTraining = false;
// 绕过 persist 插件的异步 watch,直接同步写入 storage
// 防止 navigateBack 后 persist flush 赶不及,导致 isTraining 残留为 true
try {
uni.setStorageSync('training', JSON.stringify(this.$state));
} catch (e) {
// 静默处理,不影响主流程
}
},
},
... ...