Authored by Bad

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

@@ -8,7 +8,7 @@ SHOPRO_VERSION=v2.4.1 @@ -8,7 +8,7 @@ SHOPRO_VERSION=v2.4.1
8 # 后端接口 - 测试环境(通过 process.env.NODE_ENV = development) 8 # 后端接口 - 测试环境(通过 process.env.NODE_ENV = development)
9 SHOPRO_DEV_BASE_URL=http://192.168.1.200:48081 9 SHOPRO_DEV_BASE_URL=http://192.168.1.200:48081
10 # SHOPRO_DEV_BASE_URL=http://192.168.1.85:48080 10 # SHOPRO_DEV_BASE_URL=http://192.168.1.85:48080
11 - SHOPRO_DEV_BASE_URL=https://xunji.geaktec.com 11 + #SHOPRO_DEV_BASE_URL=https://xunji.geaktec.com
12 # SHOPRO_DEV_BASE_URL=http://api-dashboard.yudao.iocoder.cn/ 12 # SHOPRO_DEV_BASE_URL=http://api-dashboard.yudao.iocoder.cn/
13 ### SHOPRO_DEV_BASE_URL=http://10.171.1.188:48080 13 ### SHOPRO_DEV_BASE_URL=http://10.171.1.188:48080
14 ### SHOPRO_DEV_BASE_URL = http://yunai.natapp1.cc 14 ### SHOPRO_DEV_BASE_URL = http://yunai.natapp1.cc
@@ -27,15 +27,21 @@ @@ -27,15 +27,21 @@
27 </view> 27 </view>
28 28
29 <template v-if="resdailyData.length > 0"> 29 <template v-if="resdailyData.length > 0">
  30 + <!-- Tab 按钮:如���有多个训记,用来源名作为标签 -->
30 <scroll-view class="plan-tabs" scroll-x v-if="resdailyData.length > 1"> 31 <scroll-view class="plan-tabs" scroll-x v-if="resdailyData.length > 1">
31 <button class="tab-btn" :class="{ active: currentPlanIndex === index }" v-for="(item, index) in resdailyData" 32 <button class="tab-btn" :class="{ active: currentPlanIndex === index }" v-for="(item, index) in resdailyData"
32 :key="index" @click.stop="switchPlan(index)"> 33 :key="index" @click.stop="switchPlan(index)">
33 - 训记{{ index + 1 }} 34 + {{ item.sourceType === 1 || item.sourceType === 2 ? (item.sourceName || item.name) : (item.name || '训记' + (index + 1)) }}
34 </button> 35 </button>
35 </scroll-view> 36 </scroll-view>
36 - <!-- 接口获取当天的多个训练计划 → 放进 resdailyData,currentPlan 自动变成 resdailyData[0] 一个模板-->  
37 <!-- 训练内容区域 --> 37 <!-- 训练内容区域 -->
38 <view v-if="resdailyData.length > 0 && currentPlan" class="training-container"> 38 <view v-if="resdailyData.length > 0 && currentPlan" class="training-container">
  39 + <!-- 训练来源标签 -->
  40 + <view class="source-tag-row" :class="getSourceClass(currentPlan.sourceType || 4)">
  41 + <text class="source-text">
  42 + {{ currentPlan.sourceType === 1 || currentPlan.sourceType === 2 ? (currentPlan.sourceName || currentPlan.name) : getSourceLabel(currentPlan.sourceType || 4) }}
  43 + </text>
  44 + </view>
39 <view class="unitCart"> 45 <view class="unitCart">
40 <!-- 训练计划头部卡片 --> 46 <!-- 训练计划头部卡片 -->
41 <view class="plan-header-card"> 47 <view class="plan-header-card">
@@ -959,6 +965,34 @@ $radius-round: 40rpx; @@ -959,6 +965,34 @@ $radius-round: 40rpx;
959 font-weight: bold; 965 font-weight: bold;
960 } 966 }
961 967
  968 +/* 训练来源标签 */
  969 +.source-tag-row {
  970 + display: flex;
  971 + align-items: center;
  972 + padding: 16rpx 24rpx;
  973 + margin: 0rpx 20rpx;
  974 + border-radius: 12rpx;
  975 + font-size: 24rpx;
  976 + font-weight: 500;
  977 +
  978 + .source-text {
  979 + color: #fff;
  980 + }
  981 +
  982 + &.source-plan {
  983 + background: #3b82f6; /* 蓝色 - 计划 */
  984 + }
  985 + &.source-tpl {
  986 + background: #8b5cf6; /* 紫色 - 模板 */
  987 + }
  988 + &.source-custom {
  989 + background: #f59e0b; /* 橙色 - 自定义 */
  990 + }
  991 + &.source-free {
  992 + background: #10b981; /* 绿色 - 自由 */
  993 + }
  994 +}
  995 +
