diff --git a/src/apis.js b/src/apis.js
index 06a474f..8431626 100644
--- a/src/apis.js
+++ b/src/apis.js
@@ -411,6 +411,11 @@ export const getPersonalTrainingAPI = async () => {
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 () => {
return request("GET", "/user/fight/statistics");
};
diff --git a/src/components/training/TrainingDifficultyBadge.vue b/src/components/training/TrainingDifficultyBadge.vue
index 29c0f3e..01ee967 100644
--- a/src/components/training/TrainingDifficultyBadge.vue
+++ b/src/components/training/TrainingDifficultyBadge.vue
@@ -62,8 +62,8 @@ const badgeStateStyle = computed(() => {
};
});
-const isCompleted = computed(() => {
- return !props.active && !props.locked && progressValue.value > 0;
+const showProgress = computed(() => {
+ return !props.locked;
});
const badgeFillSrc = computed(() => {
@@ -80,7 +80,7 @@ const handleClick = () => {
class="difficulty-badge"
:class="{
'difficulty-badge--active': active,
- 'difficulty-badge--completed': isCompleted,
+ 'difficulty-badge--progress': showProgress,
'difficulty-badge--locked': locked,
}"
:style="[badgeStyle, badgeStateStyle]"
@@ -134,7 +134,7 @@ const handleClick = () => {
content: "";
position: absolute;
inset: calc(var(--badge-orbit-offset) * -1);
- border: 4rpx solid #ffffff;
+ border: 4rpx solid transparent;
border-radius: 50%;
pointer-events: none;
box-sizing: border-box;
@@ -236,8 +236,8 @@ const handleClick = () => {
content: none;
}
-.difficulty-badge--completed .difficulty-badge__fill::before,
-.difficulty-badge--completed .difficulty-badge__fill::after {
+.difficulty-badge--progress .difficulty-badge__fill::before,
+.difficulty-badge--progress .difficulty-badge__fill::after {
content: "";
position: absolute;
inset: -12rpx;
@@ -251,11 +251,11 @@ const handleClick = () => {
pointer-events: none;
}
-.difficulty-badge--completed .difficulty-badge__fill::before {
+.difficulty-badge--progress .difficulty-badge__fill::before {
background: rgba(255, 255, 255, 0.35);
}
-.difficulty-badge--completed .difficulty-badge__fill::after {
+.difficulty-badge--progress .difficulty-badge__fill::after {
background: conic-gradient(
from -90deg,
rgba(254, 208, 152, 1) 0,
@@ -265,7 +265,7 @@ const handleClick = () => {
}
.difficulty-badge--active .difficulty-badge__label,
-.difficulty-badge--completed .difficulty-badge__label {
+.difficulty-badge--progress .difficulty-badge__label {
color: #333333;
}
diff --git a/src/components/training/TrainingDifficultyPreviewCard.vue b/src/components/training/TrainingDifficultyPreviewCard.vue
index 3269ac6..d8fa3a8 100644
--- a/src/components/training/TrainingDifficultyPreviewCard.vue
+++ b/src/components/training/TrainingDifficultyPreviewCard.vue
@@ -1,5 +1,7 @@
@@ -21,8 +27,13 @@ defineProps({
{{ title }}
- 箭靶划分为3个区域、需5次命中目标
- 100秒内完成所有射击
+
+ {{ line }}
+
@@ -67,5 +78,9 @@ defineProps({
text-align: center;
}
+.difficulty-preview__line {
+ display: block;
+}
+
diff --git a/src/pages/training/difficulty.vue b/src/pages/training/difficulty.vue
index 9bbc594..711f38c 100644
--- a/src/pages/training/difficulty.vue
+++ b/src/pages/training/difficulty.vue
@@ -6,16 +6,205 @@ import TargetPicker from "@/components/TargetPicker.vue";
import TrainingDifficultyBadge from "@/components/training/TrainingDifficultyBadge.vue";
import TrainingDifficultyPreviewCard from "@/components/training/TrainingDifficultyPreviewCard.vue";
import TrainingDifficultyStartButton from "@/components/training/TrainingDifficultyStartButton.vue";
-import {
- getTrainingDifficultyModeConfig,
- trainingDifficultyStorageKey,
-} from "@/mock/trainingDifficulty.js";
+import { getTrainingDifficultyListAPI } from "@/apis";
-// 当前页面的数据源:
-// 1. 模式配置:src/mock/trainingDifficulty.js -> getTrainingDifficultyModeConfig
-// 2. 当前进度:页面路由参数或配置中的 activeDifficultyId / progressMap
-const defaultModeKey = "precision";
+// 难度页接口数据源:
+// 1. 接口:GET /training/difficulty/list?type=base/endurance/precision/rhythm
+// 2. 当前进度:接口 user_levels / list.completed,路由参数可覆盖选中难度
+const trainingDifficultyStorageKey = "training-selection";
+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 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 = {
@@ -39,18 +228,13 @@ const emptyDifficulty = {
};
// 页面基础状态
-const modeKey = ref(defaultModeKey);
+const pageConfig = ref(createEmptyModeConfig(defaultTrainingType));
const unlockedDifficultyId = ref(defaultUnlockedDifficultyId);
const selectedDifficultyId = ref(defaultUnlockedDifficultyId);
const showTargetPicker = ref(false);
const nodesScrollTop = ref(0);
const nodesScrollWithAnimation = ref(false);
-// 当前训练模式配置
-const pageConfig = computed(() => {
- return getTrainingDifficultyModeConfig(modeKey.value);
-});
-
const difficultyProgressMap = computed(() => {
return pageConfig.value?.progressMap || {};
});
@@ -302,15 +486,36 @@ const initScrollPosition = () => {
});
};
-const initPageState = (options = {}) => {
- const config = getTrainingDifficultyModeConfig(options.mode);
- const nodes = createDifficultyNodes(config);
- const currentUnlockedId = resolveUnlockedDifficultyId(options, config, nodes);
+const normalizeRouteOptions = (options = {}) => {
+ const difficultyLevel = toNumber(options.difficulty);
+ const completedDifficultyLevel = toNumber(options.completedDifficulty);
- 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;
selectedDifficultyId.value = resolveSelectedDifficultyId(
- options.difficultyId,
+ safeOptions.difficultyId,
nodes,
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) => {
return Number(target) === 1 ? "20厘米全环靶" : "40厘米全环靶";
};