Files
shoot-miniprograms/src/pages/training/difficulty.vue
2026-05-29 17:46:52 +08:00

802 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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

<script setup>
import { computed, nextTick, ref } from "vue";
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import Container from "@/components/Container.vue";
import TrainingDifficultyBadge from "./components/TrainingDifficultyBadge.vue";
import TrainingDifficultyPreviewCard from "./components/TrainingDifficultyPreviewCard.vue";
import TrainingDifficultyStartButton from "./components/TrainingDifficultyStartButton.vue";
import { getTrainingDifficultyListAPI } from "@/apis";
// 难度页接口数据源:
// 1. 接口GET /training/difficulty/list?type=base/endurance/precision/rhythm
// 2. 当前进度:接口 user_levels / list.completed路由参数可覆盖选中难度
const trainingDifficultyStorageKey = "training-selection";
const trainingDifficultyRefreshEvent = "training-difficulty-refresh";
const defaultTrainingType = "precision";
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 defaultTargetType = 1;
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 = {
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 pageConfig = ref(createEmptyModeConfig(defaultTrainingType));
const unlockedDifficultyId = ref(defaultUnlockedDifficultyId);
const selectedDifficultyId = ref(defaultUnlockedDifficultyId);
const nodesScrollTop = ref(0);
const nodesScrollWithAnimation = ref(false);
const routeOptions = ref({});
const needRefreshProgress = ref(false);
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 normalizeRouteOptions = (options = {}) => {
const difficultyLevel = toNumber(options.difficulty);
const completedDifficultyLevel = toNumber(options.completedDifficulty);
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;
selectedDifficultyId.value = resolveSelectedDifficultyId(
safeOptions.difficultyId,
nodes,
currentUnlockedId
);
nextTick(() => {
initScrollPosition();
});
};
const initPageState = async (options = {}, refreshOptions = {}) => {
const { keepCurrent = false } = refreshOptions;
const trainingType = resolveTrainingType(options.mode);
const fallbackConfig = createEmptyModeConfig(trainingType);
if (!keepCurrent) {
pageConfig.value = fallbackConfig;
}
try {
const result = await getTrainingDifficultyListAPI(trainingType);
applyPageState(
options,
normalizeTrainingDifficultyConfig(result, trainingType)
);
} catch (error) {
console.log("training difficulty load failed", error);
if (!keepCurrent) {
applyPageState(options, fallbackConfig);
}
uni.showToast({
title: "训练难度加载失败",
icon: "none",
});
}
};
const cleanQueryValue = (value) => {
if (value === undefined || value === null || value === "") {
return "";
}
if (typeof value === "number" && !Number.isFinite(value)) {
return "";
}
return value;
};
const createPracticeQuery = (difficulty) => {
const trainingType = pageConfig.value.key || defaultTrainingType;
const commonQuery = {
type: trainingType,
difficultyId: difficulty.id,
difficulty: difficulty.level,
recordId: difficulty.recordId,
arrows: toNumber(difficulty.arrows, 12),
time: toNumber(difficulty.time_limit, 120) || 120,
target: defaultTargetType,
};
const typedQueryMap = {
base: {
hitReq: toNumber(difficulty.hit_req),
},
endurance: {
totalReq: toNumber(difficulty.total_req),
},
precision: {
blocks: toNumber(difficulty.blocks),
mode: toNumber(difficulty.mode),
},
rhythm: {
hitReq: toNumber(difficulty.hit_req),
mode: toNumber(difficulty.mode),
},
};
return {
...commonQuery,
...(typedQueryMap[trainingType] || {}),
};
};
const createPracticeUrl = (difficulty) => {
const query = Object.entries(createPracticeQuery(difficulty))
.map(([key, value]) => [key, cleanQueryValue(value)])
.filter(([, value]) => value !== "")
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join("&");
return `/pages/training/practise-one${query ? `?${query}` : ""}`;
};
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,
targetType: defaultTargetType,
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.navigateTo({
url: createPracticeUrl(selectedDifficulty.value),
});
};
const markProgressRefresh = () => {
needRefreshProgress.value = true;
};
onLoad((options = {}) => {
routeOptions.value = { ...options };
uni.$on(trainingDifficultyRefreshEvent, markProgressRefresh);
initPageState(options);
});
onShow(() => {
if (!needRefreshProgress.value) {
return;
}
needRefreshProgress.value = false;
initPageState(routeOptions.value, {
keepCurrent: true,
});
});
onUnload(() => {
uni.$off(trainingDifficultyRefreshEvent, markProgressRefresh);
});
</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>