Files
shoot-miniprograms/src/pages/training/index.vue

886 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import { 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";
// 后端训练项目 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 },
];
// 页面始终直接消费接口字段,这里只保留一份兜底结构,避免模板访问空值。
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 showRoutineTargetPicker = ref(false);
const trainingRadarCanvasId = "training-home-radar";
const radarImageWidth = 225;
const radarImageHeight = 224;
const radarFigureWidthRpx = 448;
const radarFigureHeightRpx = Math.round(
(radarFigureWidthRpx * radarImageHeight) / radarImageWidth
);
const radarCanvasWidth = Math.round(uni.upx2px(radarFigureWidthRpx));
const radarCanvasHeight = Math.round(uni.upx2px(radarFigureHeightRpx));
const radarScaleX = radarCanvasWidth / radarImageWidth;
const radarScaleY = radarCanvasHeight / radarImageHeight;
const radarScale = Math.min(radarScaleX, radarScaleY);
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 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 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 = 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,
Math.min(Number(item.score) || 0, radarMaxValue)
);
const progress = normalized / radarMaxValue;
return getRadarPoint(
radarCenterX,
radarCenterY,
radarOuterRadiusX * progress,
radarOuterRadiusY * progress,
angles[index]
);
});
ctx.beginPath();
points.forEach((point, index) => {
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)");
ctx.fill();
ctx.setStrokeStyle("rgba(220, 162, 92, 0.92)");
ctx.setLineWidth(radarStrokeWidth);
ctx.stroke();
points.forEach((point) => {
ctx.beginPath();
ctx.arc(point.x, point.y, radarPointRadius, 0, 2 * Math.PI);
ctx.setFillStyle("rgba(221, 162, 90, 1)");
ctx.fill();
});
ctx.beginPath();
ctx.arc(radarCenterX, radarCenterY, radarPointRadius, 0, 2 * Math.PI);
ctx.setFillStyle("rgba(125, 107, 83, 0.65)");
ctx.fill();
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.navigateTo({
url: "/pages/my-growth?tab=2",
});
};
const openTrainingItem = (item = {}) => {
const mode = getTrainingMode(item);
if (!mode) return;
if (item.is_locked) {
uni.showToast({
title: `${item.name || "训练"} 暂未开放`,
icon: "none",
});
return;
}
uni.navigateTo({
url: `/pages/training/difficulty?mode=${mode}`,
});
};
const openRoutineTraining = () => {
showRoutineTargetPicker.value = true;
};
const handleRoutineTargetConfirm = (target) => {
showRoutineTargetPicker.value = false;
uni.navigateTo({
url: `/pages/practise-one?target=${target}`,
});
};
// 首次进入页面时拉取数据并完成雷达图初始化。
onMounted(async () => {
await loadPersonalTrainingData();
pageMounted.value = true;
});
// 从其他页面返回时刷新训练数据,保持进度与推荐状态最新。
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 trainingData.week_days"
:key="item.day"
class="week-item"
>
<view class="week-item-bg"></view>
<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 === 'checked' }"
>
{{ 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/img_17.png"
mode="widthFix"
/>
<image
class="stats-quote stats-quote-right"
src="../../static/training-home/img_16.png"
mode="widthFix"
/>
<view class="stats-grid">
<view class="stats-item">
<view class="stats-value-row">
<view class="stats-value-group">
<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">共训练</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/img_28.png"
mode="widthFix"
/>
<view class="record-bubble-copy">
<view class="record-main">
已超越<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/img_7.png"
mode="widthFix"
/>
</view>
</view>
</view>
<view class="radar-board">
<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">
{{ trainingData.radar.dimensions[2].name }}
</text>
<text class="radar-label radar-label-bottom-left">
{{ trainingData.radar.dimensions[3].name }}
</text>
<text class="radar-label radar-label-left">
{{ trainingData.radar.dimensions[4].name }}
</text>
<view class="radar-figure" :style="radarFigureStyle">
<image
class="radar-grid-image"
:style="radarFigureStyle"
src="../../static/training-home/img_19.png"
/>
<canvas
:canvas-id="trainingRadarCanvasId"
:id="trainingRadarCanvasId"
class="radar-canvas"
:style="radarFigureStyle"
:width="radarCanvasWidth"
:height="radarCanvasHeight"
/>
<image
class="radar-mascot"
src="../../static/training-home/img_21.png"
mode="widthFix"
/>
</view>
</view>
</view>
<view class="featured-card" @click="openRoutineTraining">
<image
class="featured-card-bg"
src="../../static/training-home/img_22.png"
mode="widthFix"
/>
<view class="featured-card-mask"></view>
<view class="featured-card-copy">
<text class="featured-card-title">常规训练</text>
<text class="featured-card-subtitle">12箭练习</text>
</view>
</view>
<view class="mode-grid">
<view
v-for="item in trainingData.training_items.filter((item) => item.id !== 'strength')"
:key="item.id"
class="mode-card"
@click="openTrainingItem(item)"
>
<view v-if="item.is_recommended" class="mode-tag">推荐</view>
<view class="mode-card-copy">
<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="getTrainingIcon(item)"
mode="aspectFit"
/>
</view>
</view>
</view>
<TargetPicker
:show="showRoutineTargetPicker"
:onClose="() => (showRoutineTargetPicker = false)"
:onConfirm="handleRoutineTargetConfirm"
/>
</Container>
</template>
<style scoped>
.training-home {
position: relative;
overflow: hidden;
padding: 18rpx 20rpx 60rpx 20rpx;
}
.week-grid {
display: flex;
justify-content: space-between;
margin-top: 18rpx;
}
.week-item {
position: relative;
width: 92rpx;
height: 96rpx;
border-radius: 16rpx;
overflow: hidden;
}
.week-item-bg {
width: 100%;
height: 100%;
background: linear-gradient(180deg, #2f2d2b 0%, #252831 100%);
opacity: 0.5;
}
.week-item-icon {
position: absolute;
left: 28rpx;
top: 14rpx;
width: 36rpx;
}
.week-item-label {
position: absolute;
left: 0;
right: 0;
bottom: 10rpx;
color: rgba(255, 255, 255, 0.6);
font-size: 20rpx;
text-align: center;
line-height: 28rpx;
}
.week-item-label-active {
color: #e7ba80;
}
.stats-card {
position: relative;
margin-top: 32rpx;
width: 100%;
height: 124rpx;
overflow: hidden;
border-radius: 24rpx;
}
.stats-card-bg {
position: absolute;
inset: 0;
background: linear-gradient(180deg, #2f2d2b 0%, #252831 100%);
opacity: 0.5;
}
.stats-quote {
position: absolute;
z-index: 1;
width: 53rpx;
height: 50rpx;
}
.stats-quote-left {
left: 4rpx;
top: 4rpx;
}
.stats-quote-right {
right: 4rpx;
bottom: 4rpx;
}
.stats-grid {
position: absolute;
z-index: 1;
left: 36rpx;
right: 36rpx;
top: 22rpx;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.stats-item {
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.stats-value-row {
display: flex;
align-items: flex-end;
justify-content: center;
width: 100%;
height: 48rpx;
line-height: 48rpx;
}
.stats-value-group {
position: relative;
display: inline-flex;
align-items: flex-end;
justify-content: center;
min-width: 72rpx;
white-space: nowrap;
}
.stats-value-decoration {
position: absolute;
left: 0;
right: 0;
bottom: 6rpx;
min-width: 72rpx;
height: 12rpx;
border-radius: 6rpx;
background: linear-gradient(133deg, #ffd19a 0%, #a17636 100%);
opacity: 0.5;
}
.stats-value {
position: relative;
z-index: 1;
color: #fff;
font-size: 34rpx;
font-family: Helvetica, Arial, sans-serif;
font-weight: 500;
line-height: 46rpx;
}
.stats-unit {
position: relative;
z-index: 1;
margin-left: 4rpx;
padding-bottom: 8rpx;
color: #fff;
font-size: 20rpx;
line-height: 28rpx;
opacity: 0.6;
}
.stats-label {
display: inline-block;
margin-top: 6rpx;
color: #fcce96;
font-size: 20rpx;
line-height: 28rpx;
opacity: 0.6;
}
.radar-section {
position: relative;
padding-top: 34rpx;
}
.record-bubble {
position: absolute;
right: 0;
top: 10rpx;
width: 202rpx;
height: 122rpx;
z-index: 3;
}
.record-bubble-bg {
width: 202rpx;
}
.record-bubble-copy {
position: absolute;
left: 0;
right: 0;
top: 24rpx;
text-align: center;
}
.record-main {
color: #fff;
font-size: 24rpx;
line-height: 30rpx;
}
.record-main-highlight {
color: #e7ba80;
}
.record-sub-row {
margin-top: 4rpx;
display: flex;
align-items: center;
justify-content: center;
}
.record-sub-text {
color: #ffd947;
font-size: 24rpx;
height: 30rpx;
line-height: 30rpx;
}
.record-arrow {
width: 24rpx;
}
.radar-board {
position: relative;
width: 100%;
height: 514rpx;
}
.radar-label {
position: absolute;
color: rgba(255, 255, 255, 0.78);
font-size: 28rpx;
line-height: 40rpx;
}
.radar-label-top {
left: 350rpx;
top: 14rpx;
transform: translateX(-50%);
opacity: 0.5;
}
.radar-label-right {
right: 77rpx;
top: 190rpx;
opacity: 0.5;
}
.radar-label-bottom-right {
right: 170rpx;
bottom: 18rpx;
opacity: 0.5;
}
.radar-label-bottom-left {
left: 170rpx;
bottom: 18rpx;
opacity: 0.5;
}
.radar-label-left {
left: 75rpx;
top: 180rpx;
opacity: 0.5;
}
.radar-figure {
position: absolute;
left: 50%;
top: 54rpx;
transform: translateX(-50%);
overflow: visible;
}
.radar-grid-image,
.radar-canvas {
position: absolute;
left: 0;
top: 0;
}
.radar-mascot {
position: absolute;
right: 38rpx;
top: 0;
width: 92rpx;
}
.featured-card {
position: relative;
width: 100%;
height: 150rpx;
margin-top: 70rpx;
border-radius: 16rpx;
overflow: hidden;
}
.featured-card-bg {
width: 100%;
}
.featured-card-mask {
position: absolute;
left: 0;
top: 0;
width: 278rpx;
height: 150rpx;
background: linear-gradient(90deg, #ffdaa0 0%, #f5c580 74%, rgba(245, 197, 128, 0) 100%);
}
.featured-card-copy {
position: absolute;
left: 30rpx;
top: 34rpx;
display: flex;
flex-direction: column;
}
.featured-card-title {
display: block;
color: #895409;
font-size: 34rpx;
font-family: "AlimamaShuHeiTi-Bold", "PingFang SC", sans-serif;
font-weight: 700;
line-height: 42rpx;
}
.featured-card-subtitle {
display: block;
margin-top: 10rpx;
color: #895409;
font-size: 22rpx;
line-height: 32rpx;
opacity: 0.72;
}
.mode-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16rpx 18rpx;
margin-top: 16rpx;
}
.mode-card {
position: relative;
height: 150rpx;
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-tag {
position: absolute;
left: 0;
top: 0;
width: 72rpx;
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 {
position: absolute;
left: 30rpx;
top: 40rpx;
}
.mode-card-title {
display: block;
background-image: linear-gradient(
133deg,
rgba(235, 184, 123, 0.8) 0%,
rgba(181, 140, 78, 0.8) 100%
);
color: #e7ba80;
font-size: 32rpx;
font-family: "AlimamaShuHeiTi-Bold", "PingFang SC", sans-serif;
font-weight: 700;
line-height: 38rpx;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.mode-card-title-image {
display: block;
width: 128rpx;
}
.mode-card-progress {
display: block;
margin-top: 14rpx;
color: #fcce96;
font-size: 22rpx;
line-height: 32rpx;
opacity: 0.5;
}
.mode-card-icon {
position: absolute;
right: 12rpx;
top: 14rpx;
width: 124rpx;
height: 124rpx;
}
</style>