802 lines
21 KiB
Vue
802 lines
21 KiB
Vue
<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>
|