update:对接个人训练难度页

This commit is contained in:
2026-05-26 09:33:28 +08:00
parent bae31add22
commit 2a53f6739e
4 changed files with 279 additions and 32 deletions

View File

@@ -411,6 +411,11 @@ export const getPersonalTrainingAPI = async () => {
return request("GET", "/personal/training"); return request("GET", "/personal/training");
}; };
export const getTrainingDifficultyListAPI = async (type) => {
const query = type ? `?type=${encodeURIComponent(type)}` : "";
return request("GET", `/training/difficulty/list${query}`);
};
export const getBattleDataAPI = async () => { export const getBattleDataAPI = async () => {
return request("GET", "/user/fight/statistics"); return request("GET", "/user/fight/statistics");
}; };

View File

@@ -62,8 +62,8 @@ const badgeStateStyle = computed(() => {
}; };
}); });
const isCompleted = computed(() => { const showProgress = computed(() => {
return !props.active && !props.locked && progressValue.value > 0; return !props.locked;
}); });
const badgeFillSrc = computed(() => { const badgeFillSrc = computed(() => {
@@ -80,7 +80,7 @@ const handleClick = () => {
class="difficulty-badge" class="difficulty-badge"
:class="{ :class="{
'difficulty-badge--active': active, 'difficulty-badge--active': active,
'difficulty-badge--completed': isCompleted, 'difficulty-badge--progress': showProgress,
'difficulty-badge--locked': locked, 'difficulty-badge--locked': locked,
}" }"
:style="[badgeStyle, badgeStateStyle]" :style="[badgeStyle, badgeStateStyle]"
@@ -134,7 +134,7 @@ const handleClick = () => {
content: ""; content: "";
position: absolute; position: absolute;
inset: calc(var(--badge-orbit-offset) * -1); inset: calc(var(--badge-orbit-offset) * -1);
border: 4rpx solid #ffffff; border: 4rpx solid transparent;
border-radius: 50%; border-radius: 50%;
pointer-events: none; pointer-events: none;
box-sizing: border-box; box-sizing: border-box;
@@ -236,8 +236,8 @@ const handleClick = () => {
content: none; content: none;
} }
.difficulty-badge--completed .difficulty-badge__fill::before, .difficulty-badge--progress .difficulty-badge__fill::before,
.difficulty-badge--completed .difficulty-badge__fill::after { .difficulty-badge--progress .difficulty-badge__fill::after {
content: ""; content: "";
position: absolute; position: absolute;
inset: -12rpx; inset: -12rpx;
@@ -251,11 +251,11 @@ const handleClick = () => {
pointer-events: none; pointer-events: none;
} }
.difficulty-badge--completed .difficulty-badge__fill::before { .difficulty-badge--progress .difficulty-badge__fill::before {
background: rgba(255, 255, 255, 0.35); background: rgba(255, 255, 255, 0.35);
} }
.difficulty-badge--completed .difficulty-badge__fill::after { .difficulty-badge--progress .difficulty-badge__fill::after {
background: conic-gradient( background: conic-gradient(
from -90deg, from -90deg,
rgba(254, 208, 152, 1) 0, rgba(254, 208, 152, 1) 0,
@@ -265,7 +265,7 @@ const handleClick = () => {
} }
.difficulty-badge--active .difficulty-badge__label, .difficulty-badge--active .difficulty-badge__label,
.difficulty-badge--completed .difficulty-badge__label { .difficulty-badge--progress .difficulty-badge__label {
color: #333333; color: #333333;
} }

View File

@@ -1,5 +1,7 @@
<script setup> <script setup>
defineProps({ import { computed } from "vue";
const props = defineProps({
title: { title: {
type: String, type: String,
default: "", default: "",
@@ -9,6 +11,10 @@ defineProps({
default: () => [], default: () => [],
}, },
}); });
const previewLines = computed(() => {
return props.lines.map((line) => String(line || "").trim()).filter(Boolean);
});
</script> </script>
<template> <template>
@@ -21,8 +27,13 @@ defineProps({
<view class="difficulty-preview__content"> <view class="difficulty-preview__content">
<text class="difficulty-preview__title">{{ title }}</text> <text class="difficulty-preview__title">{{ title }}</text>
<view class="difficulty-preview__copy"> <view class="difficulty-preview__copy">
箭靶划分为3个区域需5次命中目标 <text
100秒内完成所有射击 v-for="(line, index) in previewLines"
:key="`${line}-${index}`"
class="difficulty-preview__line"
>
{{ line }}
</text>
</view> </view>
</view> </view>
</view> </view>
@@ -67,5 +78,9 @@ defineProps({
text-align: center; text-align: center;
} }
.difficulty-preview__line {
display: block;
}
</style> </style>

View File

@@ -6,16 +6,205 @@ import TargetPicker from "@/components/TargetPicker.vue";
import TrainingDifficultyBadge from "@/components/training/TrainingDifficultyBadge.vue"; import TrainingDifficultyBadge from "@/components/training/TrainingDifficultyBadge.vue";
import TrainingDifficultyPreviewCard from "@/components/training/TrainingDifficultyPreviewCard.vue"; import TrainingDifficultyPreviewCard from "@/components/training/TrainingDifficultyPreviewCard.vue";
import TrainingDifficultyStartButton from "@/components/training/TrainingDifficultyStartButton.vue"; import TrainingDifficultyStartButton from "@/components/training/TrainingDifficultyStartButton.vue";
import { import { getTrainingDifficultyListAPI } from "@/apis";
getTrainingDifficultyModeConfig,
trainingDifficultyStorageKey,
} from "@/mock/trainingDifficulty.js";
// 当前页面的数据源: // 难度页接口数据源:
// 1. 模式配置src/mock/trainingDifficulty.js -> getTrainingDifficultyModeConfig // 1. 接口GET /training/difficulty/list?type=base/endurance/precision/rhythm
// 2. 当前进度:页面路由参数或配置中的 activeDifficultyId / progressMap // 2. 当前进度:接口 user_levels / list.completed路由参数可覆盖选中难度
const defaultModeKey = "precision"; const trainingDifficultyStorageKey = "training-selection";
const defaultTrainingType = "precision";
const defaultUnlockedDifficultyId = "lv1"; const defaultUnlockedDifficultyId = "lv1";
const trainingTypeMetaMap = {
base: {
key: "base",
title: "基础训练",
},
endurance: {
key: "endurance",
title: "耐力训练",
},
precision: {
key: "precision",
title: "精准训练",
},
rhythm: {
key: "rhythm",
title: "节奏训练",
},
};
const routeModeTypeMap = {
basic: "base",
base: "base",
endurance: "endurance",
precision: "precision",
rhythm: "rhythm",
};
const resolveTrainingType = (mode) => {
const normalizedMode = String(mode || "").toLowerCase();
return routeModeTypeMap[normalizedMode] || defaultTrainingType;
};
const createDifficultyId = (level) => `lv${level}`;
const toNumber = (value, fallback = 0) => {
const numberValue = Number(value);
return Number.isFinite(numberValue) ? numberValue : fallback;
};
const clampProgress = (value) => {
return Math.min(Math.max(value, 0), 100);
};
const getDifficultyProgress = (item = {}) => {
const completedCnt = toNumber(item.completed_cnt);
const promoteCnt = toNumber(item.promote_cnt);
if (item.completed) {
return 100;
}
if (promoteCnt <= 0) {
return 0;
}
return clampProgress(Math.round((completedCnt / promoteCnt) * 100));
};
const checkDifficultyCompleted = (item = {}) => {
const completedCnt = toNumber(item.completed_cnt);
const promoteCnt = toNumber(item.promote_cnt);
return Boolean(item.completed) || (promoteCnt > 0 && completedCnt >= promoteCnt);
};
const getDifficultyModeText = (mode) => {
return Number(mode) === 1 ? "随机区域+指定环数" : "随机区域命中";
};
const createEmptyModeConfig = (type = defaultTrainingType) => {
const meta = trainingTypeMetaMap[type] || trainingTypeMetaMap[defaultTrainingType];
return {
key: meta.key,
title: meta.title,
nodes: [],
details: {},
activeDifficultyId: defaultUnlockedDifficultyId,
progressMap: {},
};
};
const createDifficultySummary = (item = {}) => {
const desc = String(item.desc || "").trim();
const type = item.type;
const arrows = toNumber(item.arrows);
const timeLimit = toNumber(item.time_limit);
const hitReq = toNumber(item.hit_req);
const totalReq = toNumber(item.total_req);
const blocks = toNumber(item.blocks);
const promoteCnt = toNumber(item.promote_cnt);
const timeText = timeLimit > 0 ? `${timeLimit}秒内完成` : "不限时完成";
const promoteText = promoteCnt > 0 ? `完成${promoteCnt}次晋级` : "";
const summaryMap = {
base: [
desc || (hitReq > 0 ? `每箭命中${hitReq}环以上` : "上靶即可"),
[`${arrows}`, promoteText].filter(Boolean).join(" · "),
],
endurance: [
desc || `${timeText}${arrows}`,
[`累计${totalReq}`, promoteText].filter(Boolean).join(" · "),
],
precision: [
desc || `命中${blocks}个指定区域`,
[
`${arrows}`,
timeText,
getDifficultyModeText(item.mode),
promoteText,
]
.filter(Boolean)
.join(" · "),
],
rhythm: [
desc || `间隔${timeLimit}秒射击`,
[
`${arrows}`,
hitReq > 0 ? `每箭${hitReq}环以上` : "上靶即可",
getDifficultyModeText(item.mode),
promoteText,
]
.filter(Boolean)
.join(" · "),
],
};
return (summaryMap[type] || [desc]).filter(Boolean);
};
const normalizeTrainingDifficultyConfig = (result, type) => {
const meta = trainingTypeMetaMap[type] || trainingTypeMetaMap[defaultTrainingType];
const list = Array.isArray(result?.list) ? result.list : [];
const rawItems = list.filter((item) => !item?.type || item.type === meta.key);
const difficultyItems = rawItems
.map((item) => {
const level = toNumber(item?.difficulty);
if (level <= 0) {
return null;
}
const id = createDifficultyId(level);
const label = `Lv${level}`;
return {
...item,
recordId: item.id,
completedCnt: toNumber(item.completed_cnt),
promoteCnt: toNumber(item.promote_cnt),
id,
level,
label,
title: `${label}难度`,
summary: createDifficultySummary(item),
startText: "开始",
targetPaperType: "20CM全环靶",
};
})
.filter(Boolean)
.sort((first, second) => first.level - second.level);
const maxLevel = difficultyItems.reduce(
(currentMax, item) => Math.max(currentMax, item.level),
0
);
const completedLevelFromList = difficultyItems.reduce((currentMax, item) => {
return checkDifficultyCompleted(item)
? Math.max(currentMax, item.level)
: currentMax;
}, 0);
const userCompletedLevel = toNumber(result?.user_levels?.[meta.key]);
const highestCompletedLevel = userCompletedLevel || completedLevelFromList;
const unlockedLevel = maxLevel
? Math.min(Math.max(highestCompletedLevel + 1, 1), maxLevel)
: 1;
return {
key: meta.key,
title: meta.title,
nodes: difficultyItems.map((item) => ({
id: item.id,
label: item.label,
})),
details: Object.fromEntries(
difficultyItems.map((item) => [item.id, item])
),
activeDifficultyId: createDifficultyId(unlockedLevel),
progressMap: Object.fromEntries(
difficultyItems.map((item) => [item.id, getDifficultyProgress(item)])
),
};
};
// 难度轴布局参数,节点按“等级越低越靠下”的方式排列。 // 难度轴布局参数,节点按“等级越低越靠下”的方式排列。
const nodesLayout = { const nodesLayout = {
@@ -39,18 +228,13 @@ const emptyDifficulty = {
}; };
// 页面基础状态 // 页面基础状态
const modeKey = ref(defaultModeKey); const pageConfig = ref(createEmptyModeConfig(defaultTrainingType));
const unlockedDifficultyId = ref(defaultUnlockedDifficultyId); const unlockedDifficultyId = ref(defaultUnlockedDifficultyId);
const selectedDifficultyId = ref(defaultUnlockedDifficultyId); const selectedDifficultyId = ref(defaultUnlockedDifficultyId);
const showTargetPicker = ref(false); const showTargetPicker = ref(false);
const nodesScrollTop = ref(0); const nodesScrollTop = ref(0);
const nodesScrollWithAnimation = ref(false); const nodesScrollWithAnimation = ref(false);
// 当前训练模式配置
const pageConfig = computed(() => {
return getTrainingDifficultyModeConfig(modeKey.value);
});
const difficultyProgressMap = computed(() => { const difficultyProgressMap = computed(() => {
return pageConfig.value?.progressMap || {}; return pageConfig.value?.progressMap || {};
}); });
@@ -302,15 +486,36 @@ const initScrollPosition = () => {
}); });
}; };
const initPageState = (options = {}) => { const normalizeRouteOptions = (options = {}) => {
const config = getTrainingDifficultyModeConfig(options.mode); const difficultyLevel = toNumber(options.difficulty);
const nodes = createDifficultyNodes(config); const completedDifficultyLevel = toNumber(options.completedDifficulty);
const currentUnlockedId = resolveUnlockedDifficultyId(options, config, nodes);
modeKey.value = config.key; return {
...options,
difficultyId:
options.difficultyId ||
(difficultyLevel > 0 ? createDifficultyId(difficultyLevel) : ""),
completedDifficultyId:
options.completedDifficultyId ||
(completedDifficultyLevel > 0
? createDifficultyId(completedDifficultyLevel)
: ""),
};
};
const applyPageState = (options = {}, config) => {
const safeOptions = normalizeRouteOptions(options);
const nodes = createDifficultyNodes(config);
const currentUnlockedId = resolveUnlockedDifficultyId(
safeOptions,
config,
nodes
);
pageConfig.value = config;
unlockedDifficultyId.value = currentUnlockedId; unlockedDifficultyId.value = currentUnlockedId;
selectedDifficultyId.value = resolveSelectedDifficultyId( selectedDifficultyId.value = resolveSelectedDifficultyId(
options.difficultyId, safeOptions.difficultyId,
nodes, nodes,
currentUnlockedId currentUnlockedId
); );
@@ -320,6 +525,28 @@ const initPageState = (options = {}) => {
}); });
}; };
const initPageState = async (options = {}) => {
const trainingType = resolveTrainingType(options.mode);
const fallbackConfig = createEmptyModeConfig(trainingType);
pageConfig.value = fallbackConfig;
try {
const result = await getTrainingDifficultyListAPI(trainingType);
applyPageState(
options,
normalizeTrainingDifficultyConfig(result, trainingType)
);
} catch (error) {
console.log("training difficulty load failed", error);
applyPageState(options, fallbackConfig);
uni.showToast({
title: "训练难度加载失败",
icon: "none",
});
}
};
const resolveTargetPaperType = (target) => { const resolveTargetPaperType = (target) => {
return Number(target) === 1 ? "20厘米全环靶" : "40厘米全环靶"; return Number(target) === 1 ? "20厘米全环靶" : "40厘米全环靶";
}; };