update:训练难度展示ui完成
@@ -63,6 +63,12 @@ const props = defineProps({
|
||||
src="@/static/app-bg6.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
class="bg-image"
|
||||
v-if="type === 8"
|
||||
src="@/static/app-bg7.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view class="bg-overlay" v-if="type === 0"></view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
327
src/components/training/TrainingDifficultyBadge.vue
Normal file
@@ -0,0 +1,327 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const lockedBadgeBackground =
|
||||
"/static/training-difficulty-design/unlock.svg";
|
||||
const unlockedBadgeBackground =
|
||||
"/static/training-difficulty-design/lock.svg";
|
||||
|
||||
const props = defineProps({
|
||||
node: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
completedProgress: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
locked: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["click"]);
|
||||
|
||||
const badgeStyle = computed(() => {
|
||||
const { left, top } = props.node.style || {};
|
||||
|
||||
return {
|
||||
left,
|
||||
top,
|
||||
};
|
||||
});
|
||||
|
||||
const progressValue = computed(() => {
|
||||
const value = Number(props.completedProgress);
|
||||
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
|
||||
return Math.max(0, Math.min(100, value));
|
||||
});
|
||||
|
||||
const badgeStateStyle = computed(() => {
|
||||
const label = String(props.node?.label || "");
|
||||
const estimatedLabelWidthRpx = Math.max(36, label.length * 14);
|
||||
const labelCircleSizeRpx = Math.max(58, estimatedLabelWidthRpx + 18);
|
||||
const badgeSizeRpx = Math.max(
|
||||
124,
|
||||
Math.round(labelCircleSizeRpx / 0.4727)
|
||||
);
|
||||
|
||||
return {
|
||||
"--badge-progress": progressValue.value,
|
||||
"--badge-size": `${badgeSizeRpx}rpx`,
|
||||
"--badge-label-size": `${labelCircleSizeRpx}rpx`,
|
||||
"--badge-orbit-offset": "12rpx",
|
||||
"--badge-locked-ring-offset": "12rpx",
|
||||
};
|
||||
});
|
||||
|
||||
const isCompleted = computed(() => {
|
||||
return !props.active && !props.locked && progressValue.value > 0;
|
||||
});
|
||||
|
||||
const badgeFillSrc = computed(() => {
|
||||
return props.locked ? lockedBadgeBackground : unlockedBadgeBackground;
|
||||
});
|
||||
|
||||
const handleClick = () => {
|
||||
emit("click", props.node);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view
|
||||
class="difficulty-badge"
|
||||
:class="{
|
||||
'difficulty-badge--active': active,
|
||||
'difficulty-badge--completed': isCompleted,
|
||||
'difficulty-badge--locked': locked,
|
||||
}"
|
||||
:style="[badgeStyle, badgeStateStyle]"
|
||||
@click="handleClick"
|
||||
>
|
||||
<view class="difficulty-badge__fill">
|
||||
<image class="difficulty-badge__bg" :src="badgeFillSrc" mode="aspectFit" />
|
||||
<view v-if="active" class="difficulty-badge__active-orbit">
|
||||
<view
|
||||
class="difficulty-badge__active-triangle difficulty-badge__active-triangle--top"
|
||||
></view>
|
||||
<view
|
||||
class="difficulty-badge__active-triangle difficulty-badge__active-triangle--right"
|
||||
></view>
|
||||
<view
|
||||
class="difficulty-badge__active-triangle difficulty-badge__active-triangle--bottom"
|
||||
></view>
|
||||
<view
|
||||
class="difficulty-badge__active-triangle difficulty-badge__active-triangle--left"
|
||||
></view>
|
||||
</view>
|
||||
<view class="difficulty-badge__label-wrap">
|
||||
<view class="difficulty-badge__label">{{ node.label }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.difficulty-badge,
|
||||
.difficulty-badge__fill,
|
||||
.difficulty-badge__label-wrap {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.difficulty-badge {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.difficulty-badge--active {
|
||||
transform: translate(-50%, -50%) scale(1.04);
|
||||
}
|
||||
|
||||
.difficulty-badge--active::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: calc(var(--badge-orbit-offset) * -1);
|
||||
border: 4rpx solid #ffffff;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.difficulty-badge--active::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -24rpx;
|
||||
border: 4rpx solid rgba(254, 208, 152, 0.96);
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 0 0 10rpx rgba(254, 208, 152, 0.88),
|
||||
inset 0 0 22rpx rgba(254, 208, 152, 0.32),
|
||||
0 0 14rpx rgba(254, 208, 152, 0.92),
|
||||
0 0 32rpx rgba(254, 208, 152, 0.52),
|
||||
0 0 52rpx rgba(254, 208, 152, 0.22);
|
||||
pointer-events: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.difficulty-badge__active-orbit {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: calc(100% + var(--badge-orbit-offset) * 2);
|
||||
height: calc(100% + var(--badge-orbit-offset) * 2);
|
||||
border-radius: 50%;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, -50%);
|
||||
animation: badge-orbit-spin 5.4s linear infinite;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.difficulty-badge__active-triangle {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
opacity: 0.92;
|
||||
filter: drop-shadow(0 0 8rpx rgba(255, 255, 255, 0.45));
|
||||
}
|
||||
|
||||
.difficulty-badge__active-triangle--top {
|
||||
top: -3rpx;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 11rpx 8rpx 0 8rpx;
|
||||
border-color: #ffffff transparent transparent transparent;
|
||||
}
|
||||
|
||||
.difficulty-badge__active-triangle--right {
|
||||
right: -3rpx;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-width: 8rpx 11rpx 8rpx 0;
|
||||
border-color: transparent #ffffff transparent transparent;
|
||||
}
|
||||
|
||||
.difficulty-badge__active-triangle--bottom {
|
||||
bottom: -3rpx;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 0 8rpx 11rpx 8rpx;
|
||||
border-color: transparent transparent #ffffff transparent;
|
||||
}
|
||||
|
||||
.difficulty-badge__active-triangle--left {
|
||||
left: -3rpx;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-width: 8rpx 0 8rpx 11rpx;
|
||||
border-color: transparent transparent transparent #ffffff;
|
||||
}
|
||||
|
||||
.difficulty-badge__fill {
|
||||
width: var(--badge-size);
|
||||
height: var(--badge-size);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.difficulty-badge__bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.difficulty-badge--active .difficulty-badge__fill::before,
|
||||
.difficulty-badge--active .difficulty-badge__fill::after {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.difficulty-badge--completed .difficulty-badge__fill::before,
|
||||
.difficulty-badge--completed .difficulty-badge__fill::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -12rpx;
|
||||
padding: 6rpx;
|
||||
border-radius: inherit;
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.difficulty-badge--completed .difficulty-badge__fill::before {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.difficulty-badge--completed .difficulty-badge__fill::after {
|
||||
background: conic-gradient(
|
||||
from -90deg,
|
||||
rgba(254, 208, 152, 1) 0,
|
||||
rgba(255, 229, 198, 1) calc(var(--badge-progress) * 1%),
|
||||
transparent calc(var(--badge-progress) * 1%) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.difficulty-badge--active .difficulty-badge__label,
|
||||
.difficulty-badge--completed .difficulty-badge__label {
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.difficulty-badge--locked {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.difficulty-badge--locked::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: calc(var(--badge-locked-ring-offset) * -1);
|
||||
border: 2rpx solid rgba(160, 160, 160, 0.5);
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.difficulty-badge--locked .difficulty-badge__label {
|
||||
color: rgba(51, 51, 51, 0.54);
|
||||
}
|
||||
|
||||
.difficulty-badge__label-wrap {
|
||||
width: var(--badge-label-size);
|
||||
height: var(--badge-label-size);
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: -5rpx;
|
||||
}
|
||||
|
||||
.difficulty-badge__label {
|
||||
color: rgba(51, 51, 51, 0.7);
|
||||
font-size: 24rpx;
|
||||
max-width: 100%;
|
||||
height: 34rpx;
|
||||
line-height: 34rpx;
|
||||
font-family: "PingFang SC", sans-serif;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@keyframes badge-orbit-spin {
|
||||
from {
|
||||
transform: translate(-50%, -50%) rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translate(-50%, -50%) rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
71
src/components/training/TrainingDifficultyPreviewCard.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
lines: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="difficulty-preview">
|
||||
<image
|
||||
class="difficulty-preview__bg"
|
||||
src="/static/training-difficulty-design/text.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view class="difficulty-preview__content">
|
||||
<text class="difficulty-preview__title">{{ title }}</text>
|
||||
<view class="difficulty-preview__copy">
|
||||
箭靶划分为3个区域、需5次命中目标
|
||||
100秒内完成所有射击
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.difficulty-preview {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.difficulty-preview__bg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.difficulty-preview__content {
|
||||
position: absolute;
|
||||
top: 28rpx;
|
||||
left: 30rpx;
|
||||
box-sizing: border-box;
|
||||
width: 486rpx;
|
||||
}
|
||||
|
||||
.difficulty-preview__title {
|
||||
display: block;
|
||||
color: #ffd543;
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
font-family: "PingFang SC", sans-serif;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.difficulty-preview__copy {
|
||||
width: 80%;
|
||||
margin: 0 auto;
|
||||
display: block;
|
||||
color: #ffffff;
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
font-family: "PingFang SC", sans-serif;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
86
src/components/training/TrainingDifficultyStartButton.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
text: {
|
||||
type: String,
|
||||
default: "开始",
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["click"]);
|
||||
|
||||
const handleClick = () => {
|
||||
emit("click");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="difficulty-start"
|
||||
hover-class="difficulty-start--hover"
|
||||
@click="handleClick"
|
||||
>
|
||||
<image
|
||||
class="difficulty-start__mascot"
|
||||
src="/static/training-difficulty-design/u73.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
class="difficulty-start__button"
|
||||
src="/static/training-difficulty-design/btn.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.difficulty-start {
|
||||
position: relative;
|
||||
width: 302rpx;
|
||||
height: 170rpx;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.difficulty-start::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.difficulty-start--hover {
|
||||
transform: translateY(2rpx) scale(0.99);
|
||||
}
|
||||
|
||||
.difficulty-start__mascot {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
width: 112rpx;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.difficulty-start__button {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.difficulty-start__text {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 58rpx;
|
||||
z-index: 3;
|
||||
color: #9f4d00;
|
||||
font-size: 64rpx;
|
||||
line-height: 76rpx;
|
||||
text-align: center;
|
||||
font-family: "AlimamaShuHeiTi-Bold", "PingFang SC", sans-serif;
|
||||
font-weight: 800;
|
||||
text-shadow: 0 3rpx 0 rgba(255, 245, 205, 0.78);
|
||||
}
|
||||
</style>
|
||||
113
src/mock/trainingDifficulty.js
Normal file
@@ -0,0 +1,113 @@
|
||||
// 难度页当前用于保存“开始训练前上下文”的本地存储 key。
|
||||
export const trainingDifficultyStorageKey = "training-selection";
|
||||
|
||||
// 当前是页面联调用的模拟数据:
|
||||
// 1. 总难度 20 级
|
||||
// 2. 已解锁到 Lv3
|
||||
// 3. 前三关展示不同完成进度
|
||||
const totalDifficultyLevel = 20;
|
||||
const mockedUnlockedDifficultyId = "lv3";
|
||||
const mockedDifficultyProgressMap = {
|
||||
lv1: 100,
|
||||
lv2: 90,
|
||||
lv3: 70,
|
||||
};
|
||||
|
||||
const modeList = [
|
||||
{
|
||||
key: "endurance",
|
||||
title: "耐力训练",
|
||||
},
|
||||
{
|
||||
key: "precision",
|
||||
title: "精准训练",
|
||||
},
|
||||
{
|
||||
key: "rhythm",
|
||||
title: "节奏训练",
|
||||
},
|
||||
{
|
||||
key: "basic",
|
||||
title: "基础训练",
|
||||
},
|
||||
{
|
||||
key: "power",
|
||||
title: "力量训练",
|
||||
},
|
||||
{
|
||||
key: "focus",
|
||||
title: "专注训练",
|
||||
},
|
||||
];
|
||||
|
||||
const createDifficultyId = (level) => `lv${level}`;
|
||||
|
||||
const createDifficultyLabel = (level) => `Lv${level}`;
|
||||
|
||||
// 根据等级生成模拟文案,方便一次性扩展到更多关卡。
|
||||
const createDifficultySummary = (level) => {
|
||||
return [
|
||||
`箭靶划分为${Math.min(1 + Math.floor((level - 1) / 5), 4)}个区域`,
|
||||
`需${4 + level}次命中目标`,
|
||||
`${100 + Math.floor((level - 1) / 2) * 10}秒内完成所有射击`,
|
||||
"需使用20CM全环靶",
|
||||
];
|
||||
};
|
||||
|
||||
// 难度页的节点位置已经在页面内统一计算,
|
||||
// 这里保留最核心的 id / label 即可,不再维护无效的 left / top / style 字段。
|
||||
const createDifficultyNode = (level) => {
|
||||
return {
|
||||
id: createDifficultyId(level),
|
||||
label: createDifficultyLabel(level),
|
||||
};
|
||||
};
|
||||
|
||||
const createDifficultyDetail = (level) => {
|
||||
const id = createDifficultyId(level);
|
||||
const label = createDifficultyLabel(level);
|
||||
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
title: `${label}难度`,
|
||||
summary: createDifficultySummary(level),
|
||||
startText: "开始",
|
||||
targetPaperType: "20CM全环靶",
|
||||
};
|
||||
};
|
||||
|
||||
// 所有训练模式当前共用同一套难度定义。
|
||||
const sharedDifficultyNodes = Array.from(
|
||||
{ length: totalDifficultyLevel },
|
||||
(_, index) => createDifficultyNode(index + 1)
|
||||
);
|
||||
|
||||
const sharedDifficultyDetails = Object.fromEntries(
|
||||
Array.from({ length: totalDifficultyLevel }, (_, index) => {
|
||||
const detail = createDifficultyDetail(index + 1);
|
||||
return [detail.id, detail];
|
||||
})
|
||||
);
|
||||
|
||||
const createModeConfig = ({ key, title, reward = null }) => {
|
||||
return {
|
||||
key,
|
||||
title,
|
||||
nodes: sharedDifficultyNodes,
|
||||
details: sharedDifficultyDetails,
|
||||
activeDifficultyId: mockedUnlockedDifficultyId,
|
||||
progressMap: mockedDifficultyProgressMap,
|
||||
reward,
|
||||
};
|
||||
};
|
||||
|
||||
// 难度页数据源入口:
|
||||
// 页面通过 getTrainingDifficultyModeConfig(modeKey) 获取当前模式完整配置。
|
||||
export const trainingDifficultyModeMap = Object.fromEntries(
|
||||
modeList.map((mode) => [mode.key, createModeConfig(mode)])
|
||||
);
|
||||
|
||||
export const getTrainingDifficultyModeConfig = (modeKey) => {
|
||||
return trainingDifficultyModeMap[modeKey] || trainingDifficultyModeMap.precision;
|
||||
};
|
||||
@@ -117,6 +117,9 @@
|
||||
{
|
||||
"path": "pages/mine-bow-data"
|
||||
},
|
||||
{
|
||||
"path": "pages/training/difficulty"
|
||||
},
|
||||
{
|
||||
"path": "pages/training/index"
|
||||
}
|
||||
|
||||
489
src/pages/training/difficulty.vue
Normal file
@@ -0,0 +1,489 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import TrainingDifficultyBadge from "@/components/training/TrainingDifficultyBadge.vue";
|
||||
import TrainingDifficultyPreviewCard from "@/components/training/TrainingDifficultyPreviewCard.vue";
|
||||
import TrainingDifficultyStartButton from "@/components/training/TrainingDifficultyStartButton.vue";
|
||||
import {
|
||||
getTrainingDifficultyModeConfig,
|
||||
trainingDifficultyStorageKey,
|
||||
} from "@/mock/trainingDifficulty.js";
|
||||
|
||||
// 当前页面的数据源:
|
||||
// 1. 模式配置:src/mock/trainingDifficulty.js -> getTrainingDifficultyModeConfig
|
||||
// 2. 当前进度:页面路由参数或配置中的 activeDifficultyId / progressMap
|
||||
const defaultModeKey = "precision";
|
||||
const defaultUnlockedDifficultyId = "lv1";
|
||||
|
||||
// 难度轴布局参数,节点按“等级越低越靠下”的方式排列。
|
||||
const nodesLayout = {
|
||||
viewportHeightRpx: 1020,
|
||||
topPaddingRpx: 136,
|
||||
bottomPaddingRpx: 144,
|
||||
verticalGapRpx: 188,
|
||||
anchorOffsetRpx: 796,
|
||||
horizontalPatternRpx: [388, 232, 516, 258, 458, 304],
|
||||
nearHorizontalDistanceRpx: 170,
|
||||
extraGapScale: 0.5,
|
||||
};
|
||||
|
||||
const emptyDifficulty = {
|
||||
id: "",
|
||||
label: "",
|
||||
title: "",
|
||||
summary: [],
|
||||
startText: "开始",
|
||||
targetPaperType: "",
|
||||
};
|
||||
|
||||
// 页面基础状态
|
||||
const modeKey = ref(defaultModeKey);
|
||||
const unlockedDifficultyId = ref(defaultUnlockedDifficultyId);
|
||||
const selectedDifficultyId = ref(defaultUnlockedDifficultyId);
|
||||
const nodesScrollTop = ref(0);
|
||||
const nodesScrollWithAnimation = ref(false);
|
||||
|
||||
// 当前训练模式配置
|
||||
const pageConfig = computed(() => {
|
||||
return getTrainingDifficultyModeConfig(modeKey.value);
|
||||
});
|
||||
|
||||
const difficultyProgressMap = computed(() => {
|
||||
return pageConfig.value?.progressMap || {};
|
||||
});
|
||||
|
||||
const clamp = (value, min, max) => {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
};
|
||||
|
||||
// 从 lv1 / lv20 这类 id 中提取等级数值,统一用于排序、解锁判断和进度比较。
|
||||
const getDifficultyLevel = (difficultyId = "") => {
|
||||
const matched = String(difficultyId).match(/\d+/);
|
||||
return matched ? Number(matched[0]) : 0;
|
||||
};
|
||||
|
||||
// 合并节点基础信息和难度详情,并统一按等级升序整理。
|
||||
const createDifficultyNodes = (config) => {
|
||||
const details = config?.details || {};
|
||||
const rawNodes = Array.isArray(config?.nodes) ? config.nodes : [];
|
||||
const nodeMap = new Map(rawNodes.map((node) => [node.id, node]));
|
||||
const difficultyIds = new Set([
|
||||
...rawNodes.map((node) => node.id),
|
||||
...Object.keys(details),
|
||||
]);
|
||||
|
||||
return Array.from(difficultyIds)
|
||||
.map((difficultyId) => {
|
||||
const level = getDifficultyLevel(difficultyId);
|
||||
const node = nodeMap.get(difficultyId) || {};
|
||||
const detail = details[difficultyId] || {};
|
||||
const label = node.label || detail.label || `Lv${level || ""}`;
|
||||
|
||||
return {
|
||||
...node,
|
||||
...detail,
|
||||
id: difficultyId,
|
||||
level,
|
||||
label,
|
||||
title: detail.title || `${label}难度`,
|
||||
summary: Array.isArray(detail.summary) ? detail.summary : [],
|
||||
startText: detail.startText || "开始",
|
||||
targetPaperType: detail.targetPaperType || "",
|
||||
};
|
||||
})
|
||||
.filter((node) => node.id && node.level > 0)
|
||||
.sort((first, second) => first.level - second.level);
|
||||
};
|
||||
|
||||
const findValidDifficultyId = (difficultyId, nodes) => {
|
||||
return nodes.some((node) => node.id === difficultyId) ? difficultyId : "";
|
||||
};
|
||||
|
||||
const getNextDifficultyId = (difficultyId, nodes) => {
|
||||
const currentLevel = getDifficultyLevel(difficultyId);
|
||||
return (
|
||||
nodes.find((node) => node.level === currentLevel + 1)?.id || difficultyId
|
||||
);
|
||||
};
|
||||
|
||||
// 统一解析当前最新已解锁难度:
|
||||
// completedDifficultyId 优先级最高,可在完成当前难度后自动推进到下一关。
|
||||
const resolveUnlockedDifficultyId = (options, config, nodes) => {
|
||||
const completedDifficultyId = findValidDifficultyId(
|
||||
options.completedDifficultyId,
|
||||
nodes
|
||||
);
|
||||
|
||||
if (completedDifficultyId) {
|
||||
return getNextDifficultyId(completedDifficultyId, nodes);
|
||||
}
|
||||
|
||||
return (
|
||||
[
|
||||
options.currentDifficultyId,
|
||||
options.latestDifficultyId,
|
||||
options.activeDifficultyId,
|
||||
config.activeDifficultyId,
|
||||
defaultUnlockedDifficultyId,
|
||||
].find((difficultyId) => findValidDifficultyId(difficultyId, nodes)) ||
|
||||
nodes[0]?.id ||
|
||||
defaultUnlockedDifficultyId
|
||||
);
|
||||
};
|
||||
|
||||
// 如果传入的默认选中项尚未解锁,则自动回退到当前最新已解锁难度。
|
||||
const resolveSelectedDifficultyId = (difficultyId, nodes, currentUnlockedId) => {
|
||||
const safeDifficultyId = findValidDifficultyId(difficultyId, nodes);
|
||||
|
||||
if (!safeDifficultyId) {
|
||||
return currentUnlockedId;
|
||||
}
|
||||
|
||||
return getDifficultyLevel(safeDifficultyId) <=
|
||||
getDifficultyLevel(currentUnlockedId)
|
||||
? safeDifficultyId
|
||||
: currentUnlockedId;
|
||||
};
|
||||
|
||||
// 页面渲染使用的难度节点列表,包含纵向轨道坐标。
|
||||
const difficultyNodes = computed(() => {
|
||||
const nodes = createDifficultyNodes(pageConfig.value);
|
||||
const leftPositions = nodes.map((_, index) => {
|
||||
return nodesLayout.horizontalPatternRpx[
|
||||
index % nodesLayout.horizontalPatternRpx.length
|
||||
];
|
||||
});
|
||||
const offsetsFromBottom = [];
|
||||
let accumulatedOffsetRpx = 0;
|
||||
|
||||
nodes.forEach((node, index) => {
|
||||
if (index > 0) {
|
||||
const previousLeftRpx = leftPositions[index - 1];
|
||||
const currentLeftRpx = leftPositions[index];
|
||||
const horizontalDistanceRpx = Math.abs(currentLeftRpx - previousLeftRpx);
|
||||
const extraGapRpx =
|
||||
Math.max(
|
||||
0,
|
||||
nodesLayout.nearHorizontalDistanceRpx - horizontalDistanceRpx
|
||||
) * nodesLayout.extraGapScale;
|
||||
|
||||
accumulatedOffsetRpx +=
|
||||
nodesLayout.verticalGapRpx + Math.round(extraGapRpx);
|
||||
}
|
||||
|
||||
offsetsFromBottom.push(accumulatedOffsetRpx);
|
||||
});
|
||||
|
||||
const contentHeightRpx = Math.max(
|
||||
nodesLayout.viewportHeightRpx,
|
||||
nodesLayout.topPaddingRpx +
|
||||
nodesLayout.bottomPaddingRpx +
|
||||
(offsetsFromBottom[offsetsFromBottom.length - 1] || 0)
|
||||
);
|
||||
|
||||
return nodes.map((node, index) => {
|
||||
const leftRpx = leftPositions[index];
|
||||
const topRpx =
|
||||
contentHeightRpx -
|
||||
nodesLayout.bottomPaddingRpx -
|
||||
offsetsFromBottom[index];
|
||||
|
||||
return {
|
||||
...node,
|
||||
leftRpx,
|
||||
topRpx,
|
||||
style: {
|
||||
left: `${leftRpx}rpx`,
|
||||
top: `${topRpx}rpx`,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const difficultyConnectors = computed(() => {
|
||||
const nodes = difficultyNodes.value;
|
||||
|
||||
return nodes.slice(1).map((currentNode, index) => {
|
||||
const previousNode = nodes[index];
|
||||
const startX = Number(previousNode?.leftRpx || 0);
|
||||
const startY = Number(previousNode?.topRpx || 0);
|
||||
const endX = Number(currentNode?.leftRpx || 0);
|
||||
const endY = Number(currentNode?.topRpx || 0);
|
||||
const midX = (startX + endX) / 2;
|
||||
const midY = (startY + endY) / 2;
|
||||
const angle =
|
||||
(Math.atan2(endY - startY, endX - startX) * 180) / Math.PI + 90;
|
||||
|
||||
return {
|
||||
id: `${previousNode.id}-${currentNode.id}`,
|
||||
left: `${midX}rpx`,
|
||||
top: `${midY}rpx`,
|
||||
transform: `translate(-50%, -50%) rotate(${angle}deg)`,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const nodesTrackHeightRpx = computed(() => {
|
||||
const bottomNode = difficultyNodes.value[0];
|
||||
|
||||
if (!bottomNode) {
|
||||
return nodesLayout.viewportHeightRpx;
|
||||
}
|
||||
|
||||
return Math.max(
|
||||
nodesLayout.viewportHeightRpx,
|
||||
bottomNode.topRpx + nodesLayout.bottomPaddingRpx
|
||||
);
|
||||
});
|
||||
|
||||
const nodesTrackStyle = computed(() => {
|
||||
return {
|
||||
height: `${nodesTrackHeightRpx.value}rpx`,
|
||||
};
|
||||
});
|
||||
|
||||
const selectedDifficulty = computed(() => {
|
||||
return (
|
||||
difficultyNodes.value.find((node) => node.id === selectedDifficultyId.value) ||
|
||||
difficultyNodes.value[0] ||
|
||||
emptyDifficulty
|
||||
);
|
||||
});
|
||||
|
||||
// 优先显示配置中的难度进度;没有配置时,再按已解锁等级推导完成态。
|
||||
const getCompletedDifficultyProgress = (node) => {
|
||||
const configuredProgress = Number(difficultyProgressMap.value[node?.id]);
|
||||
|
||||
if (Number.isFinite(configuredProgress) && configuredProgress > 0) {
|
||||
return configuredProgress;
|
||||
}
|
||||
|
||||
return getDifficultyLevel(node?.id) < getDifficultyLevel(unlockedDifficultyId.value)
|
||||
? 100
|
||||
: 0;
|
||||
};
|
||||
|
||||
const checkDifficultyLocked = (node) => {
|
||||
return getDifficultyLevel(node?.id) > getDifficultyLevel(unlockedDifficultyId.value);
|
||||
};
|
||||
|
||||
// 根据目标难度计算 scroll-view 应滚动到的位置,顶部/底部会自动吸附边界。
|
||||
const scrollToDifficulty = (difficultyId, animated = false) => {
|
||||
const node = difficultyNodes.value.find((item) => item.id === difficultyId);
|
||||
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxScrollRpx = Math.max(
|
||||
nodesTrackHeightRpx.value - nodesLayout.viewportHeightRpx,
|
||||
0
|
||||
);
|
||||
const targetScrollRpx = clamp(
|
||||
node.topRpx - nodesLayout.anchorOffsetRpx,
|
||||
0,
|
||||
maxScrollRpx
|
||||
);
|
||||
|
||||
nodesScrollWithAnimation.value = animated;
|
||||
nodesScrollTop.value = uni.upx2px(targetScrollRpx);
|
||||
};
|
||||
|
||||
// 首次进入页面需要静默定位到默认难度;
|
||||
// 定位完成后再开启滚动动画,避免第一次手动切换时丢失过渡效果。
|
||||
const initScrollPosition = () => {
|
||||
scrollToDifficulty(selectedDifficultyId.value, false);
|
||||
|
||||
nextTick(() => {
|
||||
nodesScrollWithAnimation.value = true;
|
||||
});
|
||||
};
|
||||
|
||||
const initPageState = (options = {}) => {
|
||||
const config = getTrainingDifficultyModeConfig(options.mode);
|
||||
const nodes = createDifficultyNodes(config);
|
||||
const currentUnlockedId = resolveUnlockedDifficultyId(options, config, nodes);
|
||||
|
||||
modeKey.value = config.key;
|
||||
unlockedDifficultyId.value = currentUnlockedId;
|
||||
selectedDifficultyId.value = resolveSelectedDifficultyId(
|
||||
options.difficultyId,
|
||||
nodes,
|
||||
currentUnlockedId
|
||||
);
|
||||
|
||||
nextTick(() => {
|
||||
initScrollPosition();
|
||||
});
|
||||
};
|
||||
|
||||
const saveTrainingContext = () => {
|
||||
const difficulty = selectedDifficulty.value;
|
||||
|
||||
if (!difficulty.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
uni.setStorageSync(trainingDifficultyStorageKey, {
|
||||
trainingType: pageConfig.value.key,
|
||||
trainingTitle: pageConfig.value.title,
|
||||
difficultyId: difficulty.id,
|
||||
difficultyLabel: difficulty.label,
|
||||
targetPaperType: difficulty.targetPaperType,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectDifficulty = (node) => {
|
||||
if (!node?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (checkDifficultyLocked(node)) {
|
||||
uni.showToast({
|
||||
title: "难度尚未解锁",
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.id === selectedDifficultyId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedDifficultyId.value = node.id;
|
||||
nextTick(() => {
|
||||
scrollToDifficulty(node.id, true);
|
||||
});
|
||||
};
|
||||
|
||||
const handleStart = () => {
|
||||
if (!selectedDifficulty.value.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
saveTrainingContext();
|
||||
uni.showToast({
|
||||
title: `${selectedDifficulty.value.title} 即将开始`,
|
||||
icon: "none",
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
onLoad((options = {}) => {
|
||||
initPageState(options);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container
|
||||
:title="pageConfig.title"
|
||||
:bgType="8"
|
||||
bgColor="#1c1c23"
|
||||
:scroll="false"
|
||||
>
|
||||
<view class="difficulty-page">
|
||||
<view class="difficulty-page__nodes">
|
||||
<scroll-view
|
||||
class="difficulty-page__nodes-scroll"
|
||||
scroll-y
|
||||
enhanced
|
||||
:scroll-top="nodesScrollTop"
|
||||
:scroll-with-animation="nodesScrollWithAnimation"
|
||||
:show-scrollbar="false"
|
||||
>
|
||||
<view class="difficulty-page__nodes-track" :style="nodesTrackStyle">
|
||||
<image
|
||||
v-for="connector in difficultyConnectors"
|
||||
:key="connector.id"
|
||||
class="difficulty-page__connector"
|
||||
src="../../static/training-difficulty-design/jiantou.png"
|
||||
mode="aspectFit"
|
||||
:style="connector"
|
||||
/>
|
||||
<TrainingDifficultyBadge
|
||||
v-for="node in difficultyNodes"
|
||||
:key="node.id"
|
||||
:node="node"
|
||||
:active="node.id === selectedDifficultyId"
|
||||
:locked="checkDifficultyLocked(node)"
|
||||
:completedProgress="getCompletedDifficultyProgress(node)"
|
||||
@click="handleSelectDifficulty"
|
||||
/>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<view class="difficulty-page__preview">
|
||||
<TrainingDifficultyPreviewCard
|
||||
:title="selectedDifficulty.title"
|
||||
:lines="selectedDifficulty.summary"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="difficulty-page__start">
|
||||
<TrainingDifficultyStartButton
|
||||
:text="selectedDifficulty.startText"
|
||||
@click="handleStart"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.difficulty-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
padding: 18rpx 0 40rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.difficulty-page__nodes {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
z-index: 2;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.difficulty-page__nodes-scroll {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.difficulty-page__nodes-track {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.difficulty-page__connector {
|
||||
position: absolute;
|
||||
width: 18rpx;
|
||||
height: 28rpx;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.difficulty-page__preview {
|
||||
position: relative;
|
||||
flex: none;
|
||||
z-index: 3;
|
||||
width: 540rpx;
|
||||
height: 172rpx;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.difficulty-page__start {
|
||||
position: relative;
|
||||
flex: none;
|
||||
z-index: 4;
|
||||
width: 302rpx;
|
||||
height: 190rpx;
|
||||
margin: 0 auto;
|
||||
top: -16rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -102,9 +102,8 @@ const openTrainingRecord = () => {
|
||||
};
|
||||
|
||||
const openFeaturedTraining = () => {
|
||||
uni.showToast({
|
||||
title: `进入${trainingHomeFeatured.title}`,
|
||||
icon: "none",
|
||||
uni.navigateTo({
|
||||
url: "/pages/training/difficulty?mode=basic",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -117,9 +116,8 @@ const openTrainingMode = (item) => {
|
||||
return;
|
||||
}
|
||||
|
||||
uni.showToast({
|
||||
title: `进入${item.title}`,
|
||||
icon: "none",
|
||||
uni.navigateTo({
|
||||
url: `/pages/training/difficulty?mode=${item.key}`,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 321 KiB |
BIN
src/static/app-bg7.png
Normal file
|
After Width: | Height: | Size: 252 KiB |
BIN
src/static/training-difficulty-design/btn.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src/static/training-difficulty-design/jiantou.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
19
src/static/training-difficulty-design/lock.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="55px" height="55px" viewBox="0 0 55 55" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>编组 11</title>
|
||||
<g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="训练难度展示" transform="translate(-160.000000, -478.000000)">
|
||||
<g id="编组-2备份" transform="translate(154.000000, 472.000000)">
|
||||
<g id="编组-11" transform="translate(6.000000, 6.000000)">
|
||||
<circle id="椭圆形" fill="#CACACA" cx="27.5" cy="27.5" r="27.5"></circle>
|
||||
<circle id="椭圆形" fill="#FFFFFF" cx="27.5" cy="26" r="25"></circle>
|
||||
<circle id="椭圆形" fill="#5E5E5E" cx="27.5" cy="26" r="22"></circle>
|
||||
<circle id="椭圆形" fill="#17B6F2" cx="27.5" cy="26" r="19.5"></circle>
|
||||
<circle id="椭圆形" fill="#FFC2C2" cx="27.5" cy="26" r="17"></circle>
|
||||
<circle id="椭圆形" fill="#FF1F33" opacity="0.800000012" cx="27.5" cy="26" r="14.5"></circle>
|
||||
<circle id="椭圆形" fill="#FED847" cx="27.5" cy="26" r="13"></circle>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src/static/training-difficulty-design/text.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
19
src/static/training-difficulty-design/unlock.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="56px" height="55px" viewBox="0 0 56 55" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>编组 9</title>
|
||||
<g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="训练难度展示" transform="translate(-60.000000, -313.000000)">
|
||||
<g id="编组-2备份" transform="translate(54.500000, 307.000000)">
|
||||
<g id="编组-9" transform="translate(5.500000, 6.000000)">
|
||||
<circle id="椭圆形" fill="#CACACA" cx="28" cy="27.5" r="27.5"></circle>
|
||||
<circle id="椭圆形" fill="#FFFFFF" cx="28" cy="26" r="25"></circle>
|
||||
<circle id="椭圆形" fill="#5E5E5E" cx="28" cy="26" r="22"></circle>
|
||||
<circle id="椭圆形" fill="#808080" cx="28" cy="26" r="19.5"></circle>
|
||||
<circle id="椭圆形" fill="#FFFFFF" cx="28" cy="26" r="17"></circle>
|
||||
<circle id="椭圆形" fill="#8C8C8C" cx="28" cy="26" r="14.5"></circle>
|
||||
<circle id="椭圆形" fill="#DBDBDB" cx="28" cy="26" r="13"></circle>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |