个人训练改版首页存档

This commit is contained in:
2026-05-13 10:49:31 +08:00
parent 19391808ef
commit 1bca5977c1
29 changed files with 1729 additions and 1 deletions

View File

@@ -57,6 +57,12 @@ const props = defineProps({
src="https://static.shelingxingqiu.com/shootmini/static/rank/rank-bg.png"
mode="widthFix"
/>
<image
class="bg-image"
v-if="type === 7"
src="@/static/app-bg6.png"
mode="widthFix"
/>
<view class="bg-overlay" v-if="type === 0"></view>
</view>
</template>

View File

@@ -0,0 +1,200 @@
<template>
<view class="radar-panel">
<canvas
:canvas-id="canvasId"
:id="canvasId"
class="radar-canvas"
:style="{ width: `${displayWidth}rpx`, height: `${displayHeight}rpx` }"
:width="canvasWidth"
:height="canvasHeight"
/>
<text class="radar-label top">{{ labels[0] }}</text>
<text class="radar-label right">{{ labels[1] }}</text>
<text class="radar-label bottom-right">{{ labels[2] }}</text>
<text class="radar-label bottom-left">{{ labels[3] }}</text>
<text class="radar-label left">{{ labels[4] }}</text>
<TrainingRecordBubble
:surpass-text="surpassText"
:record-text="recordText"
@record="$emit('record')"
/>
</view>
</template>
<script setup>
import { getCurrentInstance, nextTick, onMounted, watch } from 'vue'
import TrainingRecordBubble from './TrainingRecordBubble.vue'
const props = defineProps({
labels: {
type: Array,
default: () => []
},
values: {
type: Array,
default: () => []
},
maxValue: {
type: Number,
default: 10
},
surpassText: {
type: String,
default: ''
},
recordText: {
type: String,
default: ''
}
})
defineEmits(['record'])
const canvasId = 'training-radar'
const canvasWidth = 280
const canvasHeight = 230
const displayWidth = 560
const displayHeight = 460
const instance = getCurrentInstance()
function getPoint(centerX, centerY, radius, angle) {
return {
x: centerX + radius * Math.cos(angle),
y: centerY + radius * Math.sin(angle)
}
}
function drawRadar() {
const target = instance?.proxy
const ctx = uni.createCanvasContext(canvasId, target)
const centerX = canvasWidth / 2
const centerY = 118
const layerCount = 4
const outerRadius = 88
const angles = props.labels.map((_, index) => (-90 + index * 72) * (Math.PI / 180))
ctx.clearRect(0, 0, canvasWidth, canvasHeight)
for (let layer = layerCount; layer >= 1; layer -= 1) {
const radius = (outerRadius / layerCount) * layer
ctx.beginPath()
angles.forEach((angle, index) => {
const point = getPoint(centerX, centerY, radius, angle)
if (index === 0) {
ctx.moveTo(point.x, point.y)
} else {
ctx.lineTo(point.x, point.y)
}
})
ctx.closePath()
ctx.setStrokeStyle(layer === layerCount ? 'rgba(125,107,83,0.75)' : 'rgba(125,107,83,0.4)')
ctx.setLineWidth(1)
ctx.stroke()
}
angles.forEach((angle) => {
const point = getPoint(centerX, centerY, outerRadius, angle)
ctx.beginPath()
ctx.moveTo(centerX, centerY)
ctx.lineTo(point.x, point.y)
ctx.setStrokeStyle('rgba(125,107,83,0.36)')
ctx.setLineWidth(1)
ctx.stroke()
})
const points = props.values.map((value, index) => {
const radius = (Math.max(0, Math.min(value, props.maxValue)) / props.maxValue) * outerRadius
return getPoint(centerX, centerY, radius, 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(212, 156, 89, 0.12)')
ctx.fill()
ctx.setStrokeStyle('rgba(212, 156, 89, 0.95)')
ctx.setLineWidth(2)
ctx.stroke()
points.forEach((point) => {
ctx.beginPath()
ctx.arc(point.x, point.y, 4, 0, 2 * Math.PI)
ctx.setFillStyle('rgba(212, 156, 89, 1)')
ctx.fill()
})
ctx.beginPath()
ctx.arc(centerX, centerY, 4, 0, 2 * Math.PI)
ctx.setFillStyle('rgba(125,107,83,0.7)')
ctx.fill()
ctx.draw()
}
onMounted(() => {
nextTick(drawRadar)
})
watch(
() => props.values,
() => {
nextTick(drawRadar)
},
{ deep: true }
)
</script>
<style scoped>
.radar-panel {
position: relative;
margin-top: 24rpx;
padding: 26rpx 0 18rpx;
min-height: 560rpx;
}
.radar-canvas {
display: block;
margin: 0 auto;
}
.radar-label {
position: absolute;
color: rgba(243, 224, 185, 0.8);
font-size: 34rpx;
line-height: 1;
}
.top {
top: 26rpx;
left: 50%;
transform: translateX(-50%);
}
.right {
top: 208rpx;
right: 28rpx;
}
.bottom-right {
right: 140rpx;
bottom: 86rpx;
}
.bottom-left {
left: 142rpx;
bottom: 86rpx;
}
.left {
top: 208rpx;
left: 28rpx;
}
</style>

View File

@@ -0,0 +1,139 @@
<template>
<view class="featured-banner" @tap="$emit('tap-banner')">
<view class="banner-copy">
<text class="banner-title">{{ title }}</text>
<text class="banner-progress">{{ progressText }}</text>
<text class="banner-desc">{{ description }}</text>
</view>
<view class="banner-ornament">
<view class="ornament-ring ring-large"></view>
<view class="ornament-ring ring-small"></view>
<view class="ornament-core"></view>
</view>
</view>
</template>
<script setup>
defineProps({
title: {
type: String,
default: ''
},
progressText: {
type: String,
default: ''
},
description: {
type: String,
default: ''
}
})
defineEmits(['tap-banner'])
</script>
<style scoped>
.featured-banner {
position: relative;
overflow: hidden;
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 22rpx;
padding: 28rpx 28rpx 24rpx;
border-radius: 26rpx;
background:
radial-gradient(circle at 78% 25%, rgba(255, 238, 186, 0.82), rgba(255, 238, 186, 0) 26%),
linear-gradient(135deg, #f3ca98 0%, #e8be8d 48%, #e6b97f 100%);
box-shadow: 0 10rpx 24rpx rgba(0, 0, 0, 0.16);
}
.featured-banner::after {
content: '';
position: absolute;
right: -36rpx;
bottom: -52rpx;
width: 220rpx;
height: 220rpx;
border-radius: 50%;
background: radial-gradient(circle, rgba(255, 217, 71, 0.55) 0%, rgba(255, 217, 71, 0) 70%);
}
.banner-copy {
position: relative;
z-index: 2;
max-width: 390rpx;
}
.banner-title {
display: inline-block;
color: #b86c11;
font-size: 44rpx;
font-weight: 800;
line-height: 1.1;
}
.banner-progress {
display: inline-block;
margin-left: 14rpx;
color: #895409;
font-size: 32rpx;
line-height: 1.1;
}
.banner-desc {
display: block;
margin-top: 14rpx;
color: rgba(137, 84, 9, 0.94);
font-size: 22rpx;
line-height: 1.5;
}
.banner-ornament {
position: relative;
z-index: 2;
width: 152rpx;
height: 112rpx;
flex-shrink: 0;
}
.ornament-ring {
position: absolute;
border-radius: 50%;
border: 4rpx solid rgba(255, 255, 255, 0.48);
}
.ring-large {
right: 0;
top: 0;
width: 124rpx;
height: 124rpx;
transform: rotate(-18deg);
}
.ring-small {
right: 32rpx;
top: 18rpx;
width: 64rpx;
height: 64rpx;
border-color: rgba(184, 108, 17, 0.5);
}
.ornament-core {
position: absolute;
right: 28rpx;
top: 24rpx;
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: linear-gradient(180deg, rgba(255, 217, 71, 1) 0%, rgba(231, 186, 128, 1) 100%);
color: #6f4300;
font-size: 34rpx;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 20rpx rgba(184, 108, 17, 0.28);
}
</style>

View File

@@ -0,0 +1,147 @@
<template>
<view class="nav-shell" :style="{ paddingTop: `${statusBarHeight}px` }">
<view class="top-row">
<text class="time-text">{{ currentTime }}</text>
<view class="capsule">
<view class="capsule-dots">
<text class="dot"></text>
<text class="dot active"></text>
<text class="dot"></text>
</view>
<view class="capsule-divider"></view>
<view class="capsule-circle"></view>
</view>
</view>
<view class="action-row">
<button class="back-button" hover-class="none" @tap="$emit('back')">
<image class="back-icon" src="/static/training-home/icons/back.svg" mode="aspectFit" />
</button>
</view>
</view>
</template>
<script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue'
const emit = defineEmits(['back'])
const currentTime = ref('9:41')
const statusBarHeight = ref(24)
let timer = null
function formatTime(date) {
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${hours}:${minutes}`
}
function syncTime() {
currentTime.value = formatTime(new Date())
}
function syncSafeArea() {
const info = typeof uni.getSystemInfoSync === 'function' ? uni.getSystemInfoSync() : null
statusBarHeight.value = info?.statusBarHeight || 24
}
onMounted(() => {
syncSafeArea()
syncTime()
timer = setInterval(syncTime, 60 * 1000)
})
onBeforeUnmount(() => {
if (timer) {
clearInterval(timer)
}
})
</script>
<style scoped>
.nav-shell {
position: relative;
z-index: 2;
padding-left: 20rpx;
padding-right: 20rpx;
}
.top-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.time-text {
color: #ffffff;
font-size: 30rpx;
font-weight: 500;
}
.capsule {
display: flex;
align-items: center;
justify-content: space-between;
width: 176rpx;
height: 58rpx;
padding: 0 18rpx;
border-radius: 44rpx;
border: 1rpx solid rgba(255, 255, 255, 0.18);
background: rgba(0, 0, 0, 0.18);
backdrop-filter: blur(10rpx);
}
.capsule-dots {
display: flex;
align-items: center;
gap: 8rpx;
}
.dot {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.55);
}
.dot.active {
width: 14rpx;
height: 14rpx;
background: rgba(255, 255, 255, 0.95);
}
.capsule-divider {
width: 1rpx;
height: 26rpx;
background: rgba(255, 255, 255, 0.2);
}
.capsule-circle {
width: 28rpx;
height: 28rpx;
border-radius: 50%;
border: 6rpx solid rgba(255, 255, 255, 0.9);
box-shadow: inset 0 0 0 4rpx rgba(0, 0, 0, 0.28);
}
.action-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 18rpx;
min-height: 48rpx;
}
.back-button {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
}
.back-icon {
width: 30rpx;
height: 30rpx;
}
</style>

View File

@@ -0,0 +1,32 @@
<template>
<view class="badge">
<text class="badge-text">{{ text }}</text>
</view>
</template>
<script setup>
defineProps({
text: {
type: String,
default: '推荐'
}
})
</script>
<style scoped>
.badge {
position: absolute;
top: 0;
left: 0;
padding: 8rpx 16rpx 6rpx;
border-radius: 16rpx 4rpx 20rpx 4rpx;
background: linear-gradient(135deg, rgba(255, 209, 154, 1) 0%, rgba(161, 118, 54, 1) 100%);
box-shadow: 0 4rpx 10rpx rgba(0, 0, 0, 0.12);
}
.badge-text {
color: #000000;
font-size: 20rpx;
line-height: 1;
}
</style>

View File

@@ -0,0 +1,89 @@
<template>
<view class="mode-card" :class="{ disabled }" @tap="$emit('select')">
<RecommendBadge v-if="recommended" />
<view class="copy-wrap">
<text class="mode-title">{{ title }}</text>
<text class="mode-subtitle">{{ subtitle }}</text>
</view>
<image class="mode-icon" :src="icon" mode="aspectFit" />
</view>
</template>
<script setup>
import RecommendBadge from './RecommendBadge.vue'
defineProps({
title: {
type: String,
default: ''
},
subtitle: {
type: String,
default: ''
},
icon: {
type: String,
default: ''
},
recommended: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
}
})
defineEmits(['select'])
</script>
<style scoped>
.mode-card {
position: relative;
overflow: hidden;
min-height: 148rpx;
padding: 28rpx 24rpx;
border-radius: 24rpx;
border: 1rpx solid rgba(255, 217, 71, 0.24);
background:
radial-gradient(circle at 78% 26%, rgba(255, 244, 205, 0.14), rgba(255, 244, 205, 0) 25%),
linear-gradient(180deg, rgba(34, 34, 46, 0.92) 0%, rgba(17, 19, 27, 0.96) 100%);
box-shadow: inset 0 2rpx 8rpx rgba(255, 255, 255, 0.04);
display: flex;
align-items: center;
justify-content: space-between;
}
.mode-card.disabled {
opacity: 0.78;
}
.copy-wrap {
max-width: 250rpx;
}
.mode-title {
display: block;
color: rgba(231, 186, 128, 1);
font-size: 44rpx;
font-weight: 800;
line-height: 1.1;
}
.mode-subtitle {
display: block;
margin-top: 18rpx;
color: rgba(252, 206, 150, 0.78);
font-size: 22rpx;
line-height: 1.2;
}
.mode-icon {
width: 86rpx;
height: 86rpx;
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,36 @@
<template>
<view class="grid-wrap">
<TrainingModeCard
v-for="item in items"
:key="item.key"
:title="item.title"
:subtitle="item.subtitle"
:icon="item.icon"
:recommended="item.recommended"
:disabled="item.disabled"
@select="$emit('select', item)"
/>
</view>
</template>
<script setup>
import TrainingModeCard from './TrainingModeCard.vue'
defineProps({
items: {
type: Array,
default: () => []
}
})
defineEmits(['select'])
</script>
<style scoped>
.grid-wrap {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
margin-top: 18rpx;
}
</style>

View File

@@ -0,0 +1,86 @@
<template>
<view class="bubble-wrap" @tap="$emit('record')">
<view class="bubble-body">
<text class="bubble-main">{{ surpassText }}</text>
<view class="bubble-row">
<text class="bubble-sub">{{ recordText }}</text>
<text class="bubble-arrow">></text>
</view>
</view>
<view class="bubble-badge">L</view>
</view>
</template>
<script setup>
defineProps({
surpassText: {
type: String,
default: ''
},
recordText: {
type: String,
default: ''
}
})
defineEmits(['record'])
</script>
<style scoped>
.bubble-wrap {
position: absolute;
top: 10rpx;
right: 8rpx;
display: flex;
align-items: center;
gap: 12rpx;
}
.bubble-body {
padding: 14rpx 18rpx;
border: 2rpx dashed rgba(255, 255, 255, 0.24);
border-radius: 32rpx;
background: rgba(0, 0, 0, 0.1);
}
.bubble-main {
display: block;
color: #ffffff;
font-size: 32rpx;
font-weight: 600;
line-height: 1.2;
}
.bubble-row {
display: flex;
align-items: center;
margin-top: 6rpx;
}
.bubble-sub {
color: rgba(255, 217, 71, 1);
font-size: 24rpx;
line-height: 1.2;
}
.bubble-arrow {
margin-left: 8rpx;
color: rgba(255, 217, 71, 1);
font-size: 24rpx;
font-weight: 700;
}
.bubble-badge {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: linear-gradient(180deg, rgba(255, 217, 71, 1) 0%, rgba(231, 186, 128, 1) 100%);
color: #6f4300;
font-size: 28rpx;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 16rpx rgba(255, 217, 71, 0.22);
}
</style>

View File

@@ -0,0 +1,87 @@
<template>
<view class="summary-card">
<text class="quote quote-left"></text>
<text class="quote quote-right"></text>
<view v-for="item in items" :key="item.key" class="summary-item">
<view class="value-row">
<text class="value-text">{{ item.value }}</text>
<text class="unit-text">{{ item.unit }}</text>
</view>
<text class="label-text">{{ item.label }}</text>
</view>
</view>
</template>
<script setup>
defineProps({
items: {
type: Array,
default: () => []
}
})
</script>
<style scoped>
.summary-card {
position: relative;
display: flex;
margin-top: 28rpx;
padding: 30rpx 18rpx 20rpx;
border-radius: 24rpx;
background: rgba(34, 34, 46, 0.76);
box-shadow: inset 0 2rpx 6rpx rgba(255, 255, 255, 0.06);
}
.summary-item {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
}
.value-row {
display: flex;
align-items: baseline;
justify-content: center;
gap: 2rpx;
}
.value-text {
color: #ffffff;
font-size: 50rpx;
font-weight: 600;
line-height: 1;
}
.unit-text {
color: rgba(243, 224, 185, 1);
font-size: 22rpx;
line-height: 1;
}
.label-text {
color: rgba(252, 206, 150, 1);
font-size: 20rpx;
text-align: center;
}
.quote {
position: absolute;
color: rgba(255, 255, 255, 0.08);
font-size: 66rpx;
line-height: 1;
}
.quote-left {
left: 12rpx;
top: 4rpx;
}
.quote-right {
right: 16rpx;
bottom: 0;
}
</style>

View File

@@ -0,0 +1,97 @@
<template>
<view class="week-bar">
<view
v-for="item in items"
:key="item.key"
class="day-card"
:class="[`status-${item.status}`]"
>
<view class="icon-wrap">
<image
v-if="item.status === 'done'"
class="state-icon"
src="/static/training-home/icons/check.svg"
mode="aspectFit"
/>
<image
v-else-if="item.status === 'failed'"
class="state-icon"
src="/static/training-home/icons/close.svg"
mode="aspectFit"
/>
</view>
<text class="day-text">{{ item.label }}</text>
</view>
</view>
</template>
<script setup>
defineProps({
items: {
type: Array,
default: () => []
}
})
</script>
<style scoped>
.week-bar {
display: flex;
justify-content: space-between;
gap: 10rpx;
}
.day-card {
flex: 1;
min-width: 0;
height: 96rpx;
padding: 14rpx 0 10rpx;
border-radius: 16rpx;
background: linear-gradient(180deg, rgba(47, 45, 43, 0.9) 0%, rgba(37, 40, 49, 0.92) 100%);
box-shadow: inset 0 2rpx 6rpx rgba(255, 255, 255, 0.08);
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
}
.icon-wrap {
width: 34rpx;
height: 34rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.status-done .icon-wrap {
background: linear-gradient(180deg, rgba(255, 217, 71, 1) 0%, rgba(231, 186, 128, 1) 100%);
}
.status-failed .icon-wrap {
background: rgba(202, 202, 202, 0.92);
}
.status-current .icon-wrap {
border: 2rpx solid rgba(255, 217, 71, 0.45);
background: rgba(255, 255, 255, 0.02);
}
.state-icon {
width: 20rpx;
height: 20rpx;
}
.day-text {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.88);
}
.status-done .day-text {
color: rgba(231, 186, 128, 1);
}
.status-current .day-text {
color: rgba(255, 217, 71, 1);
}
</style>

104
src/mock/index.js Normal file
View File

@@ -0,0 +1,104 @@
// 首页一周打卡展示数据,直接对应顶部 7 个日期卡片。
export const trainingHomeWeekSchedule = [
{
key: "mon",
label: "周一",
status: "done",
icon: "../../static/training-home/slices/done.png",
},
{
key: "tue",
label: "周二",
status: "done",
icon: "../../static/training-home/slices/done.png",
},
{
key: "wed",
label: "周三",
status: "missed",
icon: "../../static/training-home/slices/missed.png",
},
{
key: "thu",
label: "周四",
status: "missed",
icon: "../../static/training-home/slices/missed.png",
},
{
key: "fri",
label: "周五",
status: "done",
icon: "../../static/training-home/slices/done.png",
},
{
key: "sat",
label: "周六",
status: "done",
icon: "../../static/training-home/slices/done.png",
},
{
key: "sun",
label: "周日",
status: "missed",
icon: "../../static/training-home/slices/missed.png",
},
];
// 首页统计卡数据,按设计稿从左到右展示。
export const trainingHomeStats = [
{ key: "days", value: "12", unit: "天", label: "共训练" },
{ key: "shots", value: "112", unit: "支", label: "累计射箭" },
{ key: "hitRate", value: "30", unit: "%", label: "命中率" },
{ key: "endurance", value: "6", unit: "支/分钟", label: "耐力射击" },
{ key: "calories", value: "31W", unit: "卡路里", label: "共消耗" },
];
// 雷达图区文案与数值配置。
export const trainingHomeRadar = {
labels: ["基础", "精准", "力量", "节奏", "耐力"],
values: [5.5, 6.3, 10, 4.5, 6],
maxValue: 10,
surpassValue: '80%'
};
// 首页主推荐训练卡数据。
export const trainingHomeFeatured = {
title: "基础训练",
progressText: "当前进度 LV7 >",
};
// 首页四个训练入口卡片数据。
export const trainingHomeModes = [
{
key: "endurance",
title: "耐力训练",
progressText: "当前进度 LV5 >",
icon: "../../static/training-home/slices/img_3.png",
recommended: true,
disabled: false,
},
{
key: "precision",
title: "精准训练",
progressText: "当前进度 LV3 >",
icon: "../../static/training-home/slices/img_4.png",
recommended: false,
disabled: false,
},
{
key: "rhythm",
title: "节奏训练",
progressText: "当前进度 LV6 >",
icon: "../../static/training-home/slices/img_5.png",
recommended: false,
disabled: false,
},
{
key: "power",
title: "力量训练",
progressText: "Coming! LV10",
icon: "../../static/training-home/slices/img_6.png",
recommended: false,
disabled: true,
},
];

View File

@@ -116,6 +116,9 @@
},
{
"path": "pages/mine-bow-data"
},
{
"path": "pages/training/index"
}
],
"globalStyle": {

View File

@@ -196,7 +196,8 @@ onShareTimeline(() => {
</BubbleTip>
</view>
<view class="play-card">
<view @click="() => toPage('/pages/practise')">
<!-- toPage('/pages/practise') -->
<view @click="() => toPage('/pages/training/index')">
<image src="../static/my-practise.png" mode="widthFix"/>
</view>
<view @click="() => toPage('/pages/friend-battle')">

View File

@@ -0,0 +1,701 @@
<script setup>
import { nextTick, onMounted } from "vue";
import { onShow } from "@dcloudio/uni-app";
import Container from "@/components/Container.vue";
import {
trainingHomeFeatured,
trainingHomeModes,
trainingHomeRadar,
trainingHomeStats,
trainingHomeWeekSchedule,
} from "@/mock/index.js";
// 雷达图绘制仍使用原生 number 尺寸,样式展示统一使用 rpx。
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);
// 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 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 drawRadar = () => {
const ctx = uni.createCanvasContext(trainingRadarCanvasId);
const angles = trainingHomeRadar.labels.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;
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);
return;
}
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();
};
// 这些入口先保留占位行为,等后续页面接入后再替换成真实跳转。
const openTrainingRecord = () => {
uni.showToast({
title: "训练记录待接入",
icon: "none",
});
};
const openFeaturedTraining = () => {
uni.showToast({
title: `进入${trainingHomeFeatured.title}`,
icon: "none",
});
};
const openTrainingMode = (item) => {
if (item.disabled) {
uni.showToast({
title: `${item.title} 暂未开放`,
icon: "none",
});
return;
}
uni.showToast({
title: `进入${item.title}`,
icon: "none",
});
};
onMounted(() => {
nextTick(drawRadar);
});
onShow(() => {
nextTick(drawRadar);
});
</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"
class="week-item"
>
<view class="week-item-bg"></view>
<image class="week-item-icon" :src="item.icon" mode="widthFix" />
<text
class="week-item-label"
:class="{ 'week-item-label-active': item.status === 'done' }"
>
{{ item.label }}
</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"
mode="widthFix"
/>
<image
class="stats-quote stats-quote-right"
src="../../static/training-home/slices/img_16.png"
mode="widthFix"
/>
<view class="stats-grid">
<view
v-for="item in trainingHomeStats"
:key="item.key"
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>
<view class="stats-value-decoration"></view>
</view>
</view>
<text class="stats-label">{{ item.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"
mode="widthFix"
/>
<view class="record-bubble-copy">
<view class="record-main">
已超越<text class="record-main-highlight">
80%
</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" />
</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-bottom-right">
{{ trainingHomeRadar.labels[2] }}
</text>
<text class="radar-label radar-label-bottom-left">
{{ trainingHomeRadar.labels[3] }}
</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"
/>
<canvas
:canvas-id="trainingRadarCanvasId"
:id="trainingRadarCanvasId"
class="radar-canvas"
:style="radarFigureStyle"
:width="radarCanvasWidth"
:height="radarCanvasHeight"
/>
<image
class="radar-mascot"
src="../../static/training-home/slices/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"
mode="widthFix"
/>
<view class="featured-card-copy">
<text class="featured-card-title"></text>
<text class="featured-card-progress">{{ trainingHomeFeatured.progressText }}</text>
</view>
</view>
<!-- 四个训练模式入口 -->
<view class="mode-grid">
<view
v-for="item in trainingHomeModes"
:key="item.key"
class="mode-card"
@click="openTrainingMode(item)"
>
<view v-if="item.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>
</view>
<image class="mode-card-icon" :src="item.icon" mode="aspectFit" />
</view>
</view>
</view>
</Container>
</template>
<style scoped>
.training-home {
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 {
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: #fff;
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;
top: 0;
right: 0;
bottom: 0;
left: 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;
width: auto;
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: 48rpx;
text-align: left;
font-style: normal;
}
.stats-unit {
position: relative;
z-index: 1;
margin-left: 4rpx;
padding-bottom: 8rpx;
color: #ffffff;
font-size: 20rpx;
font-weight: 400;
line-height: 28rpx;
text-align: left;
font-style: normal;
opacity: 0.6;
}
.stats-label {
display: inline-block;
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;
}
.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: 18rpx;
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: 12rpx;
margin-left: 8rpx;
}
.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;
}
.featured-card-bg {
width: 100%;
}
.featured-card-copy {
position: absolute;
left: 160rpx;
top: 68rpx;
display: flex;
align-items: center;
}
.featured-card-progress {
margin-left: 18rpx;
color: #895409;
font-size: 22rpx;
line-height: 32rpx;
}
.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-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;
}
.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-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>

BIN
src/static/app-bg6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1011 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 B