962 /* 训练计划头部卡片 */ 996 /* 训练计划头部卡片 */
963 .plan-header-card { 997 .plan-header-card {
964 display: flex; 998 display: flex;
@@ -247,17 +247,32 @@ const loadPlans = async (monthStr) => { @@ -247,17 +247,32 @@ const loadPlans = async (monthStr) => {
247 planMap[dateStr] = planMap[dateStr] || []; 247 planMap[dateStr] = planMap[dateStr] || [];
248 if (Array.isArray(plan.templates)) { 248 if (Array.isArray(plan.templates)) {
249 let totalWeightForDay = 0; 249 let totalWeightForDay = 0;
  250 + // 按 sourceType + sourceName 分组
  251 + const sourceMap = new Map();
250 plan.templates.forEach((template) => { 252 plan.templates.forEach((template) => {
251 - if (template && template.name) {  
252 - planMap[dateStr].push({  
253 - name: template.name, 253 + if (!template || !template.name) return;
  254 + const sourceKey = template.sourceType
  255 + ? `src_${template.sourceType}_${template.sourceName || ''}`
  256 + : `tpl_${template.dailyTemplateId}`;
  257 + if (!sourceMap.has(sourceKey)) {
  258 + sourceMap.set(sourceKey, {
  259 + name: template.sourceName || template.name,
254 bg: template.backgroundColor || '#fef08a', 260 bg: template.backgroundColor || '#fef08a',
  261 + sourceType: template.sourceType || 4,
  262 + sourceName: template.sourceName || template.name,
  263 + dailyTemplateId: template.dailyTemplateId,
  264 + totalWeight: 0,
255 }); 265 });
256 } 266 }
  267 + const group = sourceMap.get(sourceKey);
257 if (template && typeof template.totalWeight === 'number') { 268 if (template && typeof template.totalWeight === 'number') {
  269 + group.totalWeight += template.totalWeight;
258 totalWeightForDay += template.totalWeight; 270 totalWeightForDay += template.totalWeight;
259 } 271 }
260 }); 272 });
  273 + sourceMap.forEach((group) => {
  274 + planMap[dateStr].push(group);
  275 + });
261 if (totalWeightForDay > 0) { 276 if (totalWeightForDay > 0) {
262 weightMap[dateStr] = totalWeightForDay; 277 weightMap[dateStr] = totalWeightForDay;
263 } 278 }
@@ -392,9 +392,9 @@ const listData = computed(() => { @@ -392,9 +392,9 @@ const listData = computed(() => {
392 { label: '次数', last: d.lastWeekTotalCount, curr: d.totalCount }, 392 { label: '次数', last: d.lastWeekTotalCount, curr: d.totalCount },
393 ]; 393 ];
394 394
395 - return fields.map(({ label, last, curr }) => {  
396 - const lastVal = last ?? 0;  
397 - const currVal = curr ?? 0; 395 + return fields.map(({ label, key, last, curr }) => {
  396 + const lastVal = convertUnit(last, key);
  397 + const currVal = convertUnit(curr, key);
398 return { 398 return {
399 label, 399 label,
400 lastPeriod: lastVal, 400 lastPeriod: lastVal,
@@ -404,6 +404,20 @@ const listData = computed(() => { @@ -404,6 +404,20 @@ const listData = computed(() => {
404 }); 404 });
405 }); 405 });
406 406
  407 +/**
  408 + * 字段单位换算:API 返回秒/米等,UI 展示为分/km 等
  409 + * duration(秒)→ 分钟
  410 + * 其它字段(volume/setCount/distance/count)保持原值
  411 + */
  412 +const convertUnit = (val, key) => {
  413 + const v = Number(val) || 0;
  414 + if (key === 'duration') {
  415 + // 秒 → 分钟,保留 1 位小数(如 90s → 1.5)
  416 + return Math.round((v / 60) * 10) / 10;
  417 + }
  418 + return v;
  419 +};
  420 +
407 /** 肌肉弹窗图表 X 轴 */ 421 /** 肌肉弹窗图表 X 轴 */
408 const muscleChartCategories = computed(() => { 422 const muscleChartCategories = computed(() => {
409 if (MainCurrentDateType.value === 'week') { 423 if (MainCurrentDateType.value === 'week') {
@@ -8,7 +8,7 @@ @@ -8,7 +8,7 @@
8 <text class="time">{{ formattedTime.substring(3) }}</text> 8 <text class="time">{{ formattedTime.substring(3) }}</text>
9 <view class="triangle-icon"></view> 9 <view class="triangle-icon"></view>
10 </view> 10 </view>
11 - <view class="finish-btn" @click="save">完成</view> 11 + <view class="finish-btn" :class="{ disabled: !allUnitsChecked }" @click="save">完成</view>
12 </view> 12 </view>
13 13
14 <view class="action-list"> 14 <view class="action-list">
@@ -415,6 +415,14 @@ @@ -415,6 +415,14 @@
415 unitActiveStates.value = [...unitActiveStates.value] 415 unitActiveStates.value = [...unitActiveStates.value]
416 } 416 }
417 417
  418 + // 全部 unit 已勾选后才能提交
  419 + const allUnitsChecked = computed(() => {
  420 + const list = actionDetail.value?.units || []
  421 + if (list.length === 0) return false
  422 + if (unitActiveStates.value.length !== list.length) return false
  423 + return unitActiveStates.value.every((v) => v === true)
  424 + })
  425 +
418 const openSummaryPopup = () => { 426 const openSummaryPopup = () => {
419 summaryShow.value = true; 427 summaryShow.value = true;
420 summaryContent.value = ''; 428 summaryContent.value = '';
@@ -491,6 +499,17 @@ @@ -491,6 +499,17 @@
491 }); 499 });
492 500
493 const save = async () => { 501 const save = async () => {
  502 + // 前置校验:所有 unit 必须已勾选(顶部 ✓)才能提交
  503 + if (!allUnitsChecked.value) {
  504 + uni.showModal({
  505 + title: '提示',
  506 + content: '还有训练没有完成,请先勾选所有动作后再提交',
  507 + showCancel: false,
  508 + confirmText: '知道了',
  509 + });
  510 + return;
  511 + }
  512 +
494 await nextTick(); 513 await nextTick();
495 const units = []; 514 const units = [];
496 const unitList = actionDetail.value?.units || []; 515 const unitList = actionDetail.value?.units || [];
@@ -553,16 +572,17 @@ @@ -553,16 +572,17 @@
553 572
554 try { 573 try {
555 if (trainingStore.isTraining) { 574 if (trainingStore.isTraining) {
556 - await TrainingApi.createTrainHistory({ units }); 575 + await TrainingApi.createTrainHistory({ units, allCompleted: allUnitsChecked.value });
557 uni.showToast({ title: '训练提交成功', icon: 'success' }); 576 uni.showToast({ title: '训练提交成功', icon: 'success' });
558 } else if (trainingStore.type === 3 && trainingStore.dailyTemplateId) { 577 } else if (trainingStore.type === 3 && trainingStore.dailyTemplateId) {
559 await dailytemplateApi.updateDailyTemplate({ 578 await dailytemplateApi.updateDailyTemplate({
560 dailyTemplateId: trainingStore.dailyTemplateId, 579 dailyTemplateId: trainingStore.dailyTemplateId,
561 units, 580 units,
  581 + allCompleted: allUnitsChecked.value,
562 }); 582 });
563 uni.showToast({ title: '模板修改成功', icon: 'success' }); 583 uni.showToast({ title: '模板修改成功', icon: 'success' });
564 } else { 584 } else {
565 - await TrainingApi.createTrainHistory({ units }); 585 + await TrainingApi.createTrainHistory({ units, allCompleted: allUnitsChecked.value });
566 uni.showToast({ title: '训练提交成功', icon: 'success' }); 586 uni.showToast({ title: '训练提交成功', icon: 'success' });
567 } 587 }
568 setTimeout(() => { 588 setTimeout(() => {
@@ -682,6 +702,14 @@ @@ -682,6 +702,14 @@
682 border-radius: 30rpx; 702 border-radius: 30rpx;
683 font-weight: bold; 703 font-weight: bold;
684 font-size: 28rpx; 704 font-size: 28rpx;
  705 + transition: opacity 0.2s;
  706 +
  707 + &.disabled {
  708 + background-color: #555;
  709 + color: #999;
  710 + opacity: 0.6;
  711 + pointer-events: none;
  712 + }
685 } 713 }
686 } 714 }
687 715
@@ -8,6 +8,7 @@ const AuthUtil = { @@ -8,6 +8,7 @@ const AuthUtil = {
8 method: 'POST', 8 method: 'POST',
9 data, 9 data,
10 custom: { 10 custom: {
  11 + isToken: false,
11 showSuccess: true, 12 showSuccess: true,
12 successMsg: '登录成功', 13 successMsg: '登录成功',
13 }, 14 },
@@ -20,6 +21,7 @@ const AuthUtil = { @@ -20,6 +21,7 @@ const AuthUtil = {
20 method: 'POST', 21 method: 'POST',
21 data, 22 data,
22 custom: { 23 custom: {
  24 + isToken: false,
23 showSuccess: true, 25 showSuccess: true,
24 successMsg: '登录成功', 26 successMsg: '登录成功',
25 }, 27 },
@@ -32,6 +34,7 @@ const AuthUtil = { @@ -32,6 +34,7 @@ const AuthUtil = {
32 method: 'POST', 34 method: 'POST',
33 data, 35 data,
34 custom: { 36 custom: {
  37 + isToken: false,
35 showLoading: false, 38 showLoading: false,
36 }, 39 },
37 }); 40 });
@@ -67,7 +70,9 @@ const AuthUtil = { @@ -67,7 +70,9 @@ const AuthUtil = {
67 url: '/app/auth/setPassword', 70 url: '/app/auth/setPassword',
68 method: 'POST', 71 method: 'POST',
69 data, 72 data,
70 - 73 + custom: {
  74 + isToken: false,
  75 + },
71 }); 76 });
72 }, 77 },
73 78
@@ -113,6 +118,7 @@ const AuthUtil = { @@ -113,6 +118,7 @@ const AuthUtil = {
113 role: 1, 118 role: 1,
114 }, 119 },
115 custom: { 120 custom: {
  121 + isToken: false,
116 showSuccess: true, 122 showSuccess: true,
117 loadingMsg: '登录中', 123 loadingMsg: '登录中',
118 successMsg: '登录成功', 124 successMsg: '登录成功',
@@ -128,6 +134,7 @@ const AuthUtil = { @@ -128,6 +134,7 @@ const AuthUtil = {
128 code, 134 code,
129 }, 135 },
130 custom: { 136 custom: {
  137 + isToken: false,
131 showSuccess: true, 138 showSuccess: true,
132 loadingMsg: '登录中', 139 loadingMsg: '登录中',
133 successMsg: '登录成功', 140 successMsg: '登录成功',
@@ -400,6 +400,14 @@ export const useTrainingStore = defineStore('training', { @@ -400,6 +400,14 @@ export const useTrainingStore = defineStore('training', {
400 this.defaultTimeIndex = [0, 0, 0]; 400 this.defaultTimeIndex = [0, 0, 0];
401 this.showPicker = false; 401 this.showPicker = false;
402 this.isTraining = false; 402 this.isTraining = false;
  403 +
  404 + // 绕过 persist 插件的异步 watch,直接同步写入 storage
  405 + // 防止 navigateBack 后 persist flush 赶不及,导致 isTraining 残留为 true
  406 + try {
  407 + uni.setStorageSync('training', JSON.stringify(this.$state));
  408 + } catch (e) {
  409 + // 静默处理,不影响主流程
  410 + }
403 }, 411 },
404 }, 412 },
405 413