update:对接个人训练首页

This commit is contained in:
2026-05-20 16:36:07 +08:00
parent 465b9c8dc7
commit bae31add22
43 changed files with 965 additions and 165 deletions

View File

@@ -1,17 +1,62 @@
<script setup>
import { nextTick, onMounted } from "vue";
import { nextTick, onMounted, ref } from "vue";
import { onShow } from "@dcloudio/uni-app";
import Container from "@/components/Container.vue";
import {
trainingHomeFeatured,
trainingHomeModes,
trainingHomeRadar,
trainingHomeStats,
trainingHomeWeekSchedule,
} from "@/mock/index.js";
import { getPersonalTrainingAPI } from "@/apis";
const checkedIcon = "../../static/training-home/done.png";
const missedIcon = "../../static/training-home/missed.png";
// 后端训练项目 id 与难度页 mode 参数的映射关系。
const trainingModeRouteMap = {
base: "basic",
endurance: "endurance",
precision: "precision",
rhythm: "rhythm",
strength: "power",
};
// 训练项目卡片右侧主图标。
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",
};
// 训练项目卡片标题图,按接口 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",
};
const defaultWeekDays = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
const defaultRadarDimensions = [
{ name: "基础", score: 0 },
{ name: "精准", score: 0 },
{ name: "力量", score: 0 },
{ name: "节奏", score: 0 },
{ name: "耐力", score: 0 },
];
// 雷达图绘制仍使用原生 number 尺寸,样式展示统一使用 rpx
// 页面始终直接消费接口字段,这里只保留一份兜底结构,避免模板访问空值
const createDefaultTrainingData = () => ({
week_days: defaultWeekDays.map((day) => ({ day, status: "cross" })),
stats: {
total_training_days: 0,
total_arrows: 0,
hit_rate: 0,
endurance_shoot_speed: 0,
total_calories: 0,
overtake_rate: 0,
},
radar: {
dimensions: defaultRadarDimensions,
},
training_items: [],
});
const trainingData = ref(createDefaultTrainingData());
const pageMounted = ref(false);
const trainingRadarCanvasId = "training-home-radar";
const radarImageWidth = 225;
const radarImageHeight = 224;
@@ -24,37 +69,79 @@ const radarCanvasHeight = Math.round(uni.upx2px(radarFigureHeightRpx));
const radarScaleX = radarCanvasWidth / radarImageWidth;
const radarScaleY = radarCanvasHeight / radarImageHeight;
const radarScale = Math.min(radarScaleX, radarScaleY);
// Fit from img_19.png so value=10 lands on the actual outer circle.
const radarCenterX = 112.0624 * radarScaleX;
const radarCenterY = 111.4645 * radarScaleY;
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`,
};
const getRadarPoint = (centerX, centerY, radiusX, radiusY, angle) => {
return {
x: centerX + radiusX * Math.cos(angle),
y: centerY + radiusY * Math.sin(angle),
};
const formatValue = (value, digits = 1) => {
const numberValue = Number(value);
if (!Number.isFinite(numberValue)) return "--";
return String(Number(numberValue.toFixed(digits)));
};
const getLevelText = (item) => {
if (!item) return "";
const level = Number(item.current_level) || 0;
return item.is_locked ? `Coming! LV${level}` : `当前进度 LV${level} >`;
};
// 卡路里字段按需求做 K / W 缩写展示。
const getCaloriesValue = (value) => {
const numberValue = Number(value);
if (!Number.isFinite(numberValue)) return "--";
if (numberValue >= 10000) return `${formatValue(numberValue / 10000)}W`;
if (numberValue >= 1000) return `${formatValue(numberValue / 1000)}K`;
return formatValue(numberValue, 0);
};
const getTrainingIcon = (item = {}) =>
trainingModeIconMap[item.icon] || trainingModeIconMap.bow;
const getTrainingTitleImage = (item = {}) =>
trainingModeTitleImageMap[item.id] || "";
const getTrainingMode = (item = {}) =>
trainingModeRouteMap[item.id] || item.id || "";
const getFeaturedItem = () =>
trainingData.value.training_items.find((item) => item.id === "base") ||
trainingData.value.training_items[0];
const getRadarPoint = (centerX, centerY, radiusX, radiusY, angle) => ({
x: centerX + radiusX * Math.cos(angle),
y: centerY + radiusY * Math.sin(angle),
});
// 雷达图直接使用接口的 5 维 score按 0-100 等比映射到顶点位置。
const drawRadar = () => {
const dimensions = Array.isArray(trainingData.value.radar?.dimensions)
? trainingData.value.radar.dimensions.slice(0, 5)
: [];
if (dimensions.length !== 5) return;
const ctx = uni.createCanvasContext(trainingRadarCanvasId);
const angles = trainingHomeRadar.labels.map(
const angles = dimensions.map(
(_, index) => (-90 + index * 72) * (Math.PI / 180)
);
ctx.clearRect(0, 0, radarCanvasWidth, radarCanvasHeight);
// 五边形底图已经由设计切图承载,这里只叠加能力值多边形和节点。
const points = trainingHomeRadar.values.map((value, index) => {
const normalized = Math.max(0, Math.min(value, trainingHomeRadar.maxValue));
const progress = normalized / trainingHomeRadar.maxValue;
const points = dimensions.map((item, index) => {
const normalized = Math.max(
0,
Math.min(Number(item.score) || 0, radarMaxValue)
);
const progress = normalized / radarMaxValue;
return getRadarPoint(
radarCenterX,
radarCenterY,
@@ -66,11 +153,8 @@ const drawRadar = () => {
ctx.beginPath();
points.forEach((point, index) => {
if (index === 0) {
ctx.moveTo(point.x, point.y);
return;
}
ctx.lineTo(point.x, point.y);
if (index === 0) ctx.moveTo(point.x, point.y);
else ctx.lineTo(point.x, point.y);
});
ctx.closePath();
ctx.setFillStyle("rgba(255, 209, 154, 0.26)");
@@ -93,133 +177,238 @@ const drawRadar = () => {
ctx.draw();
};
// 这些入口先保留占位行为,等后续页面接入后再替换成真实跳转
// 小程序 canvas 首次渲染时机不稳定,延后一帧再绘制更稳
const refreshRadar = async () => {
await nextTick();
setTimeout(() => {
drawRadar();
}, 30);
};
const loadPersonalTrainingData = async () => {
try {
const result = await getPersonalTrainingAPI();
trainingData.value = {
week_days:
Array.isArray(result?.week_days) && result.week_days.length
? result.week_days
: createDefaultTrainingData().week_days,
stats: {
total_training_days: result?.stats?.total_training_days ?? 0,
total_arrows: result?.stats?.total_arrows ?? 0,
hit_rate: result?.stats?.hit_rate ?? 0,
endurance_shoot_speed: result?.stats?.endurance_shoot_speed ?? 0,
total_calories: result?.stats?.total_calories ?? 0,
overtake_rate: result?.stats?.overtake_rate ?? 0,
},
radar: {
dimensions:
Array.isArray(result?.radar?.dimensions) &&
result.radar.dimensions.length === 5
? result.radar.dimensions
: createDefaultTrainingData().radar.dimensions,
},
training_items: Array.isArray(result?.training_items)
? result.training_items
: [],
};
} catch (error) {
console.log("personal training load failed", error);
trainingData.value = createDefaultTrainingData();
} finally {
await refreshRadar();
}
};
const openTrainingRecord = () => {
uni.showToast({
title: "训练记录待接入",
icon: "none",
});
};
const openFeaturedTraining = () => {
uni.navigateTo({
url: "/pages/training/difficulty?mode=basic",
url: "/pages/my-growth?tab=2",
});
};
const openTrainingMode = (item) => {
if (item.disabled) {
const openTrainingItem = (item = {}) => {
const mode = getTrainingMode(item);
if (!mode) return;
if (item.is_locked) {
uni.showToast({
title: `${item.title} 暂未开放`,
title: `${item.name || "训练"} 暂未开放`,
icon: "none",
});
return;
}
uni.navigateTo({
url: `/pages/training/difficulty?mode=${item.key}`,
url: `/pages/training/difficulty?mode=${mode}`,
});
};
onMounted(() => {
nextTick(drawRadar);
const openFeaturedTraining = () => {
const item = getFeaturedItem();
if (item) openTrainingItem(item);
};
// 首次进入页面时拉取数据并完成雷达图初始化。
onMounted(async () => {
await loadPersonalTrainingData();
pageMounted.value = true;
});
onShow(() => {
nextTick(drawRadar);
// 从其他页面返回时刷新训练数据,保持进度与推荐状态最新。
onShow(async () => {
if (!pageMounted.value) return;
await loadPersonalTrainingData();
});
</script>
<template>
<Container :showBackToGame="true" :bgType="7" bgColor="#050b19">
<view class="training-home">
<!-- 周打卡区域 -->
<view class="week-grid">
<view
v-for="item in trainingHomeWeekSchedule"
:key="item.key"
v-for="item in trainingData.week_days"
:key="item.day"
class="week-item"
>
<view class="week-item-bg"></view>
<image class="week-item-icon" :src="item.icon" mode="widthFix" />
<image
class="week-item-icon"
:src="item.status === 'checked' ? checkedIcon : missedIcon"
mode="widthFix"
/>
<text
class="week-item-label"
:class="{ 'week-item-label-active': item.status === 'done' }"
:class="{ 'week-item-label-active': item.status === 'checked' }"
>
{{ item.label }}
{{ item.day }}
</text>
</view>
</view>
<!-- 训练统计卡片 -->
<view class="stats-card">
<view class="stats-card-bg"></view>
<image
class="stats-quote stats-quote-left"
src="../../static/training-home/slices/img_17.png"
src="../../static/training-home/img_17.png"
mode="widthFix"
/>
<image
class="stats-quote stats-quote-right"
src="../../static/training-home/slices/img_16.png"
src="../../static/training-home/img_16.png"
mode="widthFix"
/>
<view class="stats-grid">
<view
v-for="item in trainingHomeStats"
:key="item.key"
class="stats-item"
>
<view class="stats-item">
<view class="stats-value-row">
<view class="stats-value-group">
<text class="stats-value">{{ item.value }}</text>
<text class="stats-unit">{{ item.unit }}</text>
<text class="stats-value">
{{ formatValue(trainingData.stats.total_training_days, 0) }}
</text>
<text class="stats-unit"></text>
<view class="stats-value-decoration"></view>
</view>
</view>
<text class="stats-label">{{ item.label }}</text>
<text class="stats-label">共训练</text>
</view>
<view class="stats-item">
<view class="stats-value-row">
<view class="stats-value-group">
<text class="stats-value">
{{ formatValue(trainingData.stats.total_arrows, 0) }}
</text>
<text class="stats-unit"></text>
<view class="stats-value-decoration"></view>
</view>
</view>
<text class="stats-label">累计射箭</text>
</view>
<view class="stats-item">
<view class="stats-value-row">
<view class="stats-value-group">
<text class="stats-value">
{{ formatValue(trainingData.stats.hit_rate) }}
</text>
<text class="stats-unit">%</text>
<view class="stats-value-decoration"></view>
</view>
</view>
<text class="stats-label">命中率</text>
</view>
<view class="stats-item">
<view class="stats-value-row">
<view class="stats-value-group">
<text class="stats-value">
{{ formatValue(trainingData.stats.endurance_shoot_speed, 0) }}
</text>
<text class="stats-unit">/分钟</text>
<view class="stats-value-decoration"></view>
</view>
</view>
<text class="stats-label">耐力射击</text>
</view>
<view class="stats-item">
<view class="stats-value-row">
<view class="stats-value-group">
<text class="stats-value">
{{ getCaloriesValue(trainingData.stats.total_calories) }}
</text>
<text class="stats-unit">卡路里</text>
<view class="stats-value-decoration"></view>
</view>
</view>
<text class="stats-label">共消耗</text>
</view>
</view>
</view>
<!-- 雷达图与训练记录入口 -->
<view class="radar-section">
<view class="record-bubble" @click="openTrainingRecord">
<image
class="record-bubble-bg"
src="../../static/training-home/slices/img_28.png"
src="../../static/training-home/img_28.png"
mode="widthFix"
/>
<view class="record-bubble-copy">
<view class="record-main">
已超越<text class="record-main-highlight">
80%
</text>对手
已超越<text class="record-main-highlight">{{ formatValue(trainingData.stats.overtake_rate) }}%</text>对手
</view>
<view class="record-sub-row">
<text class="record-sub-text">我的训练记录</text>
<image class="record-arrow" src="../../static/training-home/slices/img_7.png" mode="widthFix" />
<image
class="record-arrow"
src="../../static/training-home/img_7.png"
mode="widthFix"
/>
</view>
</view>
</view>
<view class="radar-board">
<text class="radar-label radar-label-top">{{ trainingHomeRadar.labels[0] }}</text>
<text class="radar-label radar-label-right">{{ trainingHomeRadar.labels[1] }}</text>
<text class="radar-label radar-label-top">
{{ trainingData.radar.dimensions[0].name }}
</text>
<text class="radar-label radar-label-right">
{{ trainingData.radar.dimensions[1].name }}
</text>
<text class="radar-label radar-label-bottom-right">
{{ trainingHomeRadar.labels[2] }}
{{ trainingData.radar.dimensions[2].name }}
</text>
<text class="radar-label radar-label-bottom-left">
{{ trainingHomeRadar.labels[3] }}
{{ trainingData.radar.dimensions[3].name }}
</text>
<text class="radar-label radar-label-left">
{{ trainingData.radar.dimensions[4].name }}
</text>
<text class="radar-label radar-label-left">{{ trainingHomeRadar.labels[4] }}</text>
<view class="radar-figure" :style="radarFigureStyle">
<image
class="radar-grid-image"
:style="radarFigureStyle"
src="../../static/training-home/slices/img_19.png"
src="../../static/training-home/img_19.png"
/>
<canvas
:canvas-id="trainingRadarCanvasId"
@@ -231,42 +420,49 @@ onShow(() => {
/>
<image
class="radar-mascot"
src="../../static/training-home/slices/img_21.png"
src="../../static/training-home/img_21.png"
mode="widthFix"
/>
</view>
</view>
</view>
<!-- 主推荐训练入口 -->
<view class="featured-card" @click="openFeaturedTraining">
<image
class="featured-card-bg"
src="../../static/training-home/slices/img_22.png"
src="../../static/training-home/img_22.png"
mode="widthFix"
/>
<view class="featured-card-copy">
<text class="featured-card-title"></text>
<text class="featured-card-progress">{{ trainingHomeFeatured.progressText }}</text>
<text class="featured-card-progress">
{{ getLevelText(getFeaturedItem()) }}
</text>
</view>
</view>
<!-- 四个训练模式入口 -->
<view class="mode-grid">
<view
v-for="item in trainingHomeModes"
:key="item.key"
v-for="item in trainingData.training_items.filter((item) => item.id !== 'base')"
:key="item.id"
class="mode-card"
@click="openTrainingMode(item)"
@click="openTrainingItem(item)"
>
<view v-if="item.recommended" class="mode-tag">推荐</view>
<view v-if="item.is_recommended" class="mode-tag">推荐</view>
<view class="mode-card-copy">
<text class="mode-card-title">{{ item.title }}</text>
<text class="mode-card-progress">{{ item.progressText }}</text>
<image
v-if="getTrainingTitleImage(item)"
class="mode-card-title-image"
:src="getTrainingTitleImage(item)"
mode="widthFix"
/>
<text v-else class="mode-card-title">{{ item.name }}</text>
<text class="mode-card-progress">{{ getLevelText(item) }}</text>
</view>
<image class="mode-card-icon" :src="item.icon" mode="aspectFit" />
<image
class="mode-card-icon"
:src="getTrainingIcon(item)"
mode="aspectFit"
/>
</view>
</view>
</view>
@@ -278,32 +474,6 @@ onShow(() => {
position: relative;
overflow: hidden;
padding: 18rpx 20rpx 60rpx 20rpx;
}
.top-background {
position: absolute;
left: 0;
top: 0;
width: 100%;
}
.top-background {
z-index: 1;
}
.nav-row {
display: flex;
align-items: center;
}
.back-button {
display: flex;
align-items: center;
}
.back-icon {
width: 80rpx;
}
.week-grid {
@@ -323,7 +493,7 @@ onShow(() => {
.week-item-bg {
width: 100%;
height: 100%;
background: linear-gradient( 180deg, #2F2D2B 0%, #252831 100%);
background: linear-gradient(180deg, #2f2d2b 0%, #252831 100%);
opacity: 0.5;
}
@@ -339,7 +509,7 @@ onShow(() => {
left: 0;
right: 0;
bottom: 10rpx;
color: #fff;
color: rgba(255, 255, 255, 0.6);
font-size: 20rpx;
text-align: center;
line-height: 28rpx;
@@ -360,11 +530,8 @@ onShow(() => {
.stats-card-bg {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: linear-gradient( 180deg, #2F2D2B 0%, #252831 100%);
inset: 0;
background: linear-gradient(180deg, #2f2d2b 0%, #252831 100%);
opacity: 0.5;
}
@@ -427,7 +594,6 @@ onShow(() => {
left: 0;
right: 0;
bottom: 6rpx;
width: auto;
min-width: 72rpx;
height: 12rpx;
border-radius: 6rpx;
@@ -442,9 +608,7 @@ onShow(() => {
font-size: 34rpx;
font-family: Helvetica, Arial, sans-serif;
font-weight: 500;
line-height: 48rpx;
text-align: left;
font-style: normal;
line-height: 46rpx;
}
.stats-unit {
@@ -452,12 +616,9 @@ onShow(() => {
z-index: 1;
margin-left: 4rpx;
padding-bottom: 8rpx;
color: #ffffff;
color: #fff;
font-size: 20rpx;
font-weight: 400;
line-height: 28rpx;
text-align: left;
font-style: normal;
opacity: 0.6;
}
@@ -466,11 +627,7 @@ onShow(() => {
margin-top: 6rpx;
color: #fcce96;
font-size: 20rpx;
font-weight: 400;
line-height: 28rpx;
text-align: right;
font-style: normal;
white-space: nowrap;
opacity: 0.6;
}
@@ -496,7 +653,7 @@ onShow(() => {
position: absolute;
left: 0;
right: 0;
top: 18rpx;
top: 24rpx;
text-align: center;
}
@@ -525,8 +682,7 @@ onShow(() => {
}
.record-arrow {
width: 12rpx;
margin-left: 8rpx;
width: 24rpx;
}
.radar-board {
@@ -614,6 +770,13 @@ onShow(() => {
align-items: center;
}
.featured-card-title {
color: #895409;
font-size: 28rpx;
font-family: "AlimamaShuHeiTi-Bold", "PingFang SC", sans-serif;
font-weight: 700;
line-height: 32rpx;
}
.featured-card-progress {
margin-left: 18rpx;
@@ -632,30 +795,25 @@ onShow(() => {
.mode-card {
position: relative;
height: 150rpx;
box-shadow: inset 2rpx 2rpx 6rpx 0rpx rgba(255,255,255,0.27);
box-shadow: inset 2rpx 2rpx 6rpx 0rpx rgba(255, 255, 255, 0.27);
border-radius: 16rpx;
border: 2rpx solid rgba(235, 184, 123, 0.5);
background: rgba(0, 0, 0, 0.5);
overflow: hidden;
}
.mode-card-bg {
width: 100%;
}
.mode-tag {
position: absolute;
left: 0;
top: 0;
width: 72rpx;
background: linear-gradient( 133deg, #FFD19A 0%, #A17636 100%);
height: 34rpx;
line-height: 34rpx;
text-align: center;
font-size: 20rpx;
color: #000;
border-bottom-right-radius: 16rpx;
background: linear-gradient(133deg, #ffd19a 0%, #a17636 100%);
}
.mode-card-copy {
@@ -680,6 +838,11 @@ onShow(() => {
-webkit-text-fill-color: transparent;
}
.mode-card-title-image {
display: block;
width: 128rpx;
}
.mode-card-progress {
display: block;
margin-top: 14rpx;