update:对接个人训练改版

This commit is contained in:
2026-07-23 09:44:58 +08:00
parent a78ab1daeb
commit 3c5754b3fd
64 changed files with 1769 additions and 488 deletions
+96 -27
View File
@@ -1,12 +1,12 @@
<script setup>
import { nextTick, onMounted, ref } from "vue";
import { computed, nextTick, onMounted, ref } from "vue";
import { onShow } from "@dcloudio/uni-app";
import Container from "@/components/Container.vue";
import TargetPicker from "@/components/TargetPicker.vue";
import { getPersonalTrainingAPI } from "@/apis";
const checkedIcon = "../../static/training-home/done.png";
const missedIcon = "../../static/training-home/missed.png";
const checkedIcon = "./static/training-home/done.png";
const missedIcon = "./static/training-home/missed.png";
// 后端训练项目 id 与难度页 mode 参数的映射关系。
const trainingModeRouteMap = {
base: "basic",
@@ -17,18 +17,18 @@ const trainingModeRouteMap = {
};
// 训练项目卡片右侧主图标。
const trainingModeIconMap = {
base_bow: "../../static/training-home/img_22.png",
bow: "../../static/training-home/img_3.png",
target: "../../static/training-home/img_4.png",
wave: "../../static/training-home/img_5.png",
muscle: "../../static/training-home/img_6.png",
base_bow: "./static/training-home/img_22.png",
bow: "./static/training-home/img_3.png",
target: "./static/training-home/img_4.png",
wave: "./static/training-home/img_5.png",
muscle: "./static/training-home/img_6.png",
};
// 训练项目卡片标题图,按接口 id 映射本地资源。
const trainingModeTitleImageMap = {
endurance: "../../static/training-home/nailixunlian.png",
precision: "../../static/training-home/jingzhunxunlian.png",
rhythm: "../../static/training-home/jiezouxunlian.png",
strength: "../../static/training-home/liliangxulian.png",
endurance: "./static/training-home/nailixunlian.png",
precision: "./static/training-home/jingzhunxunlian.png",
rhythm: "./static/training-home/jiezouxunlian.png",
strength: "./static/training-home/liliangxulian.png",
};
const defaultWeekDays = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
const defaultRadarDimensions = [
@@ -38,6 +38,13 @@ const defaultRadarDimensions = [
{ name: "节奏", score: 0 },
{ name: "耐力", score: 0 },
];
const radarDimensionTrainingIdMap = Object.freeze({
基础: "base",
精准: "precision",
力量: "strength",
节奏: "rhythm",
耐力: "endurance",
});
// 页面始终直接消费接口字段,这里只保留一份兜底结构,避免模板访问空值。
const createDefaultTrainingData = () => ({
@@ -50,6 +57,7 @@ const createDefaultTrainingData = () => ({
total_calories: 0,
overtake_rate: 0,
},
radar_max: 0,
radar: {
dimensions: defaultRadarDimensions,
},
@@ -57,6 +65,12 @@ const createDefaultTrainingData = () => ({
});
const trainingData = ref(createDefaultTrainingData());
const recommendedTrainingId = ref("");
const visibleTrainingItems = computed(() =>
Array.isArray(trainingData.value.training_items)
? trainingData.value.training_items.filter((item) => item.id !== "strength")
: []
);
const pageMounted = ref(false);
const showRoutineTargetPicker = ref(false);
const trainingRadarCanvasId = "training-home-radar";
@@ -77,7 +91,6 @@ const radarStrokeWidth = Math.max(1, 2 * radarScale);
const radarPointRadius = Math.max(2.5, 3.5 * radarScale);
const radarOuterRadiusX = 110.7089 * radarScaleX;
const radarOuterRadiusY = 110.7089 * radarScaleY;
const radarMaxValue = 100;
const radarFigureStyle = {
width: `${radarFigureWidthRpx}rpx`,
height: `${radarFigureHeightRpx}rpx`,
@@ -113,26 +126,78 @@ const getTrainingTitleImage = (item = {}) =>
const getTrainingMode = (item = {}) =>
trainingModeRouteMap[item.id] || item.id || "";
const updateRecommendedTraining = () => {
recommendedTrainingId.value = "";
const radarMaxValue = Number(trainingData.value.radar_max);
const dimensions = trainingData.value.radar?.dimensions;
if (
!Number.isFinite(radarMaxValue) ||
radarMaxValue <= 0 ||
!Array.isArray(dimensions) ||
dimensions.length !== 5
) {
return;
}
const visibleTrainingIds = visibleTrainingItems.value.map((item) => item.id);
const candidates = dimensions.reduce((result, item) => {
const trainingId = radarDimensionTrainingIdMap[item?.name];
const rawScore = item?.score;
if (
!visibleTrainingIds.includes(trainingId) ||
rawScore === undefined ||
rawScore === null ||
rawScore === ""
) {
return result;
}
const score = Number(rawScore);
if (!Number.isFinite(score) || score < 0) return result;
result.push({ trainingId, score });
return result;
}, []);
if (!candidates.length) return;
const lowestScore = Math.min(...candidates.map((item) => item.score));
const lowestCandidates = candidates.filter(
(item) => item.score === lowestScore
);
const selectedIndex = Math.floor(Math.random() * lowestCandidates.length);
recommendedTrainingId.value =
lowestCandidates[selectedIndex]?.trainingId || "";
};
const getRadarPoint = (centerX, centerY, radiusX, radiusY, angle) => ({
x: centerX + radiusX * Math.cos(angle),
y: centerY + radiusY * Math.sin(angle),
});
// 雷达图直接使用接口的 5 维 score,按 0-100 等比映射到顶点位置。
// 雷达图直接使用接口的 5 维 score,按后端 radar_max 等比映射到顶点位置。
const drawRadar = () => {
const dimensions = Array.isArray(trainingData.value.radar?.dimensions)
? trainingData.value.radar.dimensions.slice(0, 5)
: [];
if (dimensions.length !== 5) return;
const radarMaxValue = Number(trainingData.value.radar_max);
const ctx = uni.createCanvasContext(trainingRadarCanvasId);
ctx.clearRect(0, 0, radarCanvasWidth, radarCanvasHeight);
if (
dimensions.length !== 5 ||
!Number.isFinite(radarMaxValue) ||
radarMaxValue <= 0
) {
ctx.draw();
return;
}
const angles = dimensions.map(
(_, index) => (-90 + index * 72) * (Math.PI / 180)
);
ctx.clearRect(0, 0, radarCanvasWidth, radarCanvasHeight);
const points = dimensions.map((item, index) => {
const normalized = Math.max(
0,
@@ -199,6 +264,7 @@ const loadPersonalTrainingData = async () => {
total_calories: result?.stats?.total_calories ?? 0,
overtake_rate: result?.stats?.overtake_rate ?? 0,
},
radar_max: result?.radar_max ?? 0,
radar: {
dimensions:
Array.isArray(result?.radar?.dimensions) &&
@@ -214,6 +280,7 @@ const loadPersonalTrainingData = async () => {
console.log("personal training load failed", error);
trainingData.value = createDefaultTrainingData();
} finally {
updateRecommendedTraining();
await refreshRadar();
}
};
@@ -293,12 +360,12 @@ onShow(async () => {
<view class="stats-card-bg"></view>
<image
class="stats-quote stats-quote-left"
src="../../static/training-home/img_17.png"
src="./static/training-home/img_17.png"
mode="widthFix"
/>
<image
class="stats-quote stats-quote-right"
src="../../static/training-home/img_16.png"
src="./static/training-home/img_16.png"
mode="widthFix"
/>
<view class="stats-grid">
@@ -373,7 +440,7 @@ onShow(async () => {
<view class="record-bubble" @click="openTrainingRecord">
<image
class="record-bubble-bg"
src="../../static/training-home/img_28.png"
src="./static/training-home/img_28.png"
mode="widthFix"
/>
<view class="record-bubble-copy">
@@ -384,7 +451,7 @@ onShow(async () => {
<text class="record-sub-text">我的训练记录</text>
<image
class="record-arrow"
src="../../static/training-home/img_7.png"
src="./static/training-home/img_7.png"
mode="widthFix"
/>
</view>
@@ -412,7 +479,7 @@ onShow(async () => {
<image
class="radar-grid-image"
:style="radarFigureStyle"
src="../../static/training-home/img_19.png"
src="./static/training-home/img_19.png"
/>
<canvas
:canvas-id="trainingRadarCanvasId"
@@ -424,7 +491,7 @@ onShow(async () => {
/>
<image
class="radar-mascot"
src="../../static/training-home/img_21.png"
src="./static/training-home/img_21.png"
mode="widthFix"
/>
</view>
@@ -434,7 +501,7 @@ onShow(async () => {
<view class="featured-card" @click="openRoutineTraining">
<image
class="featured-card-bg"
src="../../static/training-home/img_22.png"
src="./static/training-home/img_22.png"
mode="widthFix"
/>
<view class="featured-card-mask"></view>
@@ -446,12 +513,14 @@ onShow(async () => {
<view class="mode-grid">
<view
v-for="item in trainingData.training_items.filter((item) => item.id !== 'strength')"
v-for="item in visibleTrainingItems"
:key="item.id"
class="mode-card"
@click="openTrainingItem(item)"
>
<view v-if="item.is_recommended" class="mode-tag">推荐</view>
<view v-if="item.id === recommendedTrainingId" class="mode-tag">
推荐
</view>
<view class="mode-card-copy">
<image
v-if="getTrainingTitleImage(item)"