update:优化个人训练

This commit is contained in:
2026-07-27 16:38:53 +08:00
parent c5618fb60b
commit e6f11d3684
6 changed files with 221 additions and 48 deletions
+88
View File
@@ -25,6 +25,10 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: true, default: true,
}, },
usePageScroll: {
type: Boolean,
default: false,
},
isHome: { isHome: {
type: Boolean, type: Boolean,
default: false, default: false,
@@ -53,6 +57,14 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: true, default: true,
}, },
loading: {
type: Boolean,
default: false,
},
loadingText: {
type: String,
default: "",
},
}); });
const isIOS = uni.getDeviceInfo().osName === "ios"; const isIOS = uni.getDeviceInfo().osName === "ios";
const showHint = ref(false); const showHint = ref(false);
@@ -125,9 +137,22 @@ const goCalibration = async () => {
:onBack="onBack" :onBack="onBack"
:whiteBackArrow="whiteBackArrow" :whiteBackArrow="whiteBackArrow"
:titleStyle="titleStyle" :titleStyle="titleStyle"
:style="
usePageScroll
? {
position: 'sticky',
top: capsuleHeight + 'px',
zIndex: 10,
}
: undefined
"
/> />
<BackToGame v-if="showBackToGame" /> <BackToGame v-if="showBackToGame" />
<view v-if="usePageScroll">
<slot></slot>
</view>
<scroll-view <scroll-view
v-else
:scroll-y="scroll" :scroll-y="scroll"
:enhanced="true" :enhanced="true"
:bounces="false" :bounces="false"
@@ -189,6 +214,18 @@ const goCalibration = async () => {
</view> </view>
</view> </view>
</ScreenHint> </ScreenHint>
<view v-if="loading" class="audio-progress">
<image
src="https://static.shelingxingqiu.com/attachment/2025-11-26/deihtj15xjwcz3c1tx.png"
mode="widthFix"
/>
<view>
<view :style="{ width: '100%' }"></view>
</view>
<view>
<text>{{ loadingText || "加载中..." }}</text>
</view>
</view>
</view> </view>
</template> </template>
@@ -230,4 +267,55 @@ const goCalibration = async () => {
color: #666; color: #666;
opacity: 0.6; opacity: 0.6;
} }
.audio-progress {
z-index: 999;
width: 100vw;
height: 100vh;
position: fixed;
top: 0;
left: 0;
background: rgb(0 0 0 / 0.8);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.audio-progress > image:nth-child(1) {
width: 140rpx;
height: 150rpx;
margin-bottom: 20rpx;
}
.audio-progress > view:nth-child(2) {
width: 380rpx;
height: 6rpx;
background: #595959;
border-radius: 4rpx;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
}
.audio-progress > view:nth-child(2) > view {
width: 100%;
min-height: 6rpx;
background: #ffe431;
border-radius: 4rpx;
}
.audio-progress > view:nth-child(3) {
display: flex;
align-items: center;
justify-content: center;
}
.audio-progress > view:nth-child(3) > text {
font-size: 22rpx;
color: #a2a2a2;
text-align: center;
line-height: 32rpx;
}
</style> </style>
+1 -1
View File
@@ -115,7 +115,7 @@ const props = defineProps({
} }
.header > view:first-child > view:last-child > text:last-child { .header > view:first-child > view:last-child > text:last-child {
font-size: 10px; font-size: 10px;
/* background-color: #5f51ff; */ background-color: #5f51ff;
padding: 2px 5px; padding: 2px 5px;
border-radius: 10px; border-radius: 10px;
margin-top: 5px; margin-top: 5px;
@@ -35,6 +35,10 @@ const props = defineProps({
type: Number, type: Number,
default: 0, default: 0,
}, },
hasNextDifficulty: {
type: Boolean,
default: false,
},
result: { result: {
type: Object, type: Object,
default: () => ({}), default: () => ({}),
@@ -297,9 +301,7 @@ const resultRows = computed(() => {
}); });
}); });
const advancesDifficulty = computed(() => const advancesDifficulty = computed(() => props.hasNextDifficulty);
["base", "endurance"].includes(resultTrainingType.value)
);
const primaryText = computed(() => const primaryText = computed(() =>
advancesDifficulty.value ? "下一难度" : "再来一次" advancesDifficulty.value ? "下一难度" : "再来一次"
); );
+22 -23
View File
@@ -1,6 +1,6 @@
<script setup> <script setup>
import { computed, nextTick, ref } from "vue"; import { computed, nextTick, ref } from "vue";
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app"; import { onHide, onLoad, onShow } from "@dcloudio/uni-app";
import Container from "@/components/Container.vue"; import Container from "@/components/Container.vue";
import TrainingDifficultyBadge from "./components/TrainingDifficultyBadge.vue"; import TrainingDifficultyBadge from "./components/TrainingDifficultyBadge.vue";
import TrainingDifficultyPreviewCard from "./components/TrainingDifficultyPreviewCard.vue"; import TrainingDifficultyPreviewCard from "./components/TrainingDifficultyPreviewCard.vue";
@@ -15,7 +15,6 @@ import {
// 1. 接口:GET /training/difficulty/list?type=base/endurance/precision/rhythm // 1. 接口:GET /training/difficulty/list?type=base/endurance/precision/rhythm
// 2. 当前进度:接口 user_levels / list.completed,路由参数可覆盖选中难度 // 2. 当前进度:接口 user_levels / list.completed,路由参数可覆盖选中难度
const trainingDifficultyStorageKey = "training-selection"; const trainingDifficultyStorageKey = "training-selection";
const trainingDifficultyRefreshEvent = "training-difficulty-refresh";
const defaultTrainingType = "precision"; const defaultTrainingType = "precision";
const defaultUnlockedDifficultyId = "lv1"; const defaultUnlockedDifficultyId = "lv1";
const trainingTypeMetaMap = { const trainingTypeMetaMap = {
@@ -239,8 +238,9 @@ const selectedDifficultyId = ref(defaultUnlockedDifficultyId);
const nodesScrollTop = ref(0); const nodesScrollTop = ref(0);
const nodesScrollWithAnimation = ref(false); const nodesScrollWithAnimation = ref(false);
const routeOptions = ref({}); const routeOptions = ref({});
const needRefreshProgress = ref(false); const shouldRefreshOnShow = ref(false);
const creatingPractice = ref(false); const creatingPractice = ref(false);
let pageStateRequestGeneration = 0;
const difficultyProgressMap = computed(() => { const difficultyProgressMap = computed(() => {
return pageConfig.value?.progressMap || {}; return pageConfig.value?.progressMap || {};
@@ -533,6 +533,7 @@ const applyPageState = (options = {}, config) => {
}; };
const initPageState = async (options = {}, refreshOptions = {}) => { const initPageState = async (options = {}, refreshOptions = {}) => {
const requestGeneration = ++pageStateRequestGeneration;
const { keepCurrent = false } = refreshOptions; const { keepCurrent = false } = refreshOptions;
const trainingType = resolveTrainingType(options.mode); const trainingType = resolveTrainingType(options.mode);
const fallbackConfig = createEmptyModeConfig(trainingType); const fallbackConfig = createEmptyModeConfig(trainingType);
@@ -543,11 +544,19 @@ const initPageState = async (options = {}, refreshOptions = {}) => {
try { try {
const result = await getTrainingDifficultyListAPI(trainingType); const result = await getTrainingDifficultyListAPI(trainingType);
if (requestGeneration !== pageStateRequestGeneration) {
return;
}
applyPageState( applyPageState(
options, options,
normalizeTrainingDifficultyConfig(result, trainingType) normalizeTrainingDifficultyConfig(result, trainingType)
); );
} catch (error) { } catch (error) {
if (requestGeneration !== pageStateRequestGeneration) {
return;
}
console.log("training difficulty load failed", error); console.log("training difficulty load failed", error);
if (!keepCurrent) { if (!keepCurrent) {
applyPageState(options, fallbackConfig); applyPageState(options, fallbackConfig);
@@ -687,10 +696,6 @@ const handleStart = async () => {
let createdPracticeId = ""; let createdPracticeId = "";
creatingPractice.value = true; creatingPractice.value = true;
uni.showLoading({
title: "训练创建中",
mask: true,
});
try { try {
// 先由业务接口创建训练和比赛服,再把连接上下文交给目标页。 // 先由业务接口创建训练和比赛服,再把连接上下文交给目标页。
@@ -731,35 +736,27 @@ const handleStart = async () => {
}); });
} }
} finally { } finally {
uni.hideLoading();
creatingPractice.value = false; creatingPractice.value = false;
} }
}; };
const markProgressRefresh = () => {
needRefreshProgress.value = true;
};
onLoad((options = {}) => { onLoad((options = {}) => {
routeOptions.value = { ...options }; routeOptions.value = { ...options };
uni.$on(trainingDifficultyRefreshEvent, markProgressRefresh); void initPageState(options);
initPageState(options); });
onHide(() => {
shouldRefreshOnShow.value = true;
}); });
onShow(() => { onShow(() => {
if (!needRefreshProgress.value) { if (!shouldRefreshOnShow.value) return;
return;
}
needRefreshProgress.value = false; shouldRefreshOnShow.value = false;
initPageState(routeOptions.value, { void initPageState(routeOptions.value, {
keepCurrent: true, keepCurrent: true,
}); });
}); });
onUnload(() => {
uni.$off(trainingDifficultyRefreshEvent, markProgressRefresh);
});
</script> </script>
<template> <template>
@@ -768,6 +765,8 @@ onUnload(() => {
:bgType="8" :bgType="8"
bgColor="#1c1c23" bgColor="#1c1c23"
:scroll="false" :scroll="false"
:loading="creatingPractice"
loadingText="训练创建中..."
> >
<view class="difficulty-page"> <view class="difficulty-page">
<view class="difficulty-page__nodes"> <view class="difficulty-page__nodes">
+38 -18
View File
@@ -17,6 +17,7 @@ const trainingModeRouteMap = {
rhythm: "rhythm", rhythm: "rhythm",
strength: "power", strength: "power",
}; };
const unavailableTrainingIds = new Set(["rhythm", "strength"]);
// 训练项目卡片右侧主图标。 // 训练项目卡片右侧主图标。
const trainingModeIconMap = { const trainingModeIconMap = {
base_bow: base_bow:
@@ -79,7 +80,7 @@ const trainingData = ref(createDefaultTrainingData());
const recommendedTrainingId = ref(""); const recommendedTrainingId = ref("");
const visibleTrainingItems = computed(() => const visibleTrainingItems = computed(() =>
Array.isArray(trainingData.value.training_items) Array.isArray(trainingData.value.training_items)
? trainingData.value.training_items.filter((item) => item.id !== "strength") ? trainingData.value.training_items
: [] : []
); );
const pageMounted = ref(false); const pageMounted = ref(false);
@@ -91,8 +92,17 @@ const radarFigureWidthRpx = 448;
const radarFigureHeightRpx = Math.round( const radarFigureHeightRpx = Math.round(
(radarFigureWidthRpx * radarImageHeight) / radarImageWidth (radarFigureWidthRpx * radarImageHeight) / radarImageWidth
); );
const radarCanvasWidth = Math.round(uni.upx2px(radarFigureWidthRpx)); const radarCanvasPixelRatio = Math.max(
const radarCanvasHeight = Math.round(uni.upx2px(radarFigureHeightRpx)); 1,
Number(uni.getDeviceInfo().pixelRatio) || 1
);
const radarTargetWidth = uni.upx2px(radarFigureWidthRpx);
const radarTargetHeight = uni.upx2px(radarFigureHeightRpx);
// 画布按设备像素比直接绘制,并让显示尺寸与物理像素严格对应,避免图片二次缩放。
const radarCanvasWidth = Math.round(radarTargetWidth * radarCanvasPixelRatio);
const radarCanvasHeight = Math.round(radarTargetHeight * radarCanvasPixelRatio);
const radarDisplayWidth = radarCanvasWidth / radarCanvasPixelRatio;
const radarDisplayHeight = radarCanvasHeight / radarCanvasPixelRatio;
const radarScaleX = radarCanvasWidth / radarImageWidth; const radarScaleX = radarCanvasWidth / radarImageWidth;
const radarScaleY = radarCanvasHeight / radarImageHeight; const radarScaleY = radarCanvasHeight / radarImageHeight;
const radarScale = Math.min(radarScaleX, radarScaleY); const radarScale = Math.min(radarScaleX, radarScaleY);
@@ -103,9 +113,10 @@ const radarPointRadius = Math.max(2.5, 3.5 * radarScale);
const radarOuterRadiusX = 110.7089 * radarScaleX; const radarOuterRadiusX = 110.7089 * radarScaleX;
const radarOuterRadiusY = 110.7089 * radarScaleY; const radarOuterRadiusY = 110.7089 * radarScaleY;
const radarFigureStyle = { const radarFigureStyle = {
width: `${radarFigureWidthRpx}rpx`, width: `${radarDisplayWidth}px`,
height: `${radarFigureHeightRpx}rpx`, height: `${radarDisplayHeight}px`,
}; };
let radarRenderGeneration = 0;
const formatValue = (value, digits = 1) => { const formatValue = (value, digits = 1) => {
const numberValue = Number(value); const numberValue = Number(value);
@@ -116,7 +127,7 @@ const formatValue = (value, digits = 1) => {
const getLevelText = (item) => { const getLevelText = (item) => {
if (!item) return ""; if (!item) return "";
const level = Number(item.current_level) || 0; const level = Number(item.current_level) || 0;
return item.is_locked ? `Coming! LV${level}` : `当前进度 LV${level} >`; return `当前进度 LV${level} >`;
}; };
// 卡路里字段按需求做 K / W 缩写展示。 // 卡路里字段按需求做 K / W 缩写展示。
@@ -156,6 +167,7 @@ const updateRecommendedTraining = () => {
const trainingId = radarDimensionTrainingIdMap[item?.name]; const trainingId = radarDimensionTrainingIdMap[item?.name];
const rawScore = item?.score; const rawScore = item?.score;
if ( if (
unavailableTrainingIds.has(trainingId) ||
!visibleTrainingIds.includes(trainingId) || !visibleTrainingIds.includes(trainingId) ||
rawScore === undefined || rawScore === undefined ||
rawScore === null || rawScore === null ||
@@ -176,9 +188,10 @@ const updateRecommendedTraining = () => {
const lowestCandidates = candidates.filter( const lowestCandidates = candidates.filter(
(item) => item.score === lowestScore (item) => item.score === lowestScore
); );
const selectedIndex = Math.floor(Math.random() * lowestCandidates.length); const selectedIndex = Math.floor(Math.random() * lowestCandidates.length); //暂时先不随机,默认使用第一个
console.log(candidates, selectedIndex, lowestCandidates)
recommendedTrainingId.value = recommendedTrainingId.value =
lowestCandidates[selectedIndex]?.trainingId || ""; lowestCandidates[0]?.trainingId || "";
}; };
const getRadarPoint = (centerX, centerY, radiusX, radiusY, angle) => ({ const getRadarPoint = (centerX, centerY, radiusX, radiusY, angle) => ({
@@ -251,11 +264,14 @@ const drawRadar = () => {
ctx.draw(); ctx.draw();
}; };
// 小程序 canvas 首次渲染时机不稳定,延后一帧再绘制更稳。 // 小程序 Canvas 首次渲染时机不稳定,延后一帧再绘制更稳。
const refreshRadar = async () => { const refreshRadar = async () => {
const generation = ++radarRenderGeneration;
await nextTick(); await nextTick();
setTimeout(() => { setTimeout(() => {
if (generation === radarRenderGeneration) {
drawRadar(); drawRadar();
}
}, 30); }, 30);
}; };
@@ -303,17 +319,17 @@ const openTrainingRecord = () => {
}; };
const openTrainingItem = (item = {}) => { const openTrainingItem = (item = {}) => {
const mode = getTrainingMode(item); if (unavailableTrainingIds.has(item.id)) {
if (!mode) return;
if (item.is_locked) {
uni.showToast({ uni.showToast({
title: `${item.name || "训练"} 暂未开放`, title: "功能开发中...",
icon: "none", icon: "none",
}); });
return; return;
} }
const mode = getTrainingMode(item);
if (!mode) return;
uni.navigateTo({ uni.navigateTo({
url: `/pages/training/difficulty?mode=${mode}`, url: `/pages/training/difficulty?mode=${mode}`,
}); });
@@ -344,7 +360,12 @@ onShow(async () => {
</script> </script>
<template> <template>
<Container :showBackToGame="true" :bgType="7" bgColor="#050b19"> <Container
:showBackToGame="true"
:bgType="7"
bgColor="#050b19"
:usePageScroll="true"
>
<view class="training-home"> <view class="training-home">
<view class="week-grid"> <view class="week-grid">
<view <view
@@ -818,10 +839,9 @@ onShow(async () => {
} }
.radar-figure { .radar-figure {
position: absolute; position: relative;
left: 50%;
top: 54rpx; top: 54rpx;
transform: translateX(-50%); margin: 0 auto;
overflow: visible; overflow: visible;
} }
+66 -2
View File
@@ -18,6 +18,7 @@ import {
startPractiseAPI, startPractiseAPI,
endPractiseAPI, endPractiseAPI,
getPractiseAPI, getPractiseAPI,
getTrainingDifficultyListAPI,
} from "@/apis"; } from "@/apis";
import { import {
connectMatchWebSocket, connectMatchWebSocket,
@@ -52,6 +53,7 @@ const defaultTargetType = 1;
const total = ref(defaultTotal); const total = ref(defaultTotal);
const practiseResult = ref({}); const practiseResult = ref({});
const practiceEndSnapshot = ref({}); const practiceEndSnapshot = ref({});
const hasNextDifficulty = ref(false);
const practiseId = ref(""); const practiseId = ref("");
const showGuide = ref(false); const showGuide = ref(false);
const tips = ref(""); const tips = ref("");
@@ -64,7 +66,6 @@ const visiblePrecisionTarget = ref({
randomRingArea: 0, randomRingArea: 0,
}); });
const trainingDifficultyStorageKey = "training-selection"; const trainingDifficultyStorageKey = "training-selection";
const trainingDifficultyRefreshEvent = "training-difficulty-refresh";
const useHighlightTest = ref(false); const useHighlightTest = ref(false);
const highlightTestState = ref({ const highlightTestState = ref({
blocks: 8, blocks: 8,
@@ -87,6 +88,8 @@ let stopPracticeTask = null;
let practiceSyncTimer = null; let practiceSyncTimer = null;
let waitingPracticeSync = false; let waitingPracticeSync = false;
let shotPresentationGeneration = 0; let shotPresentationGeneration = 0;
let nextDifficultyRequest = null;
let nextDifficultyRequestGeneration = 0;
const audioWaiters = new Set(); const audioWaiters = new Set();
const shotEffectWaiters = new Map(); const shotEffectWaiters = new Map();
const PRACTICE_SYNC_TIMEOUT_MS = 5000; const PRACTICE_SYNC_TIMEOUT_MS = 5000;
@@ -152,6 +155,63 @@ const currentDifficultyLevel = computed(
getPositiveInteger(trainingParams.value.difficulty) getPositiveInteger(trainingParams.value.difficulty)
); );
const resetNextDifficultyState = () => {
nextDifficultyRequestGeneration += 1;
nextDifficultyRequest = null;
hasNextDifficulty.value = false;
};
const loadNextDifficultyState = (result = {}) => {
const requestGeneration = ++nextDifficultyRequestGeneration;
const currentLevel =
getPositiveInteger(result.difficultyLevel) ||
getPositiveInteger(result.difficulty_level) ||
currentDifficultyLevel.value;
const currentTrainingType = result.trainingType || trainingType.value;
hasNextDifficulty.value = false;
if (!currentTrainingType || !currentLevel) {
nextDifficultyRequest = Promise.resolve(false);
return nextDifficultyRequest;
}
nextDifficultyRequest = getTrainingDifficultyListAPI(currentTrainingType)
.then((difficultyResult) => {
const levels = Array.isArray(difficultyResult?.list)
? difficultyResult.list
.filter(
(item) => !item?.type || item.type === currentTrainingType
)
.map((item) => getPositiveInteger(item?.difficulty))
.filter(Boolean)
: [];
const maxLevel = Math.max(0, ...levels);
const highestCompletedLevel = getPositiveInteger(
difficultyResult?.user_levels?.[currentTrainingType]
);
const latestUnlockedLevel = Math.min(
highestCompletedLevel + 1,
maxLevel
);
const canAdvance =
currentLevel < maxLevel && latestUnlockedLevel > currentLevel;
if (requestGeneration === nextDifficultyRequestGeneration) {
hasNextDifficulty.value = canAdvance;
}
return canAdvance;
})
.catch((error) => {
console.log("training next difficulty load failed", error);
if (requestGeneration === nextDifficultyRequestGeneration) {
hasNextDifficulty.value = false;
}
return false;
});
return nextDifficultyRequest;
};
// time_limit 缺失或非正数都表示整局不限时。 // time_limit 缺失或非正数都表示整局不限时。
const timeLimit = computed(() => getPositiveInteger(practiceInfo.value.timeLimit)); const timeLimit = computed(() => getPositiveInteger(practiceInfo.value.timeLimit));
const hasTimeLimit = computed(() => timeLimit.value > 0); const hasTimeLimit = computed(() => timeLimit.value > 0);
@@ -1007,11 +1067,13 @@ const onOver = async () => {
try { try {
const apiResult = (await getPractiseAPI(practiseId.value)) || {}; const apiResult = (await getPractiseAPI(practiseId.value)) || {};
await (nextDifficultyRequest || loadNextDifficultyState(apiResult));
if (!enterPracticeResult(mergePracticeResult(apiResult))) { if (!enterPracticeResult(mergePracticeResult(apiResult))) {
pageStage.value = pageStages.DISTANCE; pageStage.value = pageStages.DISTANCE;
} }
} catch (error) { } catch (error) {
if (Object.keys(practiceEndSnapshot.value).length > 0) { if (Object.keys(practiceEndSnapshot.value).length > 0) {
await (nextDifficultyRequest || loadNextDifficultyState(practiceEndSnapshot.value));
enterPracticeResult(mergePracticeResult()); enterPracticeResult(mergePracticeResult());
return; return;
} }
@@ -1067,6 +1129,7 @@ async function onReceiveMessage(msg) {
} }
} else if (msg.type === MESSAGETYPESV2.BattleEnd) { } else if (msg.type === MESSAGETYPESV2.BattleEnd) {
practiceEndSnapshot.value = createPracticeEndSnapshot(msg); practiceEndSnapshot.value = createPracticeEndSnapshot(msg);
void loadNextDifficultyState(practiceEndSnapshot.value);
if ( if (
trainingType.value === "base" && trainingType.value === "base" &&
Number(msg.status) === 3 && Number(msg.status) === 3 &&
@@ -1093,7 +1156,6 @@ function onComplete() {
invalidateShotPresentations(); invalidateShotPresentations();
clearPracticeRuntimeContext(); clearPracticeRuntimeContext();
closePracticeConnection("training-practice-complete"); closePracticeConnection("training-practice-complete");
uni.$emit(trainingDifficultyRefreshEvent);
uni.navigateBack(); uni.navigateBack();
} }
@@ -1102,6 +1164,7 @@ async function onRetry() {
setPracticeAppHideResumable(false); setPracticeAppHideResumable(false);
clearHighlightTestTimer(); clearHighlightTestTimer();
useHighlightTest.value = false; useHighlightTest.value = false;
resetNextDifficultyState();
practiseId.value = ""; practiseId.value = "";
serverAddr.value = ""; serverAddr.value = "";
practiseResult.value = {}; practiseResult.value = {};
@@ -1332,6 +1395,7 @@ onBeforeUnmount(() => {
:onRetry="onRetry" :onRetry="onRetry"
:trainingType="trainingType" :trainingType="trainingType"
:difficultyLevel="currentDifficultyLevel" :difficultyLevel="currentDifficultyLevel"
:hasNextDifficulty="hasNextDifficulty"
:result="practiseResult" :result="practiseResult"
/> />
<canvas class="share-canvas" id="shareCanvas" type="2d"></canvas> <canvas class="share-canvas" id="shareCanvas" type="2d"></canvas>