diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
index 2e541bd..d92774f 100644
--- a/app/(tabs)/index.tsx
+++ b/app/(tabs)/index.tsx
@@ -75,6 +75,7 @@ export default function HomeScreen() {
level="初学者"
progress={0}
/>
+ router.push('/challenge')}>
+
diff --git a/app/_layout.tsx b/app/_layout.tsx
index bd921ee..3100a47 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -37,6 +37,7 @@ export default function RootLayout() {
+
diff --git a/app/auth/login.tsx b/app/auth/login.tsx
index f814a13..bb672e1 100644
--- a/app/auth/login.tsx
+++ b/app/auth/login.tsx
@@ -168,7 +168,7 @@ const styles = StyleSheet.create({
},
title: {
fontSize: 32,
- fontWeight: '800',
+ fontWeight: '500',
letterSpacing: 0.5,
},
subtitle: {
diff --git a/app/challenge/_layout.tsx b/app/challenge/_layout.tsx
new file mode 100644
index 0000000..b86fc0d
--- /dev/null
+++ b/app/challenge/_layout.tsx
@@ -0,0 +1,13 @@
+import { Stack } from 'expo-router';
+import React from 'react';
+
+export default function ChallengeLayout() {
+ return (
+
+
+
+
+ );
+}
+
+
diff --git a/app/challenge/day.tsx b/app/challenge/day.tsx
new file mode 100644
index 0000000..dd36ae3
--- /dev/null
+++ b/app/challenge/day.tsx
@@ -0,0 +1,182 @@
+import { useAppDispatch, useAppSelector } from '@/hooks/redux';
+import { completeDay, setCustom } from '@/store/challengeSlice';
+import type { Exercise, ExerciseCustomConfig } from '@/utils/pilatesPlan';
+import { Ionicons } from '@expo/vector-icons';
+import { useLocalSearchParams, useRouter } from 'expo-router';
+import React, { useState } from 'react';
+import { FlatList, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+
+export default function ChallengeDayScreen() {
+ const { day } = useLocalSearchParams<{ day: string }>();
+ const router = useRouter();
+ const dispatch = useAppDispatch();
+ const challenge = useAppSelector((s) => (s as any).challenge);
+ const dayNumber = Math.max(1, Math.min(30, parseInt(String(day || '1'), 10)));
+ const dayState = challenge?.days?.[dayNumber - 1];
+ const [currentSetIndexByExercise, setCurrentSetIndexByExercise] = useState>({});
+ const [custom, setCustomLocal] = useState(dayState?.custom || []);
+
+ const isLocked = dayState?.status === 'locked';
+ const isCompleted = dayState?.status === 'completed';
+ const plan = dayState?.plan;
+
+ // 不再强制所有动作完成,始终允许完成
+ const canFinish = true;
+
+ const handleNextSet = (ex: Exercise) => {
+ const curr = currentSetIndexByExercise[ex.key] ?? 0;
+ if (curr < ex.sets.length) {
+ setCurrentSetIndexByExercise((prev) => ({ ...prev, [ex.key]: curr + 1 }));
+ }
+ };
+
+ const handleComplete = async () => {
+ // 持久化自定义配置
+ await dispatch(setCustom({ dayNumber, custom: custom }));
+ await dispatch(completeDay(dayNumber));
+ router.back();
+ };
+
+ const updateCustom = (key: string, partial: Partial) => {
+ setCustomLocal((prev) => {
+ const next = prev.map((c) => (c.key === key ? { ...c, ...partial } : c));
+ return next;
+ });
+ };
+
+ if (!plan) {
+ return (
+
+ 加载中...
+
+ );
+ }
+
+ return (
+
+
+
+
+ router.back()} style={styles.backButton} accessibilityRole="button">
+
+
+ 第{plan.dayNumber}天
+
+
+ {plan.title}
+ {plan.focus}
+
+
+ item.key}
+ contentContainerStyle={{ paddingHorizontal: 20, paddingBottom: 120 }}
+ renderItem={({ item }) => {
+ const doneSets = currentSetIndexByExercise[item.key] ?? 0;
+ const conf = custom.find((c) => c.key === item.key);
+ const targetSets = conf?.sets ?? item.sets.length;
+ const perSetDuration = conf?.durationSec ?? item.sets[0]?.durationSec ?? 40;
+ return (
+
+
+ {item.name}
+ {item.description}
+
+
+ updateCustom(item.key, { enabled: !(conf?.enabled ?? true) })}>
+ {conf?.enabled === false ? '已关闭' : '已启用'}
+
+
+ 组数
+
+ updateCustom(item.key, { sets: Math.max(1, (conf?.sets ?? targetSets) - 1) })}>-
+ {conf?.sets ?? targetSets}
+ updateCustom(item.key, { sets: Math.min(10, (conf?.sets ?? targetSets) + 1) })}>+
+
+
+
+ 时长/组
+
+ updateCustom(item.key, { durationSec: Math.max(10, (conf?.durationSec ?? perSetDuration) - 5) })}>-
+ {conf?.durationSec ?? perSetDuration}s
+ updateCustom(item.key, { durationSec: Math.min(180, (conf?.durationSec ?? perSetDuration) + 5) })}>+
+
+
+
+
+ {Array.from({ length: targetSets }).map((_, idx) => (
+
+
+ {perSetDuration}s
+
+
+ ))}
+
+ handleNextSet(item)} disabled={doneSets >= targetSets || conf?.enabled === false}>
+ {doneSets >= item.sets.length ? '本动作完成' : '完成一组'}
+
+ {item.tips && (
+
+ {item.tips.map((t: string, i: number) => (
+ • {t}
+ ))}
+
+ )}
+
+ );
+ }}
+ />
+
+
+
+ {isCompleted ? '已完成' : '完成今日训练'}
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ safeArea: { flex: 1, backgroundColor: '#F7F8FA' },
+ container: { flex: 1, backgroundColor: '#F7F8FA' },
+ header: { paddingHorizontal: 20, paddingTop: 10, paddingBottom: 10 },
+ headerRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
+ backButton: { width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', backgroundColor: '#E5E7EB' },
+ headerTitle: { fontSize: 18, fontWeight: '800', color: '#1A1A1A' },
+ title: { marginTop: 6, fontSize: 20, fontWeight: '800', color: '#1A1A1A' },
+ subtitle: { marginTop: 6, fontSize: 12, color: '#6B7280' },
+ exerciseCard: {
+ backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, marginTop: 12,
+ shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3,
+ },
+ exerciseHeader: { marginBottom: 8 },
+ exerciseName: { fontSize: 16, fontWeight: '800', color: '#111827' },
+ exerciseDesc: { marginTop: 4, fontSize: 12, color: '#6B7280' },
+ setsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 8 },
+ controlsRow: { flexDirection: 'row', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginTop: 8 },
+ toggleBtn: { backgroundColor: '#111827', paddingHorizontal: 12, paddingVertical: 8, borderRadius: 8 },
+ toggleBtnOff: { backgroundColor: '#9CA3AF' },
+ toggleBtnText: { color: '#FFFFFF', fontWeight: '700' },
+ counterBox: { backgroundColor: '#F3F4F6', borderRadius: 8, padding: 8 },
+ counterLabel: { fontSize: 10, color: '#6B7280' },
+ counterRow: { flexDirection: 'row', alignItems: 'center' },
+ counterBtn: { backgroundColor: '#E5E7EB', width: 28, height: 28, borderRadius: 6, alignItems: 'center', justifyContent: 'center' },
+ counterBtnText: { fontWeight: '800', color: '#111827' },
+ counterValue: { minWidth: 40, textAlign: 'center', fontWeight: '700', color: '#111827' },
+ setPill: { paddingHorizontal: 10, paddingVertical: 6, borderRadius: 999 },
+ setPillTodo: { backgroundColor: '#F3F4F6' },
+ setPillDone: { backgroundColor: '#BBF246' },
+ setPillText: { fontSize: 12, fontWeight: '700' },
+ setPillTextTodo: { color: '#6B7280' },
+ setPillTextDone: { color: '#192126' },
+ nextSetBtn: { marginTop: 10, alignSelf: 'flex-start', backgroundColor: '#111827', paddingHorizontal: 12, paddingVertical: 8, borderRadius: 8 },
+ nextSetText: { color: '#FFFFFF', fontWeight: '700' },
+ tipsBox: { marginTop: 10, backgroundColor: '#F9FAFB', borderRadius: 8, padding: 10 },
+ tipText: { fontSize: 12, color: '#6B7280', lineHeight: 18 },
+ bottomBar: { position: 'absolute', left: 0, right: 0, bottom: 0, padding: 20, backgroundColor: 'transparent' },
+ finishBtn: { backgroundColor: '#BBF246', paddingVertical: 14, borderRadius: 999, alignItems: 'center' },
+ finishBtnText: { color: '#192126', fontWeight: '800', fontSize: 16 },
+});
+
+
diff --git a/app/challenge/index.tsx b/app/challenge/index.tsx
new file mode 100644
index 0000000..6bfb602
--- /dev/null
+++ b/app/challenge/index.tsx
@@ -0,0 +1,139 @@
+import { useAppDispatch, useAppSelector } from '@/hooks/redux';
+import { initChallenge } from '@/store/challengeSlice';
+import { estimateSessionMinutesWithCustom } from '@/utils/pilatesPlan';
+import { Ionicons } from '@expo/vector-icons';
+import { useRouter } from 'expo-router';
+import React, { useEffect, useMemo } from 'react';
+import { Dimensions, FlatList, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+
+export default function ChallengeHomeScreen() {
+ const dispatch = useAppDispatch();
+ const router = useRouter();
+ const challenge = useAppSelector((s) => (s as any).challenge);
+
+ useEffect(() => {
+ dispatch(initChallenge());
+ }, [dispatch]);
+
+ const progress = useMemo(() => {
+ const total = challenge?.days?.length || 30;
+ const done = challenge?.days?.filter((d: any) => d.status === 'completed').length || 0;
+ return total ? done / total : 0;
+ }, [challenge?.days]);
+
+ return (
+
+
+
+
+ router.back()} style={styles.backButton} accessibilityRole="button">
+
+
+ 30天普拉提打卡
+
+
+ 专注核心、体态与柔韧 · 连续完成解锁徽章
+
+
+ {/* 进度环与统计 */}
+
+
+
+
+
+ {Math.round((progress || 0) * 100)}%
+
+
+ {challenge?.streak ?? 0} 天连续
+ {(challenge?.days?.filter((d: any)=>d.status==='completed').length) ?? 0} / 30 完成
+
+
+
+ {/* 日历格子(简单 6x5 网格) */}
+ String(item.plan.dayNumber)}
+ numColumns={5}
+ columnWrapperStyle={{ justifyContent: 'space-between', marginBottom: 12 }}
+ contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 10, paddingBottom: 40 }}
+ renderItem={({ item }) => {
+ const { plan, status } = item;
+ const isLocked = status === 'locked';
+ const isCompleted = status === 'completed';
+ const minutes = estimateSessionMinutesWithCustom(plan, item.custom);
+ return (
+ router.push({ pathname: '/challenge/day', params: { day: String(plan.dayNumber) } })}
+ style={[styles.dayCell, isLocked && styles.dayCellLocked, isCompleted && styles.dayCellCompleted]}
+ activeOpacity={0.8}
+ >
+ {plan.dayNumber}
+ {minutes}′
+ {isCompleted && }
+ {isLocked && }
+
+ );
+ }}
+ />
+
+ {/* 底部 CTA */}
+
+ router.push({ pathname: '/challenge/day', params: { day: String((challenge?.days?.find((d:any)=>d.status==='available')?.plan.dayNumber) || 1) } })}>
+ 开始今日训练
+
+
+
+
+ );
+}
+
+const { width } = Dimensions.get('window');
+const cellSize = (width - 40 - 4 * 12) / 5; // 20 padding *2, 12 spacing *4
+
+const styles = StyleSheet.create({
+ safeArea: { flex: 1, backgroundColor: '#F7F8FA' },
+ container: { flex: 1, backgroundColor: '#F7F8FA' },
+ header: { paddingHorizontal: 20, paddingTop: 10 },
+ headerRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
+ backButton: { width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', backgroundColor: '#E5E7EB' },
+ headerTitle: { fontSize: 22, fontWeight: '800', color: '#1A1A1A' },
+ subtitle: { marginTop: 6, fontSize: 12, color: '#6B7280' },
+ summaryCard: {
+ marginTop: 16,
+ marginHorizontal: 20,
+ backgroundColor: '#FFFFFF',
+ borderRadius: 16,
+ padding: 16,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3,
+ },
+ summaryLeft: { flexDirection: 'row', alignItems: 'center' },
+ progressPill: { width: 120, height: 10, borderRadius: 999, backgroundColor: '#E5E7EB', overflow: 'hidden' },
+ progressFill: { height: '100%', backgroundColor: '#BBF246' },
+ progressText: { marginLeft: 12, fontWeight: '700', color: '#111827' },
+ summaryRight: {},
+ summaryItem: { fontSize: 12, color: '#6B7280' },
+ summaryItemValue: { fontWeight: '800', color: '#111827' },
+ dayCell: {
+ width: cellSize,
+ height: cellSize,
+ borderRadius: 16,
+ backgroundColor: '#FFFFFF',
+ alignItems: 'center',
+ justifyContent: 'center',
+ shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3,
+ },
+ dayCellLocked: { backgroundColor: '#F3F4F6' },
+ dayCellCompleted: { backgroundColor: '#ECFDF5', borderWidth: 1, borderColor: '#A7F3D0' },
+ dayNumber: { fontWeight: '800', color: '#111827', fontSize: 16 },
+ dayNumberLocked: { color: '#9CA3AF' },
+ dayMinutes: { marginTop: 4, fontSize: 12, color: '#6B7280' },
+ bottomBar: { padding: 20 },
+ startButton: { backgroundColor: '#BBF246', paddingVertical: 14, borderRadius: 999, alignItems: 'center' },
+ startButtonText: { color: '#192126', fontWeight: '800', fontSize: 16 },
+});
+
+
diff --git a/store/challengeSlice.ts b/store/challengeSlice.ts
new file mode 100644
index 0000000..8ddc8df
--- /dev/null
+++ b/store/challengeSlice.ts
@@ -0,0 +1,129 @@
+import { buildDefaultCustomFromPlan, DayPlan, ExerciseCustomConfig, generatePilates30DayPlan, PilatesLevel } from '@/utils/pilatesPlan';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
+
+export type DayStatus = 'locked' | 'available' | 'completed';
+
+export type ChallengeDayState = {
+ plan: DayPlan;
+ status: DayStatus;
+ completedAt?: string | null; // ISO
+ notes?: string;
+ custom?: ExerciseCustomConfig[]; // 用户自定义:启用/禁用、组数、时长
+};
+
+export type ChallengeState = {
+ startedAt?: string | null;
+ level: PilatesLevel;
+ days: ChallengeDayState[]; // 1..30
+ streak: number; // 连续天数
+};
+
+const STORAGE_KEY = '@pilates_challenge_30d';
+
+const initialState: ChallengeState = {
+ startedAt: null,
+ level: 'beginner',
+ days: [],
+ streak: 0,
+};
+
+function computeStreak(days: ChallengeDayState[]): number {
+ // 连续从第1天开始的已完成天数
+ let s = 0;
+ for (let i = 0; i < days.length; i += 1) {
+ if (days[i].status === 'completed') s += 1; else break;
+ }
+ return s;
+}
+
+export const initChallenge = createAsyncThunk(
+ 'challenge/init',
+ async (_: void, { getState }) => {
+ const persisted = await AsyncStorage.getItem(STORAGE_KEY);
+ if (persisted) {
+ try {
+ const parsed = JSON.parse(persisted) as ChallengeState;
+ return parsed;
+ } catch {}
+ }
+ // 默认生成
+ const level: PilatesLevel = 'beginner';
+ const plans = generatePilates30DayPlan(level);
+ const days: ChallengeDayState[] = plans.map((p, idx) => ({
+ plan: p,
+ status: idx === 0 ? 'available' : 'locked',
+ custom: buildDefaultCustomFromPlan(p),
+ }));
+ const state: ChallengeState = {
+ startedAt: new Date().toISOString(),
+ level,
+ days,
+ streak: 0,
+ };
+ await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(state));
+ return state;
+ }
+);
+
+export const persistChallenge = createAsyncThunk(
+ 'challenge/persist',
+ async (_: void, { getState }) => {
+ const s = (getState() as any).challenge as ChallengeState;
+ await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(s));
+ return true;
+ }
+);
+
+export const completeDay = createAsyncThunk(
+ 'challenge/completeDay',
+ async (dayNumber: number, { getState, dispatch }) => {
+ const state = (getState() as any).challenge as ChallengeState;
+ const idx = dayNumber - 1;
+ const days = [...state.days];
+ if (!days[idx] || days[idx].status === 'completed') return state;
+ days[idx] = { ...days[idx], status: 'completed', completedAt: new Date().toISOString() };
+ if (days[idx + 1]) {
+ days[idx + 1] = { ...days[idx + 1], status: 'available' };
+ }
+ const next: ChallengeState = {
+ ...state,
+ days,
+ streak: computeStreak(days),
+ };
+ await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next));
+ return next;
+ }
+);
+
+const challengeSlice = createSlice({
+ name: 'challenge',
+ initialState,
+ reducers: {
+ setLevel(state, action: PayloadAction) {
+ state.level = action.payload;
+ },
+ setNote(state, action: PayloadAction<{ dayNumber: number; notes: string }>) {
+ const idx = action.payload.dayNumber - 1;
+ if (state.days[idx]) state.days[idx].notes = action.payload.notes;
+ },
+ setCustom(state, action: PayloadAction<{ dayNumber: number; custom: ExerciseCustomConfig[] }>) {
+ const idx = action.payload.dayNumber - 1;
+ if (state.days[idx]) state.days[idx].custom = action.payload.custom;
+ },
+ },
+ extraReducers: (builder) => {
+ builder
+ .addCase(initChallenge.fulfilled, (_state, action) => {
+ return action.payload as ChallengeState;
+ })
+ .addCase(completeDay.fulfilled, (_state, action) => {
+ return action.payload as ChallengeState;
+ });
+ },
+});
+
+export const { setLevel, setNote, setCustom } = challengeSlice.actions;
+export default challengeSlice.reducer;
+
+
diff --git a/store/index.ts b/store/index.ts
index 81df940..96bee61 100644
--- a/store/index.ts
+++ b/store/index.ts
@@ -1,9 +1,11 @@
import { configureStore } from '@reduxjs/toolkit';
+import challengeReducer from './challengeSlice';
import userReducer from './userSlice';
export const store = configureStore({
reducer: {
user: userReducer,
+ challenge: challengeReducer,
},
// React Native 环境默认即可
});
diff --git a/utils/pilatesPlan.ts b/utils/pilatesPlan.ts
new file mode 100644
index 0000000..38aa416
--- /dev/null
+++ b/utils/pilatesPlan.ts
@@ -0,0 +1,190 @@
+export type ExerciseSet = {
+ durationSec?: number; // 计时型(秒)
+ reps?: number; // 次数型(可选)
+ restSecAfter?: number; // 组间休息
+};
+
+export type Exercise = {
+ key: string;
+ name: string;
+ description: string;
+ videoUrl?: string;
+ sets: ExerciseSet[];
+ tips?: string[];
+};
+
+export type DayPlan = {
+ dayNumber: number; // 1..30
+ type: 'training' | 'recovery' | 'rest';
+ title: string;
+ focus: string; // 训练重点
+ exercises: Exercise[]; // rest/recovery 可为空数组
+};
+
+export type PilatesLevel = 'beginner' | 'intermediate' | 'advanced';
+
+const BASE_EXERCISES: Omit[] = [
+ {
+ key: 'hundred',
+ name: '百次拍击 (The Hundred)',
+ description: '仰卧,抬腿至桌面位,核心收紧,小幅快速摆动手臂并配合呼吸。',
+ videoUrl: undefined,
+ },
+ {
+ key: 'single_leg_stretch',
+ name: '单腿伸展 (Single Leg Stretch)',
+ description: '仰卧,交替伸直一条腿,另一条腿屈膝抱向胸口,核心稳定。',
+ },
+ {
+ key: 'double_leg_stretch',
+ name: '双腿伸展 (Double Leg Stretch)',
+ description: '仰卧同时伸直双腿与双臂,呼气收回环抱膝盖。',
+ },
+ {
+ key: 'roll_up',
+ name: '卷起 (Roll Up)',
+ description: '由仰卧卷起至坐并前屈,再控制还原,强调脊柱分节活动。',
+ },
+ {
+ key: 'spine_twist',
+ name: '脊柱扭转 (Spine Twist)',
+ description: '坐姿,躯干直立,左右控制旋转,强调轴向延展与核心稳定。',
+ },
+ {
+ key: 'bridge',
+ name: '桥式 (Bridge)',
+ description: '仰卧,卷尾抬起骨盆至肩桥位,感受臀腿后侧发力,控制还原。',
+ },
+ {
+ key: 'side_leg_lift',
+ name: '侧抬腿 (Side Leg Lift)',
+ description: '侧卧,髋稳定,抬高上侧腿,控制下放。',
+ },
+ {
+ key: 'swimming',
+ name: '游泳式 (Swimming)',
+ description: '俯卧,交替抬起对侧手臂与腿,保持脊柱中立并延展。',
+ },
+ {
+ key: 'cat_cow',
+ name: '猫牛式 (Cat-Cow)',
+ description: '四点支撑,呼吸带动脊柱屈伸,作为热身或整理放松。',
+ },
+ {
+ key: 'saw',
+ name: '锯式 (Saw)',
+ description: '坐姿分腿,旋转并前屈,斜向触碰对侧脚尖。',
+ },
+ {
+ key: 'plank',
+ name: '平板支撑 (Plank)',
+ description: '前臂/掌支撑,身体成直线,保持核心紧致。',
+ },
+];
+
+function buildSets(level: PilatesLevel, baseSec: number, setCount: number): ExerciseSet[] {
+ const effort = baseSec + (level === 'intermediate' ? 10 : level === 'advanced' ? 20 : 0);
+ const rest = 15;
+ return Array.from({ length: setCount }).map(() => ({ durationSec: effort, restSecAfter: rest }));
+}
+
+function pickExercises(keys: string[], level: PilatesLevel, baseSec: number, setCount: number): Exercise[] {
+ return keys.map((k) => {
+ const base = BASE_EXERCISES.find((e) => e.key === k)!;
+ return {
+ ...base,
+ sets: buildSets(level, baseSec, setCount),
+ tips: [
+ '保持呼吸与动作节奏一致',
+ '核心始终收紧,避免腰椎塌陷',
+ '以控制优先于速度,专注动作质量',
+ ],
+ };
+ });
+}
+
+export function generatePilates30DayPlan(level: PilatesLevel = 'beginner'): DayPlan[] {
+ // 周期化:每7天安排恢复/拉伸;逐周增加时长或组数
+ const plan: DayPlan[] = [];
+ for (let d = 1; d <= 30; d += 1) {
+ const weekIndex = Math.ceil(d / 7); // 1..5
+ const isRecovery = d % 7 === 0 || d === 14 || d === 21 || d === 28; // 每周末恢复
+
+ if (isRecovery) {
+ plan.push({
+ dayNumber: d,
+ type: 'recovery',
+ title: '恢复与拉伸日',
+ focus: '呼吸、脊柱分节、柔韧性',
+ exercises: pickExercises(['cat_cow', 'saw'], level, 40, 2),
+ });
+ continue;
+ }
+
+ // 训练参数随周数递增
+ const baseSec = 35 + (weekIndex - 1) * 5; // 35,40,45,50,55
+ const setCount = 2 + (weekIndex > 3 ? 1 : 0); // 第4周开始 3 组
+
+ // 交替不同侧重点
+ let title = '核心激活与稳定';
+ let focus = '腹横肌、骨盆稳定、呼吸控制';
+ let keys: string[] = ['hundred', 'single_leg_stretch', 'double_leg_stretch', 'roll_up', 'spine_twist'];
+
+ if (d % 3 === 0) {
+ title = '后链力量与体态';
+ focus = '臀腿后侧、胸椎伸展、姿态矫正';
+ keys = ['bridge', 'swimming', 'plank', 'spine_twist', 'roll_up'];
+ } else if (d % 3 === 2) {
+ title = '髋稳定与侧链';
+ focus = '中臀肌、侧链控制、骨盆稳定';
+ keys = ['side_leg_lift', 'single_leg_stretch', 'bridge', 'saw', 'plank'];
+ }
+
+ const exercises = pickExercises(keys, level, baseSec, setCount);
+ plan.push({ dayNumber: d, type: 'training', title, focus, exercises });
+ }
+ return plan;
+}
+
+export function estimateSessionMinutes(day: DayPlan): number {
+ if (day.type !== 'training' && day.type !== 'recovery') return 0;
+ const totalSec = day.exercises.reduce((sum, ex) => {
+ return (
+ sum +
+ ex.sets.reduce((s, st) => s + (st.durationSec ?? 0) + (st.restSecAfter ?? 0), 0)
+ );
+ }, 0);
+ return Math.max(10, Math.round(totalSec / 60));
+}
+
+// 用户自定义配置(用于挑战页可调节)
+export type ExerciseCustomConfig = {
+ key: string;
+ enabled: boolean;
+ sets: number; // 目标组数(计时型)
+ durationSec?: number; // 每组计时秒数(若为空可按默认)
+ reps?: number; // 每组次数(可选)
+};
+
+export function buildDefaultCustomFromPlan(day: DayPlan): ExerciseCustomConfig[] {
+ return (day.exercises || []).map((ex) => ({
+ key: ex.key,
+ enabled: true,
+ sets: Math.max(1, ex.sets?.length || 2),
+ durationSec: ex.sets?.[0]?.durationSec ?? 40,
+ reps: ex.sets?.[0]?.reps,
+ }));
+}
+
+export function estimateSessionMinutesWithCustom(day: DayPlan, custom: ExerciseCustomConfig[] | undefined): number {
+ if (!custom || custom.length === 0) return estimateSessionMinutes(day);
+ const restSec = 15; // 估算默认休息
+ const totalSec = custom.reduce((sum, c) => {
+ if (!c.enabled) return sum;
+ const perSet = (c.durationSec ?? 40) + restSec;
+ return sum + perSet * Math.max(1, c.sets);
+ }, 0);
+ return Math.max(5, Math.round(totalSec / 60));
+}
+
+