update:对接个人训练难度页
This commit is contained in:
@@ -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");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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厘米全环靶";
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user